Endpoint telemetry, PC Detail rework and a checked backup/restore path

This commit is contained in:
2026-09-06 18:53:11 -04:00
parent 32b8745d10
commit 993353dfc0
97 changed files with 13340 additions and 756 deletions

View File

@@ -4,6 +4,16 @@ Follow these when suggesting code. They are enforced by a naming/style hook and
by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
`CONTRIBUTING.md` is the full authority; this is the short version.
## General Guidelines
- Finish the in-flight task before starting new workstreams.
- When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies.
- Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F <file>`.
- Avoid using `powershell.exe -File` for launching scripts, as it fails if the script file is missing or quarantined.
## Installer Logic
- The endpoint agent installer shipped to endpoints is generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`, NOT `agent/installer/install-shopdbagent.ps1`. Any installer logic change must be made in `siteagent.py`, followed by rolling the containers.
- The user's workflow: the agent hands over only the built EXEs (`shopdbagent.exe`, `shopdb-endpoint-tray.exe`); the user uploads them via the "Upload New/Legacy Agents" field in the Build Site Agent UI and builds the package with the newest version selected at the top.
## Naming (LOCKED - the hook rejects violations)
- **DB tables**: lowercase, concatenated, plural. No underscores, no dashes.

View File

@@ -171,32 +171,15 @@ Tests are required for:
## Branches: what belongs on main, and what does not
`main` is the curated product other sites adopt, and what the publication
mirror exports. It is a release decision: something reaching `main` says other
sites should take it. **`main` is not what this site runs.**
Each branch answers to one instance:
| branch | runs on | contains |
|---|---|---|
| `dev` | the dev instance | work being validated |
| `wj` | this site's PRODUCTION | everything this site runs |
| `main` | nothing directly | the curated release for other sites |
| `wilm` | the other site | their work, on the corporate GitHub |
So work flows `dev` -> `wj` -> `main`: land it on `dev`, validate it on the dev
instance, take it to `wj` for production, and promote to `main` what you are
happy for another site to run. Promoting first and back-filling the site branch
is the wrong way round - it puts unvalidated work in front of other sites and
leaves production behind.
`wilm` is not visible from the dev box because that machine has no account on
the corporate GitHub, which is why `fetch-wilm-branch.cmd` exists to bring it
back for review. It sits on the publication lineage, so it shares no ancestor
with `main`/`wj`/`dev` and cannot be merged locally.
`main` is the product every site runs and the publication mirror exports.
`<site>` branches hold work that is not ready for other sites to see. There are
two: `wj` here, and `wilm` on the corporate GitHub, where the other site
develops the .NET endpoint agent.
A site branch is not a fork. Both feed `main`, and the sooner they do it the
cheaper it is.
cheaper it is. A branch the dev box cannot see is still a branch: `wilm` is not
visible from here because this machine has no GitHub account, which is why
`fetch-wilm-branch.cmd` on the share exists to bring it back for review.
**Most work is product.** ADR-015 gives site differences three homes on `main`:
a setting with a neutral default, seed data, or a site-namespaced directory

View File

@@ -84,12 +84,23 @@ event channel and system WMI.
## Tray app (system-tray icon)
The tray is a per-user process, not a service, so it starts at sign-in rather
than under `sc.exe`. `install-shopdbagent.ps1` handles it: it copies
`shopdb-endpoint-tray.exe` next to the agent, registers an HKCU logon autostart
(`Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`), and
launches it for the current user. Run the installer from the operator's own
session so the autostart lands in the right user hive. Pass `-NoTray` for a
headless, service-only install.
than under `sc.exe`. The generated `install.ps1` handles it: it copies
`shopdb-endpoint-tray.exe` next to the agent and registers a machine-wide logon
autostart (`HKLM\Software\Microsoft\Windows\CurrentVersion\Run\ShopDbEndpointTray`,
64-bit view). The installer deliberately does NOT start the tray itself: it runs
elevated, so any process it starts inherits an admin token, and mapped drives are
per logon token - an elevated tray probes the wrong drive table. The Run key only
fires at logon, which would leave the tray dead until sign-out on a machine
someone is already using, so the installer also registers a transient
`ShopDB tray first launch` scheduled task under `S-1-5-32-545` at `RunLevel
Limited`, runs it once, and unregisters it. That task is the only mechanism that
can reach into an existing interactive session without carrying the installer's
token. Pass `-NoTray` for a headless, service-only install.
User-context collection does not depend on the tray. The installer registers a
`ShopDB user-context probe` scheduled task against the Users group at
`RunLevel Limited`, which runs `shopdbagent.exe --collect-usercontext` in the
operator's own session at logon and every 30 minutes.
Right-click the tray icon for:
@@ -143,7 +154,7 @@ logic itself changes.
## Diagnostics
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\agent-YYYYMMDD.log`
- Rolling file log: `%ProgramData%\ShopDB\EndpointAgent\logs\YYYYMMDD-agent.log`
(14-day retention).
- Windows Event Log: agent runtime failures and unhandled exceptions are written
to the `Application` log under source `ShopDB Endpoint Agent`. The source is

View File

@@ -89,38 +89,44 @@ public sealed class AgentConfig
return bare;
}
// Per-name allowlist of pre-staged scripts for the generic run_script
// action. Exactly like LaunchPaths, the wire never carries a script body or
// path - only a script NAME the agent already holds here. A name absent
// from this map fails closed. There are no shipped defaults: a script is a
// site-specific artifact, so an empty map means run_script does nothing
// until a site stages scripts and lists them in appsettings.json. Keyed by
// script name (case-insensitive, .ps1 optional).
[JsonPropertyName("scriptpaths")]
public Dictionary<string, string> ScriptPaths { get; set; } = new();
// REMOVED DELIBERATELY - DO NOT REINTRODUCE: 'scriptpaths' / ScriptPaths.
//
// This used to be a per-name map of PRE-STAGED scripts on the endpoint
// (typically under C:\ProgramData\ShopDB\EndpointAgent\scripts), which
// run_script would look up by name and execute. It was superseded by
// server-hosted delivery and left behind as dead config: nothing read the
// map, so it was an execution path that appeared supported but was not.
//
// It is gone rather than merely unused because reviving it is a security
// regression, and dead code invites revival. That model had NO integrity
// check - no digest, no provenance - so whoever could write into the staging
// directory chose what the agent ran as SYSTEM. ProgramData is world-
// writable by default unless the tree is explicitly tightened, which made
// the gate "is this name in appsettings.json", not "is this content
// trusted".
//
// The supported path is run_script with a server-issued delivery descriptor:
// the body is fetched from ShopDB over the collector credential and verified
// against a server-pinned SHA-256 before it executes, so the content is
// authenticated and the action is attributable in the command log. If you
// need a script to run on an endpoint, publish it server-side and send it
// through that path. If you need to trigger something already installed on
// the box, use scheduledtask_run, which is far narrower.
// The effective script map: appsettings.json entries only (no defaults).
// Keys are normalized to bare names so a lookup tolerates 'reset' or
// 'reset.ps1'.
public IReadOnlyDictionary<string, string> ResolveScriptPaths()
{
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in ScriptPaths)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
continue;
resolved[NormalizeScript(pair.Key)] = pair.Value;
}
return resolved;
}
private static string NormalizeScript(string name)
{
var bare = (name ?? "").Trim();
if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
bare = bare[..^4];
return bare;
}
// Approved scheduled task paths for the scheduledtask_run action. The wire
// carries a free-text task path so the WebUI can expose it as a single
// field; ScheduledTaskPolicy validates it against this list and fails closed
// when it does not match. An entry matches that exact task path, or acts as
// a folder prefix for anything beneath it.
//
// Empty means "use the shipped Corporate Tools defaults"
// (ScheduledTaskPolicy.DefaultApprovedTaskPaths). Setting it REPLACES those
// defaults, so a site can narrow the set as well as widen it without an
// agent rebuild. Keep it tight: every entry here is something an operator
// holding endpoints.deploy can start, in whatever context the task itself
// registered.
[JsonPropertyName("approvedtaskpaths")]
public List<string> ApprovedTaskPaths { get; set; } = new();
public bool IsUsable()
=> !string.IsNullOrWhiteSpace(BaseUrl)

View File

@@ -64,6 +64,10 @@ public sealed class CommandExecutor
// before they are honored. Local launchPaths always win over a server path.
private readonly LaunchPathPolicy launchPathPolicy;
// Validates the free-text task path carried by scheduledtask_run before the
// agent will start anything. Fails closed: an unapproved path never runs.
private readonly ScheduledTaskPolicy scheduledTaskPolicy;
// 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.
@@ -77,6 +81,7 @@ public sealed class CommandExecutor
this.config = config ?? new AgentConfig();
this.launchPaths = this.config.ResolveLaunchPaths();
this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots);
this.scheduledTaskPolicy = new ScheduledTaskPolicy(this.config.ApprovedTaskPaths);
}
public async Task<CommandResult> ExecuteAsync(
@@ -116,6 +121,16 @@ public sealed class CommandExecutor
return RequireTarget(action, target)
?? await ProcessStart(target, token);
// Start an already-registered scheduled task by path. Deliberately
// narrow: the agent does not create, modify, or define tasks, it
// only starts one that already exists AND clears the approved-task
// policy. Prefer this over run_script whenever the work is already
// packaged as a task - it is a far smaller grant than shipping a
// script body, and the command log names exactly what ran.
case "scheduledtask_run":
return RequireTarget(action, target)
?? await ScheduledTaskRun(target, token);
// Software delivery. run_script fetches a ShopDB-hosted script
// pinned by SHA-256 and runs it in the script's configured context
// (system or the interactive user); update_app fetches a ShopDB
@@ -144,6 +159,14 @@ public sealed class CommandExecutor
Message = "Fresh telemetry collection requested",
};
// Full TCP connection table, on demand only. Deliberately not part of
// the standard report: established connections churn every second, so
// sending them on cadence would bloat every payload and swamp the
// stable listener list. As a command the technician pays that cost
// once, at the moment they are actually diagnosing connectivity.
case "network_connections":
return NetworkConnections();
default:
logger.LogWarning("Rejected unknown command action {Action}", action);
return new CommandResult
@@ -154,6 +177,98 @@ public sealed class CommandExecutor
}
}
// Snapshot of every TCP connection with its owning process. Capped because a
// busy machine can hold thousands of sockets and an unbounded result would
// be rejected by the server's payload limit, returning nothing at all -
// a truncated answer is far more useful than a failed one.
private const int MaxReportedConnections = 500;
private CommandResult NetworkConnections()
{
try
{
var rows = TcpTableReader.GetTcpTable();
var names = GetProcessNameSnapshot();
var connections = new List<object>();
var truncated = false;
foreach (var row in rows)
{
if (connections.Count >= MaxReportedConnections)
{
truncated = true;
break;
}
names.TryGetValue(row.ProcessId, out var processName);
connections.Add(new Dictionary<string, object?>
{
["localaddress"] = row.LocalAddress,
["localport"] = row.LocalPort,
["remoteaddress"] = row.RemoteAddress,
["remoteport"] = row.RemotePort,
["state"] = row.State.ToString().ToLowerInvariant(),
["processid"] = row.ProcessId,
["processname"] = processName,
});
}
logger.LogInformation(
"network_connections returned {Count} connections (truncated: {Truncated})",
connections.Count, truncated);
return new CommandResult
{
Succeeded = true,
Message = $"Collected {connections.Count} TCP connections"
+ (truncated ? " (truncated)" : ""),
Detail = new Dictionary<string, object>
{
["connections"] = connections,
["totalcount"] = rows.Count,
["truncated"] = truncated,
},
};
}
catch (Exception exception)
{
logger.LogWarning(exception, "network_connections failed");
return new CommandResult
{
Succeeded = false,
Message = "Could not read the TCP connection table: " + exception.Message,
};
}
}
private Dictionary<int, string> GetProcessNameSnapshot()
{
var names = new Dictionary<int, string>();
try
{
foreach (var process in Process.GetProcesses())
{
try
{
names[process.Id] = process.ProcessName;
}
catch
{
// The process exited between enumeration and read.
}
finally
{
process.Dispose();
}
}
}
catch (Exception exception)
{
logger.LogDebug(exception, "Process name snapshot failed");
}
return names;
}
// Guard: a generic action with no target has nothing to act on. Returns a
// failed result to short-circuit, or null when the target is present.
private CommandResult? RequireTarget(string action, string target)
@@ -550,6 +665,139 @@ public sealed class CommandExecutor
return found;
}
// ---- Scheduled tasks ----
// Upper bound on how long a task start may block the command worker. This
// waits only for schtasks.exe to hand the request to the Task Scheduler
// service, NOT for the task's own work to finish - schtasks /run returns as
// soon as the task is launched. So success here means "the task was
// started", not "the task succeeded", and the timeout only needs to cover
// the handoff.
private static readonly TimeSpan ScheduledTaskStartTimeout = TimeSpan.FromSeconds(30);
// Start an already-registered scheduled task by path, after the policy
// approves it. Uses schtasks.exe with ArgumentList (no shell, no string
// concatenation) so the task path is passed as a single argv entry and
// cannot break out into additional arguments.
//
// The task runs in whatever security context its own registered definition
// specifies - SYSTEM, an interactive user, whatever principal was set when
// the task was created. The agent does not and cannot change that, which is
// exactly why this action is narrower than run_script: the endpoint owns the
// definition of what happens, the server only asks for it to happen now.
private async Task<CommandResult> ScheduledTaskRun(string target, CancellationToken token)
{
if (!scheduledTaskPolicy.TryValidate(target, out var taskPath, out var reason))
{
logger.LogWarning(
"Rejected scheduledtask_run for {Target}: {Reason}", target, reason);
return new CommandResult
{
Succeeded = false,
Message = $"Scheduled task '{target}' rejected: {reason}",
};
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = "schtasks.exe",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
startInfo.ArgumentList.Add("/run");
startInfo.ArgumentList.Add("/tn");
startInfo.ArgumentList.Add(taskPath);
using var process = Process.Start(startInfo);
if (process is null)
{
return new CommandResult
{
Succeeded = false,
Message = $"Failed to start schtasks.exe for task {taskPath}",
};
}
var stdout = "";
var stderr = "";
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(ScheduledTaskStartTimeout);
try
{
await process.WaitForExitAsync(timeout.Token);
// Read only after exit. Reading to end first would block until
// the streams close - which IS process exit - so the timeout
// above could never fire. Safe here because schtasks emits a
// line or two, far below the pipe buffer that would deadlock a
// chattier child.
stdout = await process.StandardOutput.ReadToEndAsync(token);
stderr = await process.StandardError.ReadToEndAsync(token);
}
catch (OperationCanceledException) when (!token.IsCancellationRequested)
{
TryKill(process);
return new CommandResult
{
Succeeded = false,
Message = $"Timed out starting scheduled task {taskPath}",
};
}
var exit = process.ExitCode;
var ok = exit == 0;
// schtasks writes the useful diagnosis (task not found, access
// denied, disabled) to its output, so surface it rather than making
// the operator guess from a bare exit code.
var detail = (stderr.Trim().Length > 0 ? stderr.Trim() : stdout.Trim());
if (ok)
{
logger.LogInformation("Started scheduled task {TaskPath}", taskPath);
}
else
{
logger.LogWarning(
"schtasks /run for {TaskPath} exited {Exit}: {Detail}",
taskPath, exit, detail);
}
return new CommandResult
{
Succeeded = ok,
Message = ok
? $"Started scheduled task {taskPath}"
: $"Scheduled task {taskPath} failed to start (exit {exit})"
+ (detail.Length > 0 ? $": {detail}" : ""),
};
}
catch (Exception error)
{
logger.LogWarning(error, "scheduledtask_run for {TaskPath} failed", taskPath);
return new CommandResult
{
Succeeded = false,
Message = $"Scheduled task {taskPath} failed: {error.Message}",
};
}
}
private void TryKill(Process process)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch (Exception error)
{
logger.LogDebug(error, "Could not kill timed-out schtasks.exe");
}
}
// ---- Software delivery: run_script and update_app ----
// Run a ShopDB-hosted script pinned by the server's delivery descriptor. The
@@ -559,6 +807,26 @@ public sealed class CommandExecutor
// 'system' through the agent's own service account, or 'user' in the active
// console session so the script sees the interactive user's resources (for
// example mapped network drives). Anything missing or mismatched fails closed.
//
// SECURITY CONTRACT - read before changing this method. This is arbitrary
// code execution on the endpoint, by design, and the agent is a SYSTEM
// service: a script whose descriptor does not pin the user context runs with
// full machine privilege. Three properties make that acceptable, and all
// three are load-bearing:
//
// 1. The body is never carried on the command wire. The agent fetches it
// itself from ShopDB using its own collector credential, so queueing a
// command cannot smuggle in code.
// 2. The body is verified against the server-pinned SHA-256 before it
// runs. A tampered or swapped body fails closed.
// 3. The action is gated on endpoints.deploy server-side and is meant to
// sit behind an explicit UI confirmation, the same as system_restart.
//
// Do not add a path that runs a script the agent found locally rather than
// fetched and verified - that was the old pre-staged 'scriptpaths' model and
// it was removed on purpose (see the tombstone in AgentConfig). If the goal
// is to trigger something already installed on the endpoint, use
// scheduledtask_run instead: it is a much smaller grant.
private async Task<CommandResult> RunScript(
string name, ScriptDeliveryClaim? script, CancellationToken token)
{
@@ -714,6 +982,12 @@ public sealed class CommandExecutor
// to exit and returns its real exit code, so success here means the script
// actually ran to a zero exit - not merely that it was launched. Fails closed
// when no interactive user is signed in.
// Upper bound on how long a user-session script may block the command
// worker. Generous, because a staged maintenance script can legitimately run
// for minutes, but finite: before this existed the wait was Infinite and a
// hung script pinned the caller forever.
private static readonly TimeSpan UserScriptWaitTimeout = TimeSpan.FromMinutes(5);
private CommandResult RunScriptInUserSession(
string name, ScriptDeliveryClaim script, string scriptPath)
{
@@ -723,7 +997,8 @@ public sealed class CommandExecutor
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
"powershell.exe", arguments, workingDirectory, waitForExit: true);
"powershell.exe", arguments, workingDirectory, waitForExit: true,
waitTimeout: UserScriptWaitTimeout);
if (!outcome.Launched)
{
logger.LogWarning(
@@ -737,6 +1012,23 @@ public sealed class CommandExecutor
};
}
if (outcome.TimedOut)
{
// The script is still running; we simply stopped waiting. Report it
// as unresolved rather than success, because we never saw an exit
// code and cannot claim it worked.
logger.LogWarning(
"run_script for {Name} still running after {Timeout}; stopped waiting",
name, UserScriptWaitTimeout);
return new CommandResult
{
Succeeded = false,
Message = $"Script {name} did not finish within "
+ $"{UserScriptWaitTimeout.TotalMinutes:0} minutes and is still running",
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
};
}
var exit = outcome.ExitCode;
var ok = exit is null || exit == 0;
return new CommandResult

View File

@@ -0,0 +1,51 @@
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Core;
// Hides the console window this process was given.
//
// shopdbagent.exe is a console executable, so anything that starts it in an
// interactive session gets a visible black window. That was invisible while the
// tray owned the probe - it launched with CreateNoWindow - but the user-context
// scheduled task runs the exe directly in the operator's session, where the
// window is very much visible and pops up on every collection cycle.
//
// The window is hidden rather than the exe rebuilt as a WinExe: the agent is
// also a service host and a command-line tool, and both of those want a real
// console for --once, --report and service debugging.
public static class ConsoleWindow
{
private const int SW_HIDE = 0;
[DllImport("kernel32.dll")]
private static extern nint GetConsoleWindow();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(nint window, int command);
// Best-effort: a process with no console (already detached, or redirected by
// a caller) simply has nothing to hide, and failing to hide a window must
// never stop the collection that window belongs to.
public static void Hide()
{
if (!OperatingSystem.IsWindows())
{
return;
}
try
{
var window = GetConsoleWindow();
if (window != nint.Zero)
{
ShowWindow(window, SW_HIDE);
}
}
catch
{
// Nothing useful to do or say: there is no log sink yet at the point
// this runs, and a visible window is cosmetic next to losing data.
}
}
}

View File

@@ -44,11 +44,57 @@ public sealed class EndpointReport
// collection: both Uninstall registry hives, {displayname, displayversion,
// publisher}. Server logs it as an unpersisted key until the SBOM store lands.
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
// True when Windows is holding a reboot. A machine that has been waiting to
// reboot for weeks explains stalled patching and "the fix did not take"
// reports, and on a shop floor PC nobody reboots voluntarily.
[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
// Which signal(s) drove pendingreboot, so the server can tell a box staged
// mid-servicing (pendingfilerename) from one with a stale servicing key.
// Empty array = checked, nothing pending. Omitted = the check failed.
[JsonPropertyName("rebootreasons")] public string[]? RebootReasons { get; set; }
// hiberbootenabled (fast startup configured) and lastboottype (what the
// last boot actually did). Uptime is not trustworthy when these disagree.
[JsonPropertyName("bootinfo")] public object? BootInfo { get; set; }
// Defender health. Persisted but deliberately not surfaced: known false
// positives in this environment.
[JsonPropertyName("defender")] public object? Defender { get; set; }
// Physical drive health from two sources: MSFT_PhysicalDisk (the only one
// that reports NVMe) and Win32_DiskDrive.Status (ATA/SATA and fallback).
// Each entry carries a precomputed predictfailure so an alert is a boolean
// test rather than a string match against an enum. Null predictfailure
// means the drive did not answer - unknown, not healthy.
[JsonPropertyName("diskhealth")] public object[]? DiskHealth { get; set; }
// Session-scoped data gathered by a probe running as the signed-in user:
// mapped drives and user-owned listeners. Null when nobody is signed in or
// the probe did not produce a result this cycle - absent means "not
// observed", which is deliberately distinct from an empty list.
[JsonPropertyName("usercontext")] public object? UserContext { get; set; }
// Pending Dell Command | Update results, produced by the shopdb-dellupdates
// agent utility rather than collected in C#: the data comes from dcu-cli's
// own scan output, so the utility that parses it belongs next to the task
// that writes it. Null means no scan has ever run (or the utility is not
// installed), which is deliberately distinct from a scan that found nothing
// pending - the first is "unknown", the second is "known clean".
[JsonPropertyName("dellupdates")] public object? DellUpdates { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
// The Windows event record id: monotonic and unique within its channel on a
// given machine. It is both the agent's harvest watermark and the server's
// dedupe key, so the same record can never be stored twice. Null only when
// the source record did not expose one.
[JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }

View File

@@ -27,7 +27,15 @@ public static class CuratedEventCatalog
new(55, "System", "NTFS file system corruption"),
new(7011, "System", "Service Control Manager timeout"),
new(10016, "System", "DCOM permission error"),
// Windows Error Reporting on the Application channel: 1000 is the
// faulting-application record (app, version, faulting module, exception
// code), 1001 is the WER bucket record Windows pairs with it, and 1002
// is an unresponsive app rather than a crash. The structured detail for
// all three arrives in the record's EventData, which the collector lifts
// into `fields`.
new(1000, "Application", "Application error / faulting module"),
new(1001, "Application", "Application error / bugcheck report"),
new(1002, "Application", "Application hang"),
};
// The distinct channels the collector must read.

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Diagnostics.Eventing.Reader;
using System.Management;
using System.Text.Json;
using Microsoft.Extensions.Logging;
@@ -19,16 +20,28 @@ public sealed class EndpointCollector : ICollector
// keep the payload bounded on a chatty Security log.
private const int MaxEventsPerChannel = 25;
// The spool file is read every cycle. Reading it is a small file read, and
// the tray - not this cadence - decides how often the data is refreshed, so
// throttling the read here would only delay already-collected data reaching
// the server without saving any real work.
private readonly ILogger<EndpointCollector> logger;
private readonly AgentConfig config;
private readonly IAgentUtilityRunner? utilities;
private readonly EventWatermarkStore? watermarks;
private readonly UserContextSpool? userContextSpool;
private object? lastUserContext;
public EndpointCollector(ILogger<EndpointCollector> logger, AgentConfig config,
IAgentUtilityRunner? utilities = null)
IAgentUtilityRunner? utilities = null, EventWatermarkStore? watermarks = null,
UserContextSpool? userContextSpool = null)
{
this.logger = logger;
this.config = config;
this.utilities = utilities;
this.watermarks = watermarks;
this.userContextSpool = userContextSpool;
}
public EndpointReport Collect(string agentVersion, bool adopt)
@@ -49,23 +62,232 @@ public sealed class EndpointCollector : ICollector
MachineNumber = GetMachineNumber(),
LastBootTime = GetLastBootTime(),
Services = GetMonitoredTargets(),
Ports = GetListeningPorts(),
Interfaces = GetInterfaces(),
Events = GetCuratedEvents(),
Disks = GetDisks(),
DiskHealth = GetDiskHealth(),
Bios = GetBios(),
Memory = GetMemory(),
Processor = GetProcessor(),
OsInfo = GetOsInfo(),
Domain = GetDomain(),
Applications = GetApplications(),
PendingReboot = GetPendingReboot(),
RebootReasons = GetRebootReasons(),
BootInfo = GetBootInfo(),
Defender = GetDefender(),
UserContext = GetUserContext(),
};
if (report.LastBootTime is DateTimeOffset boot)
report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds;
report.CpuPercent = GetCpuPercent();
report.MemoryPercent = GetMemoryPercent();
report.IdleSeconds = GetIdleSecondsFromUserContext();
return report;
}
// Instantaneous CPU load from the _Total performance instance.
//
// Uses the WMI formatted-data class rather than PerformanceCounter because a
// PerformanceCounter reads 0 on its first sample and needs a second sample a
// second later to mean anything. Sleeping a second inside the collect path
// would delay the heartbeat on every tick for one number. The formatted-data
// class is already averaged by the OS, so a single read is usable.
private double? GetCpuPercent()
{
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT PercentProcessorTime FROM " +
"Win32_PerfFormattedData_PerfOS_Processor WHERE Name='_Total'");
foreach (var mo in searcher.Get())
{
var value = ToLong(mo["PercentProcessorTime"]);
if (value is long percent)
return Math.Clamp(percent, 0, 100);
}
}
catch (Exception error)
{
logger.LogDebug(error, "CPU percent query failed");
}
return null;
}
// Percentage of physical memory in use. Reported alongside the absolute
// byte counts in the memory object because the percentage is what an alert
// threshold keys off, and deriving it server-side would break whenever the
// byte fields are absent.
private double? GetMemoryPercent()
{
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT TotalVisibleMemorySize, FreePhysicalMemory " +
"FROM Win32_OperatingSystem");
foreach (var mo in searcher.Get())
{
var totalKb = ToLong(mo["TotalVisibleMemorySize"]);
var freeKb = ToLong(mo["FreePhysicalMemory"]);
if (totalKb is long total && total > 0 && freeKb is long free)
{
var used = (double)(total - free) / total * 100.0;
return Math.Round(Math.Clamp(used, 0, 100), 1);
}
}
}
catch (Exception error)
{
logger.LogDebug(error, "Memory percent query failed");
}
return null;
}
// Idle time is measured by the user-context probe, because GetLastInputInfo
// is per session and this service runs in session 0. Null when no probe
// result is available, which correctly reads as "not observed" rather than
// "the machine has been idle for zero seconds".
private long? GetIdleSecondsFromUserContext()
{
if (lastUserContext is not Dictionary<string, object?> context)
return null;
if (!context.TryGetValue("idleseconds", out var raw) || raw is null)
return null;
try
{
if (raw is JsonElement element)
{
return element.ValueKind == JsonValueKind.Number
&& element.TryGetInt64(out var parsed) ? parsed : null;
}
return Convert.ToInt64(raw);
}
catch (Exception error)
{
logger.LogDebug(error, "Could not read idleseconds from the user context");
return null;
}
}
// Listening TCP ports with the owning process name.
//
// Only listeners, never established connections: listeners answer "what
// service is this machine offering" (the DNC port, the MTConnect adapter),
// which is stable and small. Established connections churn constantly and
// would make every report differ from the last for no diagnostic gain, so
// the full table is exposed as an on-demand command instead.
private const int MaxListeners = 100;
private object[] GetListeningPorts()
{
try
{
var listeners = TcpTableReader.GetListeners();
if (listeners.Count == 0)
return Array.Empty<object>();
var names = GetProcessNames();
var result = new List<object>();
foreach (var listener in listeners)
{
if (result.Count >= MaxListeners)
{
logger.LogDebug(
"Listener list truncated at {Limit} entries.", MaxListeners);
break;
}
names.TryGetValue(listener.ProcessId, out var processName);
result.Add(new
{
localaddress = listener.LocalAddress,
localport = listener.LocalPort,
processid = listener.ProcessId,
processname = processName,
protocol = "tcp",
});
}
return result.ToArray();
}
catch (Exception error)
{
logger.LogDebug(error, "Listening port enumeration failed");
return Array.Empty<object>();
}
}
// Snapshot the process table once and index by id. Resolving each listener
// individually would be an O(n) call per row and can race with process exit.
private Dictionary<int, string> GetProcessNames()
{
var names = new Dictionary<int, string>();
try
{
foreach (var process in Process.GetProcesses())
{
try
{
names[process.Id] = process.ProcessName;
}
catch
{
// The process exited between enumeration and read.
}
finally
{
process.Dispose();
}
}
}
catch (Exception error)
{
logger.LogDebug(error, "Process name snapshot failed");
}
return names;
}
// Session-scoped data, read from the spool file the tray writes. The service
// only reads: it cannot collect this itself, because mapped drives and idle
// time do not exist in session 0. Between reads the previous result is
// repeated, because a mapped drive stays mapped and dropping the key would
// read on the server as "the drive went away". The spool's own age limit is
// what eventually retires a result, so a logged-off machine goes quiet
// instead of reporting the last operator forever.
private object? GetUserContext()
{
if (userContextSpool is null)
return null;
var resultPath = userContextSpool.TryGetResultPath(out var reason);
if (resultPath is null)
{
logger.LogDebug("Skipping the user-context read: {Reason}", reason);
return lastUserContext;
}
// Peek rather than consume: the probe overwrites the spool on every run,
// so the newest result is always the one on disk. The file deliberately
// survives a service restart, which the previous delete-on-read did not -
// lastUserContext is in-memory only, so a restart used to leave the agent
// with no drives to report until the tray's next probe up to 30 minutes
// later. lastUserContext still repeats the previous value between reads,
// because the tray collects on a slower cadence than telemetry and
// dropping the key in between would read on the server as "the drives
// went away".
var collected = userContextSpool.ConsumeResult(resultPath);
if (collected is not null)
lastUserContext = collected;
return lastUserContext;
}
// The interactive tray app ships and updates independently of this service,
// so the agent reports the tray's file version as its own field. Resolve the
// tray exe beside the agent first (the normal install layout), then the
@@ -296,6 +518,7 @@ public sealed class EndpointCollector : ICollector
private object[] GetInterfaces()
{
var result = new List<object>();
var linkstate = GetLinkState();
try
{
using var searcher = new ManagementObjectSearcher(
@@ -321,6 +544,10 @@ public sealed class EndpointCollector : ICollector
var gateway = gateways is null || gateways.Length == 0
? null : gateways[0];
// Paired by MAC from MSFT_NetAdapter. Absent when that class is
// unavailable, which must leave the rest of the entry intact.
linkstate.TryGetValue(NormalizeMac(mac) ?? "", out var link);
result.Add(new
{
alias = mo["Description"]?.ToString()?.Trim() ?? "",
@@ -332,6 +559,10 @@ public sealed class EndpointCollector : ICollector
gateway = gateway ?? "",
subnet = subnet ?? "",
dhcp = mo["DHCPEnabled"] as bool? ?? false,
// Null rather than 0/false when link state was not
// readable: an unknown duplex must not read as half.
linkspeedbps = link.Speed,
fullduplex = link.FullDuplex,
});
}
}
@@ -451,40 +682,33 @@ public sealed class EndpointCollector : ICollector
}
}
// Walk each curated channel newest-first, keep only the curated IDs for
// that channel, cap per channel. Reads Security/System/Application - the
// old scaffold read System only and filtered by the wrong IDs.
// Walk each curated channel newest-first and report ONLY records newer than
// the stored watermark, so a record crosses the wire exactly once.
//
// Two things changed here versus the original scaffold. First, filtering by
// event id now happens inside the event log service through an XPath query
// instead of walking every entry in the channel from C#; on a busy Security
// log that is the difference between touching a handful of records and
// hundreds of thousands. Second, EventLogReader (not the legacy EventLog
// API) exposes RecordId, which is what makes exactly-once possible.
//
// First contact on a channel has no watermark, so it sends a bounded tail
// (MaxEventsPerChannel) for initial context rather than the whole backlog.
// The same cap also bounds a burst: if more than the cap arrived since the
// last cycle, the NEWEST are taken and the watermark still jumps to the
// newest, so the payload can never balloon.
private EndpointEventRecord[] GetCuratedEvents()
{
var records = new List<EndpointEventRecord>();
foreach (var channel in CuratedEventCatalog.Channels)
{
var wanted = CuratedEventCatalog.IdsForChannel(channel);
if (wanted.Count == 0)
continue;
try
{
using var log = new EventLog(channel);
int kept = 0;
for (int i = log.Entries.Count - 1;
i >= 0 && kept < MaxEventsPerChannel; i--)
{
EventLogEntry entry;
try { entry = log.Entries[i]; } catch { continue; }
int id = (int)(entry.InstanceId & 0xFFFF);
if (!wanted.Contains(id))
continue;
records.Add(new EndpointEventRecord
{
EventId = id,
EventChannel = channel,
EventSource = entry.Source,
EventTime = new DateTimeOffset(
entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero),
EventMessage = Truncate(entry.Message, 1000),
});
kept++;
}
records.AddRange(ReadChannel(channel, wanted));
}
catch (Exception error)
{
@@ -493,9 +717,172 @@ public sealed class EndpointCollector : ICollector
logger.LogDebug(error, "Event channel {Channel} unreadable", channel);
}
}
watermarks?.Save();
return records.ToArray();
}
private IEnumerable<EndpointEventRecord> ReadChannel(
string channel, IReadOnlySet<int> wanted)
{
long watermark = watermarks?.GetWatermark(channel) ?? 0;
bool seeding = watermarks is null || !watermarks.HasWatermark(channel);
var query = new EventLogQuery(channel, PathType.LogName, BuildIdFilter(wanted))
{
// Newest-first so the cap keeps the most recent records and we can
// stop as soon as we reach the watermark.
ReverseDirection = true,
};
var found = new List<EndpointEventRecord>();
long newestSeen = 0;
using var reader = new EventLogReader(query);
while (found.Count < MaxEventsPerChannel)
{
EventRecord? entry;
try { entry = reader.ReadEvent(); }
catch (Exception error)
{
logger.LogDebug(error, "Event read failed on {Channel}", channel);
break;
}
if (entry is null)
break;
using (entry)
{
long recordId = entry.RecordId ?? 0;
if (recordId > newestSeen)
newestSeen = recordId;
// Reverse order means everything from here down is older than
// the watermark and has already been reported.
if (!seeding && recordId != 0 && recordId <= watermark)
break;
found.Add(ToEventRecord(entry, channel, recordId));
}
}
// A cleared log restarts record ids low; reseed rather than go silent.
if (!seeding && newestSeen > 0
&& watermarks is not null && watermarks.ResetIfStale(channel, newestSeen))
{
return found;
}
if (newestSeen > 0)
watermarks?.Advance(channel, newestSeen);
return found;
}
private EndpointEventRecord ToEventRecord(
EventRecord entry, string channel, long recordId)
{
string? message = null;
try { message = entry.FormatDescription(); }
catch (Exception error)
{
// A provider whose message DLL is missing cannot render text; the
// id, time, and source are still worth reporting.
logger.LogDebug(error, "Event message not renderable on {Channel}", channel);
}
return new EndpointEventRecord
{
EventId = entry.Id,
EventRecordId = recordId == 0 ? null : recordId,
EventChannel = channel,
EventSource = entry.ProviderName,
EventTime = entry.TimeCreated is DateTime created
? new DateTimeOffset(created.ToUniversalTime(), TimeSpan.Zero)
: null,
EventMessage = Truncate(message, 1000),
Fields = GetEventFields(entry, channel),
};
}
// Bounds on the structured field extraction. The rendered message is often
// a paragraph of prose while the useful specifics (which account, which
// logon type, which faulting module) live in EventData, so it is worth
// carrying - but a single record can declare a lot of elements and some
// providers emit large blobs, so both the count and each value are capped.
private const int MaxEventFields = 30;
private const int MaxEventFieldLength = 512;
// Pull the provider's structured payload out of the event XML into the
// existing `fields` dictionary, which the server already stores as
// payloadjson. Most providers use <EventData><Data Name="x">v</Data></>;
// some use <UserData> with an arbitrary provider-defined subtree, so both
// are walked. Unnamed <Data> elements keep their positional index as the
// key, because for several catalog IDs position IS the meaning.
//
// Never throws: a provider with malformed or unreadable XML degrades to no
// fields rather than losing the whole record.
private Dictionary<string, object>? GetEventFields(EventRecord entry, string channel)
{
try
{
var xml = entry.ToXml();
if (string.IsNullOrWhiteSpace(xml))
return null;
var document = System.Xml.Linq.XDocument.Parse(xml);
var root = document.Root;
if (root is null)
return null;
var container =
root.Elements().FirstOrDefault(e => e.Name.LocalName == "EventData")
?? root.Elements().FirstOrDefault(e => e.Name.LocalName == "UserData");
if (container is null)
return null;
var fields = new Dictionary<string, object>();
int unnamed = 0;
foreach (var element in container.Descendants())
{
if (fields.Count >= MaxEventFields)
break;
if (element.HasElements)
continue;
var value = element.Value;
if (string.IsNullOrWhiteSpace(value))
continue;
var name = element.Attribute("Name")?.Value;
if (string.IsNullOrWhiteSpace(name))
{
name = element.Name.LocalName == "Data"
? "data" + unnamed++
: element.Name.LocalName;
}
fields[name!] = Truncate(value, MaxEventFieldLength) ?? string.Empty;
}
return fields.Count > 0 ? fields : null;
}
catch (Exception error)
{
logger.LogDebug(error, "Event XML not parseable on {Channel}", channel);
return null;
}
}
// An XPath the event log service evaluates itself, e.g.
// *[System[(EventID=4624 or EventID=4625)]]
private static string BuildIdFilter(IReadOnlySet<int> wanted)
{
var clauses = string.Join(" or ", wanted.Select(id => "EventID=" + id));
return "*[System[(" + clauses + ")]]";
}
// Installed-software SBOM. Mirrors the Assess-Orchestrator applications
// collection exactly: enumerate both Uninstall registry hives (64-bit and
// WOW6432Node), keep only entries with a DisplayName, and emit
@@ -566,6 +953,243 @@ public sealed class EndpointCollector : ICollector
}
}
// Whether Windows is waiting on a reboot.
//
// There is no single API for this, so we check the three signals Windows
// itself uses: the CBS servicing flag, the Windows Update auto-update flag,
// and a queued file-rename operation. Any one of them is sufficient. Checked
// by key EXISTENCE rather than value, which is how these flags work - the
// key is created when a reboot is needed and deleted once it happens.
// Link speed and duplex, keyed by MAC so the caller can pair them onto the
// matching GetInterfaces() entry. Deliberately a separate query: the
// IPEnabled config class above does not carry link state, and a NIC that
// negotiated 100Mb half-duplex on a gigabit switch port is a cable/port
// fault that otherwise presents as "the machine is slow sometimes".
private Dictionary<string, (long? Speed, bool? FullDuplex)> GetLinkState()
{
var byMac = new Dictionary<string, (long?, bool?)>(
StringComparer.OrdinalIgnoreCase);
// MSFT_NetAdapter carries duplex; Win32_NetworkAdapter does not. It
// lives in the same Storage-style optional namespace family, so a
// failure here must not take the interface list with it.
try
{
using var searcher = new ManagementObjectSearcher(
@"root\StandardCimv2",
"SELECT PermanentAddress, Speed, FullDuplex, MediaConnectState " +
"FROM MSFT_NetAdapter");
foreach (var mo in searcher.Get())
{
var mac = NormalizeMac(mo["PermanentAddress"]?.ToString());
if (mac is null)
continue;
byMac[mac] = (ToLong(mo["Speed"]), mo["FullDuplex"] as bool?);
}
}
catch (Exception error)
{
logger.LogDebug(error, "Net adapter link state query failed");
}
return byMac;
}
// WMI reports MACs in several shapes (00:11:22, 001122AABBCC). Strip to hex
// so the two sources can be matched at all.
private static string? NormalizeMac(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return null;
var hex = new string(Array.FindAll(raw.ToCharArray(), Uri.IsHexDigit));
return hex.Length == 12 ? hex : null;
}
// Whether the last start was a true cold boot or a fast-startup resume.
//
// This matters because uptime LIES under fast startup: the box reports days
// of uptime after what the operator experienced as a shutdown, and a
// "reboot" that was really a hybrid resume does not clear the driver or
// kernel state that a support call is usually trying to clear.
//
// Two distinct facts, deliberately not collapsed:
// hiberbootenabled - fast startup is CONFIGURED (registry, always readable)
// lastboottype - what the last boot ACTUALLY did (Kernel-Boot event 27)
// The registry alone cannot tell you what the last boot did, and the event
// may have aged out of the log, so neither substitutes for the other.
private object? GetBootInfo()
{
bool? hiberboot = null;
try
{
using var power = Registry.LocalMachine.OpenSubKey(
@"SYSTEM\CurrentControlSet\Control\Session Manager\Power");
if (power?.GetValue("HiberbootEnabled") is int raw)
hiberboot = raw != 0;
}
catch (Exception error)
{
logger.LogDebug(error, "Hiberboot registry read failed");
}
string? lastboottype = null;
try
{
// Kernel-Boot 27 BootType: 0 cold, 1 hybrid (fast startup),
// 2 resume from hibernate.
var query = new EventLogQuery(
"System", PathType.LogName,
"*[System[Provider[@Name='Microsoft-Windows-Kernel-Boot'] " +
"and (EventID=27)]]")
{ ReverseDirection = true };
using var reader = new EventLogReader(query);
using var record = reader.ReadEvent();
if (record?.Properties.Count > 0)
{
lastboottype = ToInt(record.Properties[0].Value) switch
{
0 => "Cold",
1 => "Fast startup",
2 => "Resume",
_ => null,
};
}
}
catch (Exception error)
{
logger.LogDebug(error, "Boot type event read failed");
}
if (hiberboot is null && lastboottype is null)
return null;
return new
{
hiberbootenabled = hiberboot,
lastboottype = lastboottype ?? "Unknown",
};
}
// Defender status. Collected and persisted but NOT surfaced: this
// environment is known to produce false positives, so wiring it to a card
// or an alert now would train operators to ignore the card. Stored so the
// signal is already there when someone is ready to interpret it.
private object? GetDefender()
{
try
{
using var searcher = new ManagementObjectSearcher(
@"root\Microsoft\Windows\Defender",
"SELECT AMServiceEnabled, RealTimeProtectionEnabled, " +
"AntivirusSignatureAge, AntispywareSignatureAge, " +
"QuickScanAge FROM MSFT_MpComputerStatus");
foreach (var mo in searcher.Get())
{
return new
{
serviceenabled = mo["AMServiceEnabled"] as bool?,
realtimeenabled = mo["RealTimeProtectionEnabled"] as bool?,
// Ages in days. Null when Defender did not report rather
// than 0, which would read as "updated today".
signatureagedays = ToLong(mo["AntivirusSignatureAge"]),
antispywareagedays = ToLong(mo["AntispywareSignatureAge"]),
quickscanagedays = ToLong(mo["QuickScanAge"]),
};
}
}
catch (Exception error)
{
// A third-party AV box or a stripped image has no Defender WMI
// provider at all. That is normal here, not a fault.
logger.LogDebug(error, "Defender status query failed");
}
return null;
}
// Which signal(s) drove pendingreboot=true, in report order.
//
// pendingreboot is a single bool OR-ed from three sources, so it cannot say
// WHY. That distinction is the point: PendingFileRenameOperations is the
// stronger signal - files are staged to be replaced on next boot, so the
// box is mid-servicing - whereas a lone CBS or Windows Update key can sit
// set for weeks on a machine nobody has rebooted. Same boolean, very
// different urgency.
//
// Empty array = checked, nothing pending. Null = the check failed, matching
// pendingreboot's own null-is-unknown rule.
private string[]? GetRebootReasons()
{
try
{
var reasons = new List<string>();
if (LocalMachineKeyExists(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"))
{
reasons.Add("componentbasedservicing");
}
if (LocalMachineKeyExists(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"))
{
reasons.Add("windowsupdate");
}
using var sessionManager = Registry.LocalMachine.OpenSubKey(
@"SYSTEM\CurrentControlSet\Control\Session Manager");
if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending
&& pending.Length > 0)
{
reasons.Add("pendingfilerename");
}
return reasons.ToArray();
}
catch (Exception error)
{
logger.LogDebug(error, "Reboot reason check failed");
return null;
}
}
private bool? GetPendingReboot()
{
try
{
const string cbs =
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending";
const string windowsUpdate =
@"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired";
if (LocalMachineKeyExists(cbs) || LocalMachineKeyExists(windowsUpdate))
return true;
using var sessionManager = Registry.LocalMachine.OpenSubKey(
@"SYSTEM\CurrentControlSet\Control\Session Manager");
if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending
&& pending.Length > 0)
{
return true;
}
return false;
}
catch (Exception error)
{
// Null, not false: an unreadable registry means we do not know, and
// reporting a confident "no reboot pending" would be worse than
// reporting nothing.
logger.LogDebug(error, "Pending reboot check failed");
return null;
}
}
private static bool LocalMachineKeyExists(string path)
{
using var key = Registry.LocalMachine.OpenSubKey(path);
return key is not null;
}
// Fixed local disks with capacity/free so the server can flag a PC that is
// running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk;
// network/removable drives are skipped. Bytes stay as longs; the server can
@@ -591,6 +1215,14 @@ public sealed class EndpointCollector : ICollector
freebytes = free,
usedbytes = size is long total && free is long available
? total - available : (long?)null,
// Precomputed so an alert threshold is a direct comparison.
// A 40GB free reading means nothing without the capacity it
// came from, and the server would have to guard against a
// null or zero size on every evaluation to derive it.
freepercent = size is long capacity && capacity > 0
&& free is long remaining
? Math.Round((double)remaining / capacity * 100.0, 1)
: (double?)null,
});
}
}
@@ -601,6 +1233,129 @@ public sealed class EndpointCollector : ICollector
return result.ToArray();
}
// Physical drive health, so a failing disk is caught before it takes the
// machine and whatever was local on it.
//
// Two sources, because neither covers the fleet alone:
//
// MSFT_PhysicalDisk (Storage namespace) is the only one that reports NVMe.
// The classic SMART class, MSStorageDriver_FailurePredictStatus, is ATA/SATA
// only and returns NOTHING on an NVMe box - verified on a two-NVMe machine
// here, where it yielded zero instances while the drives were present and
// healthy. Collecting only that class would have reported an empty disk
// health list on every modern endpoint and looked like "no problems".
//
// Win32_DiskDrive.Status is the old reliable string ("OK"/"Pred Fail") and
// covers anything the Storage namespace does not, including machines where
// that namespace is missing or blocked.
//
// Keyed by DeviceId so the server can pair the two views per drive the same
// way mapped drives are paired.
private object[] GetDiskHealth()
{
var result = new List<object>();
// MSFT_PhysicalDisk returns raw enum INTEGERS through
// ManagementObjectSearcher, not the friendly strings Get-PhysicalDisk
// prints. Verified on a live box: Health=0 Op=2 for a healthy NVMe.
// Mapping them here keeps the wire self-describing; shipping bare
// integers would make the server guess at an undocumented enum.
try
{
using var searcher = new ManagementObjectSearcher(
@"root\Microsoft\Windows\Storage",
"SELECT DeviceId, FriendlyName, MediaType, BusType, " +
"HealthStatus, OperationalStatus FROM MSFT_PhysicalDisk");
foreach (var mo in searcher.Get())
{
var health = ToInt(mo["HealthStatus"]);
result.Add(new
{
deviceid = mo["DeviceId"]?.ToString()?.Trim() ?? "",
model = mo["FriendlyName"]?.ToString()?.Trim() ?? "",
mediatype = PhysicalDiskMediaType(ToInt(mo["MediaType"])),
bustype = PhysicalDiskBusType(ToInt(mo["BusType"])),
healthstatus = PhysicalDiskHealth(health),
// The actionable bit, precomputed so an alert is a direct
// boolean test rather than a string comparison against an
// enum the server would have to keep in sync with this file.
// Null when health is unreadable: unknown is not healthy,
// but it is not a confident failure either.
predictfailure = health is null ? (bool?)null : health != 0,
source = "physicaldisk",
});
}
}
catch (Exception error)
{
logger.LogDebug(error, "Physical disk health query failed");
}
// Win32_DiskDrive as the ATA/SATA and fallback view. Status is a plain
// string here, no enum mapping needed.
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT DeviceID, Model, Status FROM Win32_DiskDrive");
foreach (var mo in searcher.Get())
{
var status = mo["Status"]?.ToString()?.Trim() ?? "";
result.Add(new
{
deviceid = mo["DeviceID"]?.ToString()?.Trim() ?? "",
model = mo["Model"]?.ToString()?.Trim() ?? "",
mediatype = "",
bustype = "",
healthstatus = status,
// "OK" is the only healthy value this class reports;
// "Pred Fail" is the SMART trip. An empty status means the
// drive did not answer, which is unknown rather than failed.
predictfailure = status.Length == 0
? (bool?)null
: !status.Equals("OK", StringComparison.OrdinalIgnoreCase),
source = "diskdrive",
});
}
}
catch (Exception error)
{
logger.LogDebug(error, "Disk drive status query failed");
}
return result.ToArray();
}
// MSFT_PhysicalDisk HealthStatus: 0 Healthy, 1 Warning, 2 Unhealthy.
private static string PhysicalDiskHealth(int? value) => value switch
{
0 => "Healthy",
1 => "Warning",
2 => "Unhealthy",
_ => "Unknown",
};
// MSFT_PhysicalDisk MediaType: 3 HDD, 4 SSD, 5 SCM.
private static string PhysicalDiskMediaType(int? value) => value switch
{
3 => "HDD",
4 => "SSD",
5 => "SCM",
_ => "Unknown",
};
// MSFT_PhysicalDisk BusType. Only the values that actually appear on an
// endpoint are named; anything else stays numeric rather than guessed at.
private static string PhysicalDiskBusType(int? value) => value switch
{
3 => "ATA",
7 => "USB",
8 => "RAID",
9 => "iSCSI",
11 => "SATA",
17 => "NVMe",
_ => value is null ? "Unknown" : value.Value.ToString(),
};
// Firmware identity from Win32_BIOS: vendor, version string, and release
// date. Lets the server track BIOS/UEFI levels for a fleet update campaign.
private object? GetBios()
@@ -737,6 +1492,14 @@ public sealed class EndpointCollector : ICollector
catch { return null; }
}
private static int? ToInt(object? value)
{
if (value is null)
return null;
try { return Convert.ToInt32(value); }
catch { return null; }
}
// Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC
// string; anything unparseable passes through untouched so nothing is lost.
private string? ToBiosDate(string? raw)

View File

@@ -0,0 +1,132 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Remembers the newest Windows event record id already reported for each
// curated channel, so a collect cycle harvests ONLY records the server has not
// seen.
//
// Why this exists: the collector used to re-read the newest N records of every
// channel on every cycle, and the server ingest appends event rows without a
// dedupe check. A single crash therefore landed in the database once per
// cadence tick for as long as it stayed inside that window - up to 96 identical
// rows a day per event. Carrying a watermark makes the steady state "only what
// is new", which is also what makes the full event XML affordable to send.
//
// The watermark is persisted under %ProgramData% (not in memory) because a
// service restart would otherwise re-report the whole window again, which is
// exactly the crash-loop case where duplicate events are most likely.
//
// Record ids are per channel and monotonic on a given machine. A cleared or
// rebuilt event log restarts them at 1; that is handled by treating a stored
// watermark HIGHER than the newest available record as stale and resetting it,
// otherwise the channel would go permanently silent.
public sealed class EventWatermarkStore
{
private const string StoreFileName = "eventwatermark.json";
// %ProgramData%\ShopDB\EndpointAgent - the same root the rolling agent log
// lives under, so all agent-owned state sits in one place.
public static string DefaultStateDirectory => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "EndpointAgent");
private readonly string storePath;
private readonly ILogger<EventWatermarkStore> logger;
private readonly Dictionary<string, long> watermarks;
public EventWatermarkStore(ILogger<EventWatermarkStore> logger,
string? stateDirectory = null)
{
this.logger = logger;
var directory = string.IsNullOrWhiteSpace(stateDirectory)
? DefaultStateDirectory
: stateDirectory;
storePath = Path.Combine(directory, StoreFileName);
watermarks = Load();
}
// The last record id reported for this channel, or 0 when the channel has
// never been harvested. 0 means "first contact": the caller sends a bounded
// tail for initial context rather than the entire backlog.
public long GetWatermark(string channel)
=> watermarks.TryGetValue(channel, out var value) ? value : 0;
public bool HasWatermark(string channel)
=> watermarks.ContainsKey(channel);
// Advance the channel forward only. Never moves backwards, so an
// out-of-order read cannot cause the same record to be reported twice.
public void Advance(string channel, long recordId)
{
if (recordId <= 0)
return;
if (watermarks.TryGetValue(channel, out var current) && current >= recordId)
return;
watermarks[channel] = recordId;
}
// A cleared or rebuilt log restarts record ids near 1. When the newest
// record available is below the stored watermark the store is stale, so
// drop it and let the channel re-seed instead of going silent forever.
public bool ResetIfStale(string channel, long newestAvailableRecordId)
{
if (newestAvailableRecordId <= 0)
return false;
if (!watermarks.TryGetValue(channel, out var current) || current <= newestAvailableRecordId)
return false;
logger.LogInformation(
"Event channel {Channel} record id went backwards ({Stored} -> {Newest}); log was cleared, reseeding",
channel, current, newestAvailableRecordId);
watermarks.Remove(channel);
return true;
}
// Best-effort persist. A failure here costs at most a repeated report of
// records already sent, so it must never abort a collection.
public void Save()
{
try
{
var directory = Path.GetDirectoryName(storePath);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
// Write-then-rename so a crash mid-write cannot leave a truncated
// file that would reseed every channel on the next start.
var temporaryPath = storePath + ".tmp";
File.WriteAllText(temporaryPath,
JsonSerializer.Serialize(watermarks));
File.Move(temporaryPath, storePath, overwrite: true);
}
catch (Exception error)
{
logger.LogDebug(error, "Could not persist event watermarks to {Path}", storePath);
}
}
private Dictionary<string, long> Load()
{
try
{
if (!File.Exists(storePath))
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
var loaded = JsonSerializer.Deserialize<Dictionary<string, long>>(
File.ReadAllText(storePath));
return loaded is null
? new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, long>(loaded, StringComparer.OrdinalIgnoreCase);
}
catch (Exception error)
{
// A corrupt store reseeds every channel, which costs one bounded
// tail per channel - acceptable versus failing the collection.
logger.LogWarning(error,
"Event watermark store unreadable at {Path}; reseeding channels", storePath);
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
}
}
}

View File

@@ -14,32 +14,74 @@ public sealed class FileLoggerProvider : ILoggerProvider
private readonly string directory;
private readonly LogLevel minLevel;
private readonly int retentionDays;
private readonly string filePrefix;
private readonly object gate = new();
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
private string currentDateStamp = "";
private StreamWriter? writer;
// Why the sink can be unusable, or null when it opened normally. The
// constructor cannot throw (see below), but a caller that has a second,
// known-writable place to complain to needs to be able to ask.
public string? SinkProblem { get; private set; }
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
int retentionDays = 14)
int retentionDays = 14, string filePrefix = "agent")
{
this.directory = directory;
this.minLevel = minLevel;
this.retentionDays = retentionDays;
this.filePrefix = filePrefix;
// Best-effort: creating the log directory can fail on a locked-down host
// or when a non-privileged user targets a SYSTEM-owned path. Never let
// that abort construction; Write() degrades to a no-op if the sink is
// unusable.
//
// The reason is KEPT rather than discarded. A silently dead sink is what
// made the user-context probe undiagnosable: running as the operator it
// could not write into the SYSTEM-owned ProgramData log directory, so a
// probe that failed and a probe that never ran produced identical
// silence. Callers that have a writable fallback can now surface this.
try { Directory.CreateDirectory(directory); }
catch (Exception error) when (error is IOException or UnauthorizedAccessException) { }
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
SinkProblem = $"{directory}: {error.Message}";
}
}
// The default log directory when config leaves LogPath empty:
// %ProgramData%\ShopDB\EndpointAgent\logs
//
// SERVICE-ONLY. This path is SYSTEM-owned and a signed-in operator cannot
// write to it, so anything running in the user's session must use
// UserProfileLogDirectory() instead.
public static string DefaultDirectory()
=> Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "EndpointAgent", "logs");
// The shop-floor operator account. Every shop-floor endpoint uses this same
// local account name, so the path is fixed rather than resolved through the
// interactive token: the token lookup is the part that fails on a hardened
// endpoint, and when it fails the writer and the reader silently disagree
// about where the spool lives. If a site ever uses a different account only
// user-context is lost - telemetry, commands and inventory are unaffected.
public const string ShopFloorUserName = "ShopFloor";
public static string ShopFloorProfileDirectory()
=> Path.Combine(DrivesRoot, "Users", ShopFloorUserName);
private static string DrivesRoot =>
Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows))
?? @"C:\";
// Where the TRAY and the user-context probe log:
// C:\Users\ShopFloor\ShopDB\Logs
// The operator owns this directory, so it is writable from the user session
// where ProgramData is not.
public static string UserProfileLogDirectory()
=> Path.Combine(ShopFloorProfileDirectory(), "ShopDB", "Logs");
public ILogger CreateLogger(string categoryName)
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
@@ -85,7 +127,14 @@ public sealed class FileLoggerProvider : ILoggerProvider
writer?.Flush();
writer?.Dispose();
currentDateStamp = stamp;
var path = Path.Combine(directory, $"agent-{stamp}.log");
// One convention for every log this codebase writes:
// "<yyyyMMdd>-<prefix>.log", so agent, tray and installer logs sort
// chronologically when they share a directory and can be collected with
// a single date-leading glob. The service previously used the opposite
// order, which meant a support request for "the logs from the 5th" had to
// know which component wrote which shape.
var name = $"{stamp}-{filePrefix}.log";
var path = Path.Combine(directory, name);
writer = new StreamWriter(new FileStream(
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
{
@@ -99,7 +148,12 @@ public sealed class FileLoggerProvider : ILoggerProvider
try
{
var cutoff = DateTime.Now.AddDays(-retentionDays);
foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log"))
// Both shapes: retention has to keep covering the "<prefix>-<date>"
// files written before the convention was unified, or an upgraded
// endpoint would keep its pre-upgrade logs forever.
string[] patterns = [$"*-{filePrefix}.log", $"{filePrefix}-*.log"];
foreach (var pattern in patterns)
foreach (var file in Directory.EnumerateFiles(directory, pattern))
{
if (File.GetLastWriteTime(file) < cutoff)
File.Delete(file);

View File

@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
namespace ShopDbEndpointAgent.Core;
@@ -18,7 +19,11 @@ namespace ShopDbEndpointAgent.Core;
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
public static class InteractiveProcessLauncher
{
public readonly record struct LaunchOutcome(bool Launched, string? Error, int? ExitCode = null);
// TimedOut=true means the process launched fine but was still running when
// the caller's wait budget expired. It is deliberately NOT killed, so the
// work still completes; the caller just stops blocking on it.
public readonly record struct LaunchOutcome(
bool Launched, string? Error, int? ExitCode = null, bool TimedOut = false);
private const int TokenDuplicate = 0x0002;
private const int TokenQuery = 0x0008;
@@ -34,6 +39,10 @@ public static class InteractiveProcessLauncher
private const uint Infinite = 0xFFFFFFFF;
// WaitForSingleObject returns this when the budget expires with the handle
// still unsignalled.
private const uint WaitTimeout = 0x00000102;
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
// failures come back as Launched=false with a descriptive Error so the
// caller can surface a clean CommandResult.
@@ -51,9 +60,17 @@ public static class InteractiveProcessLauncher
// needs the actual outcome (run_script in the user session) can report
// completed/failed instead of fire-and-forget "launched". GUI callers
// (process_start) leave it false and return as soon as the window is up.
//
// waitTimeout bounds that wait. This matters because the caller may be the
// telemetry loop, and telemetry IS the heartbeat: a script that hangs in the
// user session used to block here forever (the wait was hardcoded to
// Infinite), which would stall reporting and make a perfectly healthy PC
// read as offline in the WebUI. On expiry the child is left running and the
// outcome comes back with TimedOut=true. Null means wait forever and should
// only be used by a caller that is not on a timed path.
public static LaunchOutcome LaunchInActiveSession(
string applicationPath, string? arguments, string? workingDirectory,
bool waitForExit = false)
bool waitForExit = false, TimeSpan? waitTimeout = null)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == InvalidSessionId)
@@ -129,11 +146,25 @@ public static class InteractiveProcessLauncher
}
int? exitCode = null;
bool timedOut = false;
if (waitForExit && processInformation.hProcess != IntPtr.Zero)
{
WaitForSingleObject(processInformation.hProcess, Infinite);
if (GetExitCodeProcess(processInformation.hProcess, out var code))
uint milliseconds = waitTimeout is TimeSpan budget
? (uint)Math.Clamp(budget.TotalMilliseconds, 0, uint.MaxValue - 1)
: Infinite;
if (WaitForSingleObject(processInformation.hProcess, milliseconds)
== WaitTimeout)
{
// Still running. Leave it alone - killing it would abandon
// half-done work, and a file-spooling probe will still write
// its result for the next cycle to pick up.
timedOut = true;
}
else if (GetExitCodeProcess(processInformation.hProcess, out var code))
{
exitCode = unchecked((int)code);
}
}
if (processInformation.hProcess != IntPtr.Zero)
@@ -141,7 +172,7 @@ public static class InteractiveProcessLauncher
if (processInformation.hThread != IntPtr.Zero)
CloseHandle(processInformation.hThread);
return new LaunchOutcome(true, null, exitCode);
return new LaunchOutcome(true, null, exitCode, timedOut);
}
finally
{
@@ -154,6 +185,57 @@ public static class InteractiveProcessLauncher
}
}
// Profile directory of the active console user, for example
// C:\Users\ShopFloor. The agent runs as LocalSystem, so expanding
// %USERPROFILE% here would return SYSTEM's own profile under
// config\systemprofile rather than the operator's. The interactive user's
// token is the only reliable source, and this class already knows how to
// obtain it. Returns null with a reason when nobody is signed in.
public static string? TryGetActiveUserProfileDirectory(out string? error)
{
var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == InvalidSessionId)
{
error = "No active console session (no interactive user is signed in).";
return null;
}
if (!WTSQueryUserToken(sessionId, out var userToken))
{
error = "Could not obtain the interactive user token: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
return null;
}
try
{
uint size = 0;
GetUserProfileDirectory(userToken, null, ref size);
if (size == 0)
{
error = "Could not size the user profile directory: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
return null;
}
var buffer = new StringBuilder((int)size);
if (!GetUserProfileDirectory(userToken, buffer, ref size))
{
error = "Could not resolve the user profile directory: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
return null;
}
error = null;
return buffer.ToString();
}
finally
{
if (userToken != IntPtr.Zero)
CloseHandle(userToken);
}
}
private enum SecurityImpersonationLevel
{
SecurityAnonymous,
@@ -222,6 +304,10 @@ public static class InteractiveProcessLauncher
[DllImport("userenv.dll", SetLastError = true)]
private static extern bool DestroyEnvironmentBlock(IntPtr environment);
[DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool GetUserProfileDirectory(
IntPtr token, StringBuilder? profileDirectory, ref uint size);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CreateProcessAsUser(
IntPtr token,

View File

@@ -0,0 +1,159 @@
namespace ShopDbEndpointAgent.Core;
// Guards which registered scheduled tasks the scheduledtask_run action may
// start. The wire carries a free-text task path (so the WebUI can expose it as
// one field, and a site can widen the set without an agent rebuild); this
// policy is the safeguard that makes that free text safe to honor.
//
// WHY THIS EXISTS AT ALL. The agent runs as SYSTEM. A scheduled task is an
// INDIRECTION: the task path names a definition, and that definition chooses
// what actually runs. So "start the task named X" is only as safe as the set of
// X the agent will accept - an unconstrained task name would be a general-
// purpose "run anything already registered on this box, as whatever context it
// asked for" primitive. That is a materially bigger grant than any other
// allowlisted action, and it would be invisible in the audit trail (the command
// log would read "scheduledtask_run \Foo" no matter what \Foo does).
//
// WHAT THIS DOES AND DOES NOT BUY. A prefix allowlist stops a compromised or
// misconfigured SERVER from starting arbitrary tasks on the fleet - that is the
// threat it actually addresses, and it is the realistic one. It does NOT stop an
// attacker who can already register or rewrite task definitions on the endpoint:
// such an attacker can define a task under an approved prefix and have the agent
// start it. That is accepted, because anyone who can write task definitions on a
// box already has the privilege the task would grant them. Local task definition
// ACLs, not this policy, are the control for that.
//
// The approved-prefix list ships with the Corporate Tools set and is overridable
// per site via AgentConfig.ApprovedTaskPaths, matching the LaunchPathPolicy
// pattern so the future Site Agent Configuration tool can narrow or widen it
// without a rebuild.
public sealed class ScheduledTaskPolicy
{
// Shipped defaults: the Corporate Tools task set. Prefix entries end with a
// backslash and match a whole folder; entries without one match that exact
// task path only. Kept deliberately tight - widen per site, not here.
//
// The ShopDB-* entries are tasks ShopDB itself owns. They are separate task
// definitions from any corporate-initiated equivalent ON PURPOSE: a
// ShopDB-initiated Dell Command scan must write its results somewhere the
// agent's collector can attribute to ShopDB, rather than into whatever
// location a corporate-managed task already writes. Keeping the definitions
// distinct is what makes "who asked for this scan" answerable at all - see
// the note in commands.py about the DCU output path convention.
public static IReadOnlyList<string> DefaultApprovedTaskPaths { get; } = new[]
{
@"\ReportIP",
@"\SFLD-ApplyDSCConfig",
@"\SFLD - Consume Credentials",
@"\ShopDB-DellCommandScan",
@"\ShopDB-DellCommandUpdate",
};
private readonly IReadOnlyList<string> approvedPaths;
public ScheduledTaskPolicy(IEnumerable<string>? approvedPathsOverride = null)
{
var paths = (approvedPathsOverride ?? Enumerable.Empty<string>())
.Where(path => !string.IsNullOrWhiteSpace(path))
.Select(NormalizeTaskPath)
.ToList();
approvedPaths = paths.Count > 0 ? paths : DefaultApprovedTaskPaths;
}
// Validates a candidate task path. On success taskPath is the normalized
// path to hand to Task Scheduler; on failure reason explains the rejection
// so the command result tells the operator WHY rather than just "denied".
public bool TryValidate(string? candidate, out string taskPath, out string reason)
{
taskPath = "";
reason = "";
if (string.IsNullOrWhiteSpace(candidate))
{
reason = "empty task path";
return false;
}
var normalized = NormalizeTaskPath(candidate);
// Traversal in a task path is meaningless to Task Scheduler but very
// meaningful to a prefix check: "\Approved\..\Evil" starts with an
// approved prefix as a STRING while naming something else entirely.
// Reject rather than try to canonicalize it away.
if (normalized.Contains(".."))
{
reason = "task path contains a traversal segment";
return false;
}
// Control characters and quotes have no place in a task path and are the
// shape of an attempt to break out of the argument. The invocation uses
// ArgumentList (no shell, no string concatenation) so this is defense in
// depth rather than the primary control, but a task path that needs
// these is not a task path we should be starting.
foreach (var ch in normalized)
{
if (char.IsControl(ch) || ch == '"' || ch == '&' || ch == '|')
{
reason = "task path contains an illegal character";
return false;
}
}
if (!IsApproved(normalized))
{
reason = "task path is not in the approved task list";
return false;
}
taskPath = normalized;
return true;
}
// Task Scheduler paths are rooted and backslash-separated. Accept the
// operator writing "ReportIP", "\ReportIP", or "/ReportIP" and settle on the
// rooted form, so an allowlist entry does not hinge on how it was typed.
private static string NormalizeTaskPath(string path)
{
var trimmed = (path ?? "").Trim().Replace('/', '\\');
if (trimmed.Length == 0)
return "";
if (!trimmed.StartsWith('\\'))
trimmed = "\\" + trimmed;
// Collapse a trailing separator so "\Folder\" and "\Folder" compare
// equal as exact entries; prefix semantics are handled in IsApproved.
while (trimmed.Length > 1 && trimmed.EndsWith('\\'))
trimmed = trimmed[..^1];
return trimmed;
}
// An entry ending in a separator is a FOLDER prefix and matches anything
// beneath it; any other entry must match the whole task path exactly. The
// exact-match case is why "\Approved" does not silently authorize
// "\ApprovedEvil".
private bool IsApproved(string candidate)
{
foreach (var entry in approvedPaths)
{
if (entry.Length == 0)
continue;
var isFolderPrefix = entry.EndsWith('\\');
if (isFolderPrefix)
{
if (candidate.StartsWith(entry, StringComparison.OrdinalIgnoreCase))
return true;
continue;
}
if (string.Equals(candidate, entry, StringComparison.OrdinalIgnoreCase))
return true;
// Allow the folder form without its trailing separator to still act
// as a folder: "\SFLD" approves "\SFLD\Thing" but never "\SFLDEvil".
if (candidate.StartsWith(entry + "\\", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}

View File

@@ -17,6 +17,10 @@
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
<!-- EventWaitHandleAcl: creating the user-context refresh event with an
explicit DACL. The service creates it as LocalSystem, and without a
granted ACE the unelevated tray cannot open it. -->
<PackageReference Include="System.Threading.AccessControl" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,128 @@
using System.Net;
using System.Runtime.InteropServices;
namespace ShopDbEndpointAgent.Core;
// TCP connection state with owning process ids, via GetExtendedTcpTable.
//
// Why P/Invoke instead of shelling out to netstat.exe: parsing netstat text
// output means spawning a process on every telemetry tick and depending on the
// column layout of a localized command. GetExtendedTcpTable is the API netstat
// itself calls, returns process ids directly, and costs a single allocation.
//
// Listeners are the cheap, bounded half of this (a handful of rows, stable over
// time) and ride the normal cadence. The full connection table is unbounded and
// churns constantly, so it stays on-demand.
public static class TcpTableReader
{
private const int AF_INET = 2;
private const uint ERROR_INSUFFICIENT_BUFFER = 122;
private const int TCP_TABLE_OWNER_PID_ALL = 5;
public enum TcpState
{
Closed = 1,
Listen = 2,
SynSent = 3,
SynReceived = 4,
Established = 5,
FinWait1 = 6,
FinWait2 = 7,
CloseWait = 8,
Closing = 9,
LastAck = 10,
TimeWait = 11,
DeleteTcb = 12,
}
public readonly record struct TcpRow(
string LocalAddress,
int LocalPort,
string RemoteAddress,
int RemotePort,
TcpState State,
int ProcessId);
// Full IPv4 TCP table. Throws only on catastrophic interop failure; callers
// treat an empty list as "not observed".
public static List<TcpRow> GetTcpTable()
{
var rows = new List<TcpRow>();
var buffer = IntPtr.Zero;
var size = 0;
try
{
// First call sizes the buffer. It is expected to fail with
// INSUFFICIENT_BUFFER; anything else is a real error.
var result = GetExtendedTcpTable(
IntPtr.Zero, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0);
if (result != ERROR_INSUFFICIENT_BUFFER && result != 0)
return rows;
buffer = Marshal.AllocHGlobal(size);
result = GetExtendedTcpTable(
buffer, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0);
if (result != 0)
return rows;
var entryCount = Marshal.ReadInt32(buffer);
var rowSize = Marshal.SizeOf<MIB_TCPROW_OWNER_PID>();
var cursor = IntPtr.Add(buffer, 4);
for (var index = 0; index < entryCount; index++)
{
var row = Marshal.PtrToStructure<MIB_TCPROW_OWNER_PID>(cursor);
rows.Add(new TcpRow(
FormatAddress(row.localAddress),
FormatPort(row.localPort),
FormatAddress(row.remoteAddress),
FormatPort(row.remotePort),
(TcpState)row.state,
(int)row.owningProcessId));
cursor = IntPtr.Add(cursor, rowSize);
}
}
finally
{
if (buffer != IntPtr.Zero)
Marshal.FreeHGlobal(buffer);
}
return rows;
}
public static List<TcpRow> GetListeners()
=> GetTcpTable().FindAll(row => row.State == TcpState.Listen);
private static string FormatAddress(uint address)
=> new IPAddress(BitConverter.GetBytes(address)).ToString();
// The port arrives as a big-endian value packed into the low two bytes of a
// host-order uint, so the bytes must be swapped rather than simply cast.
private static int FormatPort(uint port)
{
var bytes = BitConverter.GetBytes(port);
return (bytes[0] << 8) | bytes[1];
}
[StructLayout(LayoutKind.Sequential)]
private struct MIB_TCPROW_OWNER_PID
{
public uint state;
public uint localAddress;
public uint localPort;
public uint remoteAddress;
public uint remotePort;
public uint owningProcessId;
}
[DllImport("iphlpapi.dll", SetLastError = true)]
private static extern uint GetExtendedTcpTable(
IntPtr tcpTable,
ref int size,
bool order,
int addressFamily,
int tableClass,
int reserved);
}

View File

@@ -15,6 +15,11 @@ namespace ShopDbEndpointAgent.Core;
// cadence tick rather than hammering the server.
public sealed class TelemetryWorker : BackgroundService
{
// The one agent utility whose output is forwarded to the server today. The
// name is the .ps1 filename without its extension, which is how
// PowerShellUtilityRunner keys its results.
public const string DellUpdatesUtilityName = "shopdb-dellupdates";
private readonly ICollector collector;
private readonly CollectorClient client;
private readonly IAgentUtilityRunner? utilities;
@@ -22,11 +27,31 @@ public sealed class TelemetryWorker : BackgroundService
private readonly string agentVersion;
private readonly ILogger<TelemetryWorker> logger;
private readonly CollectSignal? collectSignal;
private readonly UserContextSpool? userContextSpool;
private readonly UserContextRefreshChannel? userContextRefresh;
private readonly Random jitter = new();
// How long to wait for the tray to answer a refresh request.
//
// Bounded, and bounded SHORT. Telemetry is this agent's liveness signal: a
// report that arrives with slightly stale mapped drives is a working
// endpoint, while a report that never arrives because we blocked on an
// absent tray is an endpoint that looks dead. The probe itself is a single
// short PowerShell run, so a tray that is alive answers well inside this;
// anything longer means nobody is home.
private static readonly TimeSpan UserContextRefreshTimeout = TimeSpan.FromSeconds(12);
// Poll interval while waiting for the stamp to advance. There is no
// completion signal coming back the other way - the tray's answer IS the
// rewritten spool file - so this is a poll by design, kept coarse enough to
// be free and fine enough not to add visible latency.
private static readonly TimeSpan UserContextPollInterval = TimeSpan.FromMilliseconds(400);
public TelemetryWorker(ICollector collector, CollectorClient client,
AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger,
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null)
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null,
UserContextSpool? userContextSpool = null,
UserContextRefreshChannel? userContextRefresh = null)
{
this.collector = collector;
this.client = client;
@@ -35,6 +60,77 @@ public sealed class TelemetryWorker : BackgroundService
this.logger = logger;
this.utilities = utilities;
this.collectSignal = collectSignal;
this.userContextSpool = userContextSpool;
this.userContextRefresh = userContextRefresh;
}
// Request a tray-side re-probe and wait, briefly, for the spool to be
// rewritten. Never throws and never fails the run.
//
// The service does NOT collect session data itself and must not start doing
// so: mapped drives belong to the operator's logon token, and a LocalSystem
// probe reads a different, near-empty drive table. See the tombstone in
// Program.cs. This method is the entire service-side involvement - ask, then
// wait for the answer to appear on disk.
private async Task RefreshUserContextAsync(CancellationToken token)
{
if (userContextSpool is null || userContextRefresh is null
|| !OperatingSystem.IsWindows())
{
return;
}
var resultPath = userContextSpool.TryGetResultPath(out _);
if (resultPath is null)
return;
// Captured BEFORE the request so "advanced" is measured against what we
// would otherwise have reported. A missing spool reads as null, and any
// stamp at all then counts as progress - that is the first-probe case.
var before = userContextSpool.TryReadCollectedAt(resultPath);
if (!userContextRefresh.RequestRefresh())
{
// Could not signal at all. Note that the inverse does NOT mean a tray
// is listening: the channel opens-or-creates and does not hold the
// handle, so signalling into an empty machine succeeds by creating
// the event and finding nobody waiting on it. Detecting an absent
// tray is the timeout's job below, not this return value's.
logger.LogDebug(
"Could not request a user-context refresh; reporting the existing spool.");
return;
}
var deadline = DateTime.UtcNow + UserContextRefreshTimeout;
while (DateTime.UtcNow < deadline)
{
try
{
await Task.Delay(UserContextPollInterval, token);
}
catch (OperationCanceledException)
{
return;
}
var after = userContextSpool.TryReadCollectedAt(resultPath);
if (after is not null && (before is null || after > before))
{
logger.LogDebug(
"User-context spool refreshed by the tray (collectedat={Stamp}).",
after);
return;
}
}
// Timed out. Proceed anyway - deliberately. Session data is one card on
// one page; telemetry carries heartbeat, inventory, events and command
// results, and holding all of that hostage to a tray that did not answer
// in twelve seconds trades a small inaccuracy for a total outage.
logger.LogDebug(
"The tray did not refresh the user-context spool within {Timeout}; "
+ "reporting with the existing session data.",
UserContextRefreshTimeout);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -111,6 +207,15 @@ public sealed class TelemetryWorker : BackgroundService
config.Monitors.Processes.Length, config.Monitors.Services.Length);
}
// Ask the tray to re-probe session data and WAIT for it, before
// collecting. Ordering is the whole point: the collector reads the spool
// file, so a refresh that lands after Collect() is a refresh the server
// does not see until the NEXT cycle. That was the bug - "Send Status"
// and "Refresh Status" both re-sent a spool nobody had rewritten, so a
// drive the operator had just unmapped kept reporting OK for up to half
// an hour and no amount of clicking changed it.
await RefreshUserContextAsync(token);
var report = collector.Collect(agentVersion, adopt);
if (utilities is not null)
@@ -118,6 +223,17 @@ public sealed class TelemetryWorker : BackgroundService
var extra = utilities.RunAll(token);
if (extra.Count > 0)
logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count);
// NARROW ON PURPOSE. The runner executes every .ps1 in the utilities
// directory, but only this one known key is attached to the report.
// Forwarding the whole dictionary would mean any script dropped into
// that directory starts uploading its output to the server on every
// cadence - see the signing caveat in PowerShellUtilityRunner's
// header. Widening this to the full map is the intended end state of
// the IAgentUtilityRunner seam, but it needs script signing or an
// allowlist first. Until then, add a key here per utility.
if (extra.TryGetValue(DellUpdatesUtilityName, out var dellUpdates))
report.DellUpdates = dellUpdates;
}
logger.LogInformation(

View File

@@ -0,0 +1,362 @@
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Collects the session-scoped data that does not exist in the agent's own
// context, by running a short PowerShell probe IN THE CALLING PROCESS'S
// SESSION and writing the result to the user's spool file.
//
// Collects only data that genuinely does not exist in the agent's own context:
// netuse - mapped drives are per logon session; "net use" from
// LocalSystem returns SYSTEM's (empty) mapping table, never the
// operator's Z: to the file server.
// userlisteners- TCP listeners owned by the signed-in user's processes, which
// is what identifies an operator-launched DNC or CMM listener.
// idleseconds - GetLastInputInfo is per session, so session 0 cannot answer
// it. Sampled at probe time only, so treat it as a coarse
// "was anyone at the machine" signal, not live presence.
//
// This runs from `shopdbagent.exe --collect-usercontext`, which the tray - and
// only the tray - invokes, because the tray is already running as the signed-in
// operator. The service does NOT call this: it previously tried to descend into
// the session with CreateProcessAsUser, which is the direction that has to
// duplicate a token, is what endpoint protection watches for, and produced
// nothing on a live endpoint. Nothing here crosses a privilege boundary now;
// the probe simply runs as whoever the tray is.
//
// The script is embedded rather than shipped as a .ps1 so the probe cannot drift
// from the agent binary or be tampered with on disk between cycles.
public sealed class UserContextProbe
{
// The spool directory is injected by BuildProbeScript rather than derived in
// the script, so the writer cannot disagree with the reader. See
// UserContextSpool.PinnedSpoolDirectory for why $env:USERPROFILE was wrong.
private const string SpoolDirectoryToken = "__SHOPDB_SPOOL_DIR__";
private const string ProbeScriptTemplate = """
$ErrorActionPreference = 'Stop'
$result = [ordered]@{}
# Injected by the agent from UserContextSpool.PinnedSpoolDirectory() so the
# writer and the reader resolve the same directory. Deliberately NOT
# $env:USERPROFILE: the tray can inherit another account's environment on a
# locked-down image, which silently wrote this file into the wrong profile.
$spool = '__SHOPDB_SPOOL_DIR__'
$target = Join-Path $spool 'usercontext.json'
$temporary = "$target.tmp"
try {
New-Item -ItemType Directory -Path $spool -Force | Out-Null
$netuse = @()
try {
Get-CimInstance -ClassName Win32_NetworkConnection -ErrorAction Stop |
ForEach-Object {
$netuse += [ordered]@{
localname = $_.LocalName
remotename = $_.RemoteName
status = $_.Status
username = $_.UserName
persistent = [bool]$_.Persistent
}
}
} catch {
$result['netuseerror'] = $_.Exception.Message
}
$result['netuse'] = $netuse
# Second, independent view of the same drives. Win32_NetworkConnection
# is the "net use" table and Get-SmbMapping is the SMB client's own,
# and they disagree: a share can be Unavailable in one and absent or
# OK in the other. Neither is authoritative, so both are reported as
# collected and the server decides what to show. Normalising them
# here would discard exactly the disagreement that is diagnostic.
$smbmapping = @()
try {
Get-SmbMapping -ErrorAction Stop |
ForEach-Object {
$smbmapping += [ordered]@{
localpath = $_.LocalPath
remotepath = $_.RemotePath
status = [string]$_.Status
}
}
} catch {
# Absent on older builds and in some session types. A missing
# source is not a drive fault, so it is recorded and left out
# rather than reported as a failed mapping.
$result['smbmappingerror'] = $_.Exception.Message
}
$result['smbmapping'] = $smbmapping
$listeners = @()
try {
$ownprocesses = @{}
Get-Process -ErrorAction SilentlyContinue | ForEach-Object {
$ownprocesses[$_.Id] = $_.ProcessName
}
Get-NetTCPConnection -State Listen -ErrorAction Stop |
Where-Object { $ownprocesses.ContainsKey($_.OwningProcess) } |
ForEach-Object {
$listeners += [ordered]@{
localaddress = $_.LocalAddress
localport = $_.LocalPort
processid = $_.OwningProcess
processname = $ownprocesses[$_.OwningProcess]
}
}
} catch {
$result['userlistenerserror'] = $_.Exception.Message
}
$result['userlisteners'] = $listeners
# Idle time must be measured inside the user's session:
# GetLastInputInfo is per session, so asking from the agent's
# session 0 would report the service's own idle time, which is
# meaningless. This is what distinguishes "powered on" from
# "someone is actually working at this machine".
try {
# Built as a single-quoted string rather than a here-string: a
# here-string terminator must sit at column 0, which cannot be
# expressed inside an indented C# raw string literal.
$signature = 'using System; using System.Runtime.InteropServices; ' +
'public class ShopDbIdle { ' +
'[StructLayout(LayoutKind.Sequential)] ' +
'struct LASTINPUTINFO { public uint cbSize; public uint dwTime; } ' +
'[DllImport("user32.dll")] ' +
'static extern bool GetLastInputInfo(ref LASTINPUTINFO info); ' +
'public static long IdleSeconds() { ' +
'LASTINPUTINFO info = new LASTINPUTINFO(); ' +
'info.cbSize = (uint)Marshal.SizeOf(info); ' +
'if (!GetLastInputInfo(ref info)) return -1; ' +
'return (long)((uint)Environment.TickCount - info.dwTime) / 1000; } }'
Add-Type -TypeDefinition $signature -ErrorAction Stop
$idle = [ShopDbIdle]::IdleSeconds()
if ($idle -ge 0) { $result['idleseconds'] = $idle }
} catch {
$result['idlesecondserror'] = $_.Exception.Message
}
$result['collectedat'] = (Get-Date).ToUniversalTime().ToString('o')
$result['username'] = $env:USERNAME
# Stamp the token's elevation state. The username alone does not
# settle it: the SAME account elevated and unelevated gets two
# different logon tokens and therefore two different drive tables,
# so a spool from an elevated ShopFloor session passes the account
# check while still reporting every drive as broken.
try {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
$result['elevated'] = [bool] $principal.IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
} catch {
$result['elevatederror'] = $_.Exception.Message
}
# Write to a temporary name and rename into place, so the reader can
# never observe a half-written file and discard a good result.
$result | ConvertTo-Json -Depth 4 -Compress |
Set-Content -Path $temporary -Encoding UTF8 -NoNewline
Move-Item -Path $temporary -Destination $target -Force
} catch {
exit 1
}
exit 0
""";
// Substitute the resolved spool directory into the template. The path goes in
// as a PowerShell single-quoted literal, so an embedded quote is doubled and
// nothing else in the path is interpreted - backslashes and $ are literal
// inside single quotes, which is exactly what a Windows path needs.
private static string BuildProbeScript(string spoolDirectory)
=> ProbeScriptTemplate.Replace(
SpoolDirectoryToken, spoolDirectory.Replace("'", "''"));
// How long the probe is allowed to take. Generous: this no longer runs on
// the telemetry path, so an unresponsive WMI call delays nothing but itself.
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60);
// powershell.exe's exit code when it rejects its command line and prints
// usage instead of running anything. Named so the failure is recognisable.
private const int PowerShellStartupFailure = -196608;
private readonly ILogger<UserContextProbe> logger;
public UserContextProbe(ILogger<UserContextProbe> logger)
{
this.logger = logger;
}
// Run the probe in THIS process's session and write the spool file.
// Returns true only when the spool file is present on disk afterwards.
//
// Called from the --collect-usercontext entry point, which the tray runs as
// the signed-in operator. Never throws: this is background collection and a
// failure must not take the tray or the agent process down with it.
public bool Collect()
{
var spoolDirectory = UserContextSpool.PinnedSpoolDirectory();
var resultPath = UserContextSpool.PinnedResultPath();
try
{
// Logged before the launch, not after. When this goes wrong the
// interesting question is always "which directory did it target",
// and a failure later in this method must not take the answer with
// it.
logger.LogInformation(
"Running the user-context probe as {User}; spool target {Path}",
Environment.UserName, resultPath);
// The probe writes a .tmp beside the target and moves it into place,
// so the directory has to exist before powershell.exe starts. The
// script creates it too, but doing it here means a permission problem
// is reported by the agent with a real exception rather than showing
// up as an opaque non-zero exit code.
try
{
Directory.CreateDirectory(spoolDirectory);
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
logger.LogWarning(error,
"Could not create the user-context spool directory at {Directory}",
spoolDirectory);
return false;
}
// Passed as -EncodedCommand rather than staged as a .ps1 and run with
// -File. The script used to be written into %TEMP% and executed from
// there, which put a dependency on that file still existing, and still
// being readable, at the moment powershell.exe started. On a hardened
// endpoint that window is not safe: endpoint protection quarantines a
// freshly written script in the temp directory between the write and
// the launch, and roaming/redirected profiles can make the path
// unusable outright.
//
// When powershell.exe cannot use its -File argument it prints its usage
// banner and exits -196608 (0xFFFD0000) WITHOUT running a single
// statement. That is a startup failure, not a script failure, so the
// script's own try/catch never runs, nothing is logged about drives,
// and the operator sees a silent "no mapped drives reported" while the
// log only shows the bare exit code. -EncodedCommand carries the script
// in the command line itself, so there is no file to lose and no
// execution-policy or quarantine step between us and the probe.
var encoded = Convert.ToBase64String(
System.Text.Encoding.Unicode.GetBytes(BuildProbeScript(spoolDirectory)));
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "powershell.exe",
Arguments =
"-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "
+ encoded,
WorkingDirectory = Path.GetTempPath(),
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = System.Diagnostics.Process.Start(startInfo);
if (process is null)
{
logger.LogWarning("The user-context probe could not be started.");
return false;
}
if (!process.WaitForExit((int)ProbeTimeout.TotalMilliseconds))
{
logger.LogWarning(
"The user-context probe did not finish within {Seconds} seconds.",
ProbeTimeout.TotalSeconds);
TryKill(process);
return false;
}
if (process.ExitCode != 0)
{
// 0xFFFD0000 is powershell.exe refusing its own command line and
// printing usage: the probe never ran. Called out by name because
// as a bare number it reads like a script error and sends the
// reader looking for a WMI or drive fault that is not there.
var detail = process.ExitCode == PowerShellStartupFailure
? " (powershell.exe rejected its command line; the probe did not run)"
: string.Empty;
logger.LogWarning(
"The user-context probe exited with code {ExitCode}.{Detail}",
process.ExitCode, detail);
return false;
}
// Exit code 0 is necessary but NOT sufficient. The probe used to be
// trusted on its exit code alone, which meant a run that wrote its
// file into a different profile entirely still reported success - the
// log claimed a spool had been written for weeks while nothing was
// ever there. Confirm the file exists where the reader will look.
if (!File.Exists(resultPath))
{
logger.LogWarning(
"The user-context probe reported success but no spool file is "
+ "present at {Path}.", resultPath);
return false;
}
// Byte count alone does not distinguish a healthy spool from a
// well-formed empty one, and "0 drives" is the exact symptom this
// whole path exists to diagnose. Summarising the contents means a
// single log line from any given task run answers whether the probe
// saw the operator's drives, without needing the spool itself.
var summary = SummariseSpool(resultPath);
logger.LogInformation(
"The user-context probe wrote {Bytes} bytes to {Path} as {User}: {Summary}",
new FileInfo(resultPath).Length, resultPath, Environment.UserName, summary);
return true;
}
catch (Exception exception)
{
logger.LogWarning(exception, "The user-context probe failed.");
return false;
}
}
// One-line description of what the probe actually captured, for the run log.
// Best-effort by design: this is diagnostic colour on an already-successful
// run, so a parse problem here must never turn that run into a failure.
private static string SummariseSpool(string resultPath)
{
try
{
using var document = System.Text.Json.JsonDocument.Parse(
File.ReadAllText(resultPath));
var root = document.RootElement;
static int CountOf(System.Text.Json.JsonElement root, string name) =>
root.TryGetProperty(name, out var value)
&& value.ValueKind == System.Text.Json.JsonValueKind.Array
? value.GetArrayLength()
: 0;
var user = root.TryGetProperty("username", out var stamped)
? stamped.GetString()
: "(unstamped)";
return $"user={user} netuse={CountOf(root, "netuse")} "
+ $"smbmapping={CountOf(root, "smbmapping")} "
+ $"listeners={CountOf(root, "userlisteners")}";
}
catch (Exception)
{
return "(could not summarise)";
}
}
private void TryKill(System.Diagnostics.Process process)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not stop the timed-out user-context probe.");
}
}
}

View File

@@ -0,0 +1,179 @@
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Threading;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// The service-to-tray request channel for an on-demand user-context refresh.
//
// WHY THIS EXISTS AT ALL. Mapped drives are a property of the operator's logon
// token, not of the machine. The agent is a session-0 LocalSystem service, so it
// CANNOT enumerate them - not by impersonation, not by CreateProcessAsUser, not
// by starting the probe task itself. That approach was built once and reverted;
// see the tombstone in Program.cs where UserContextProbe is deliberately kept
// out of the service's DI container. The only process on the box that can see
// the right drive table is one already running inside the operator's session:
// the tray.
//
// So the service does not probe. It ASKS. This channel is the ask, and the
// spool file remains the answer.
//
// WHY A NAMED EVENT rather than a pipe or a socket. The request carries no data
// - it is a single bit, "please re-probe now" - and the reply already has a
// transport (the spool file, whose collectedat stamp the service watches
// advance). A kernel event is the smallest thing that crosses the session-0
// boundary, needs no serialization, no port, no firewall rule, and no cleanup if
// either side dies. A file-watcher trigger would also work but leaves litter on
// disk when the service is killed mid-request.
//
// THE Global\ PREFIX IS LOAD-BEARING. Without it the name is scoped to the
// caller's session, so the service (session 0) and the tray (session 1+) would
// create two unrelated events with the same name and never see each other. That
// failure is silent: both sides "work", nothing is ever refreshed.
//
// THE DACL IS ALSO LOAD-BEARING. The service creates the event as LocalSystem,
// which by default produces an object a non-elevated user cannot even open. The
// tray runs unelevated as ShopFloor by design (an ELEVATED tray is refused by
// the probe, because it would read the wrong drive table). So the creator grants
// Users explicit Synchronize + Modify rights, or the tray gets access-denied on
// open and the refresh silently never happens.
public sealed class UserContextRefreshChannel
{
// Session-global so session 0 and the interactive session share one object.
public const string EventName = @"Global\ShopDbEndpointAgent.UserContextRefresh";
private readonly ILogger logger;
// Takes the base ILogger rather than ILogger<T>: both the service (generic
// host) and the tray (its own file-backed logger named "Tray") construct
// this, and the tray has no DI container to resolve a typed logger from.
public UserContextRefreshChannel(ILogger logger)
{
this.logger = logger;
}
// Signal the tray that a fresh probe is wanted.
//
// Returns false when the event could not be opened or created, which is the
// normal state on a machine with nobody signed in and no tray running. The
// caller treats that as "no refresh available" and reports what it already
// has - a stale mapped-drive list is worth strictly more than a missed
// heartbeat, because telemetry IS the liveness signal.
//
// AutoReset, so exactly one waiting tray is released per request and the
// event does not latch on for every subsequent listener iteration.
[SupportedOSPlatform("windows")]
public bool RequestRefresh()
{
try
{
using var handle = OpenOrCreate(out var created);
if (handle is null)
return false;
handle.Set();
logger.LogDebug(
"Requested a user-context refresh on {Event} (created={Created})",
EventName, created);
return true;
}
catch (Exception error)
{
// Never throws to the caller. This sits directly on the telemetry
// path, and a refresh is an enhancement to the report, not a
// precondition for sending it.
logger.LogDebug(error,
"Could not request a user-context refresh on {Event}", EventName);
return false;
}
}
// Tray side: block until the service asks for a refresh, the timeout expires,
// or the token is cancelled. Returns true only for an actual request.
[SupportedOSPlatform("windows")]
public bool WaitForRequest(TimeSpan timeout, CancellationToken token)
{
try
{
using var handle = OpenOrCreate(out _);
if (handle is null)
return false;
var index = WaitHandle.WaitAny(
new[] { handle, token.WaitHandle }, timeout);
// Index 1 is cancellation, WaitTimeout is the idle path; only index 0
// is a genuine request.
return index == 0;
}
catch (Exception error)
{
logger.LogDebug(error,
"Could not wait on the user-context refresh channel {Event}", EventName);
return false;
}
}
// Open the shared event, creating it if this process is first.
//
// Either side may start first - the service can boot before anyone signs in,
// and the tray can start before the service finishes initializing - so both
// sides use the same create-or-open call rather than designating an owner.
// Whoever wins applies the DACL; the loser opens the existing object.
[SupportedOSPlatform("windows")]
private EventWaitHandle? OpenOrCreate(out bool created)
{
created = false;
try
{
var security = new EventWaitHandleSecurity();
// BUILTIN\Users by SID, not by name: on a non-English Windows the
// group is not called "Users", and this agent ships to whatever
// locale the site runs. Same reasoning as the task registration.
var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
security.AddAccessRule(new EventWaitHandleAccessRule(
users,
EventWaitHandleRights.Synchronize | EventWaitHandleRights.Modify,
AccessControlType.Allow));
// FullControl for SYSTEM and Administrators so the service retains
// ownership semantics regardless of which side created the object.
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
security.AddAccessRule(new EventWaitHandleAccessRule(
system, EventWaitHandleRights.FullControl, AccessControlType.Allow));
var admins = new SecurityIdentifier(
WellKnownSidType.BuiltinAdministratorsSid, null);
security.AddAccessRule(new EventWaitHandleAccessRule(
admins, EventWaitHandleRights.FullControl, AccessControlType.Allow));
return EventWaitHandleAcl.Create(
initialState: false,
mode: EventResetMode.AutoReset,
name: EventName,
createdNew: out created,
eventSecurity: security);
}
catch (UnauthorizedAccessException)
{
// The object exists with a DACL that does not admit this caller.
// Retry read-side-only rights: the tray only ever needs to wait, and
// an event created by an older agent build may not grant Modify.
try
{
return EventWaitHandle.OpenExisting(EventName);
}
catch (Exception error)
{
logger.LogDebug(error,
"The user-context refresh event {Event} exists but is not accessible",
EventName);
return null;
}
}
}
}

View File

@@ -0,0 +1,389 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Reads the result file that the tray-run user-context probe writes into the
// signed-in user's ShopDB spool folder.
//
// Why a file and not a return value: the agent is a session-0 LocalSystem
// service, so anything session-scoped (mapped drives from "net use", per-user
// network state) simply does not exist in its own context. The probe runs as the
// operator under the tray, in a completely separate process with no pipe back to
// the service. A file in the user's own profile is the handoff, and the user can
// always write there without elevation.
//
// Freshness is judged from the 'collectedat' stamp inside the file, NOT by
// deleting the file around each read. Writer and reader are now independent
// processes on unrelated schedules, so a delete-then-read would race the tray
// and usually destroy a perfectly good result before it was ever reported. The
// file is therefore left alone and simply ignored once it ages out, which also
// means a logged-off machine stops reporting session data instead of pinning the
// last operator's mapped drives forever.
public sealed class UserContextSpool
{
public const string SpoolFolderName = "ShopDB";
public const string ResultFileName = "usercontext.json";
// The one definition of where the spool lives. Both the probe (writer) and
// this class (reader) call it, so the two can no longer disagree.
//
// The writer used to build its own path from $env:USERPROFILE while the
// reader used the pinned ShopFloor profile. On a locked-down image those are
// not the same directory - the tray is launched through explorer.exe by an
// elevated installer and can inherit another account's environment block, as
// the tray already documents for %TEMP%. The probe then wrote a perfectly
// good file into the wrong profile, exited 0, and the service found nothing:
// no spool ever appeared and no mapped drive was ever reported.
public static string PinnedSpoolDirectory()
=> Path.Combine(FileLoggerProvider.ShopFloorProfileDirectory(), SpoolFolderName);
public static string PinnedResultPath()
=> Path.Combine(PinnedSpoolDirectory(), ResultFileName);
// A probe result is small - a handful of mapped drives and listeners. A file
// larger than this is either corrupt or hostile, and we will not parse it.
private const long MaxResultBytes = 512 * 1024;
// A result older than this is treated as absent. Sized at roughly three tray
// probe intervals (30 min each), so a single missed run never blanks the card
// but genuinely stale session data retires quickly.
//
// This was 24h when the reader deleted the file after each read; the delete
// was doing the real retiring and the age check was only a backstop. Now that
// the spool persists, this value IS the staleness bound, so it is sized
// against the writer's cadence rather than the server's quiet-report window.
//
// Not read from the server: this is an offline-safe local bound, and the
// agent must retire stale session data even when it cannot reach ShopDB.
private static readonly TimeSpan MaxResultAge = TimeSpan.FromMinutes(90);
private readonly ILogger<UserContextSpool> logger;
public UserContextSpool(ILogger<UserContextSpool> logger)
{
this.logger = logger;
}
// Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because
// every shop-floor endpoint signs in as the same local ShopFloor account.
//
// This used to be resolved through WTSQueryUserToken/GetUserProfileDirectory,
// and that lookup is precisely what fails on a hardened endpoint: querying
// another session's token is a privileged operation that endpoint protection
// blocks. When it failed the service simply reported "no session data" while
// the tray was writing a perfectly good file, so writer and reader silently
// disagreed about the location and the operator saw an empty card.
//
// The token lookup is kept ONLY as a fallback for a machine whose operator
// account is named something else. If both are unavailable there is no
// session data, which costs mapped drives and idle time but never affects
// telemetry, commands or inventory.
public string? TryGetResultPath(out string? reason)
{
var pinned = FileLoggerProvider.ShopFloorProfileDirectory();
if (Directory.Exists(pinned))
{
reason = null;
return PinnedResultPath();
}
var profileDirectory =
InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error);
if (string.IsNullOrWhiteSpace(profileDirectory))
{
reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, "
+ "and the interactive profile could not be resolved either: "
+ (error ?? "unknown error");
return null;
}
reason = null;
return Path.Combine(profileDirectory, SpoolFolderName, ResultFileName);
}
// Remove a result file. Only used to discard one that is corrupt or has aged
// out; a valid result is left in place for the next read.
public void ClearStaleResult(string resultPath)
{
try
{
if (File.Exists(resultPath))
{
File.Delete(resultPath);
logger.LogDebug(
"Removed a stale user-context spool file at {Path}", resultPath);
}
}
catch (Exception exception)
{
// Not fatal on its own, but it does mean we cannot trust whatever we
// read next, so the caller must know.
logger.LogWarning(exception,
"Could not remove the stale user-context spool file at {Path}", resultPath);
}
}
public void EnsureSpoolDirectory(string resultPath)
{
var directory = Path.GetDirectoryName(resultPath);
if (string.IsNullOrWhiteSpace(directory))
return;
try
{
Directory.CreateDirectory(directory);
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not create the user-context spool directory at {Directory}", directory);
}
}
// Read the probe result without consuming it. Returns null when there is no
// usable, sufficiently recent result, which the caller reports as "no session
// data this cycle" rather than as empty data - absent and empty mean
// different things here.
public Dictionary<string, object?>? TryReadResult(string resultPath)
{
try
{
if (!File.Exists(resultPath))
return null;
var info = new FileInfo(resultPath);
if (info.Length == 0)
{
logger.LogDebug("The user-context spool file was empty.");
return null;
}
if (info.Length > MaxResultBytes)
{
logger.LogWarning(
"The user-context spool file was {Bytes} bytes, above the {Limit} byte "
+ "limit; discarding it.",
info.Length, MaxResultBytes);
return null;
}
var json = File.ReadAllText(resultPath);
if (string.IsNullOrWhiteSpace(json))
return null;
var parsed = JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
if (parsed is null || parsed.Count == 0)
return null;
if (IsExpired(parsed, resultPath))
return null;
if (IsWrongAccount(parsed))
return null;
if (IsElevatedCollection(parsed))
return null;
return parsed;
}
catch (JsonException exception)
{
// A truncated file is the expected shape of this failure: the probe
// was still writing when we looked. Next cycle gets a clean one.
logger.LogWarning(exception,
"The user-context spool file was not valid JSON; discarding it.");
return null;
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not read the user-context spool file at {Path}", resultPath);
return null;
}
}
// Read the spool WITHOUT removing it. The probe overwrites the file on every
// run (write .tmp, move into place), so the newest result always replaces the
// previous one and there is nothing for the reader to clean up.
//
// This used to delete the file after a successful read, to stop a dead tray
// re-reporting the same drives forever. The collectedat age check already
// does that job, and deleting created a worse problem: lastUserContext in the
// collector is in-memory only, so a service restart wiped the cache while the
// spool had already been consumed. The agent then reported NO mapped drives
// until the tray's next probe - and the installer restarts the service and
// the tray on every install, so a fresh install was guaranteed to show an
// empty card at exactly the moment someone looked at it.
//
// The contents are the operator's own session data, visible to them in their
// own context, so leaving the file in place costs nothing.
public Dictionary<string, object?>? ConsumeResult(string resultPath)
=> TryReadResult(resultPath);
// The collectedat stamp on disk, or null when there is no readable stamp.
//
// This is how the service PROVES a refresh actually happened rather than
// assuming it. Signalling the tray is asynchronous and the probe takes a
// second or two, so a caller that simply slept would either send the old
// spool anyway or pad the delay and hope. Watching this value advance past
// the pre-request stamp is the only honest way to know the file in front of
// us is the answer to OUR request and not the previous one.
//
// Deliberately NOT routed through TryReadResult: this must report the stamp
// of whatever is on disk, including a spool that is expired, elevated, or
// from the wrong account. Those are all rejected later for reporting, but
// for ordering we only care whether the file was rewritten - conflating "not
// usable" with "not fresh" would make the service wait out the full timeout
// on every cycle for a machine whose spool is being rejected for some other
// reason.
//
// Also deliberately NOT the file's LastWriteTime, for the reason IsExpired
// gives: a spool can be copied or restored with a roaming profile and carry
// a misleading filesystem timestamp. The stamp inside the file is written by
// the probe itself.
public DateTimeOffset? TryReadCollectedAt(string resultPath)
{
try
{
if (!File.Exists(resultPath))
return null;
var info = new FileInfo(resultPath);
if (info.Length == 0 || info.Length > MaxResultBytes)
return null;
var json = File.ReadAllText(resultPath);
if (string.IsNullOrWhiteSpace(json))
return null;
var parsed = JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
if (parsed is null
|| !parsed.TryGetValue("collectedat", out var raw)
|| raw is null)
{
return null;
}
if (DateTimeOffset.TryParse(
raw.ToString(),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out var collectedat))
{
return collectedat;
}
return null;
}
catch (Exception exception)
{
// A torn read while the probe is mid-write is the expected failure
// here and is not worth a warning: the caller polls, so the next
// attempt a moment later sees the completed file.
logger.LogDebug(exception,
"Could not read the user-context collectedat stamp at {Path}", resultPath);
return null;
}
}
// Reject a spool that some other account produced.
//
// Pinning the path guarantees the file lands where the reader looks, but it
// cannot guarantee WHO ran the probe. Mapped drives and idle time are per
// logon session, so a probe that ran as an administrator (for example a tray
// that inherited an elevated installer's environment) would enumerate that
// account's drive table and write it into the operator's spool. That is worse
// than reporting nothing, because the card would look populated and correct
// while showing drives the operator does not have.
//
// The probe stamps $env:USERNAME into the file, so the mismatch is checkable.
// Logged at warning, not debug: this is a misconfigured endpoint, not a quiet
// absence of data.
private bool IsWrongAccount(Dictionary<string, object?> parsed)
{
if (!parsed.TryGetValue("username", out var raw) || raw is null)
{
logger.LogWarning(
"The user-context spool has no username stamp; ignoring it because "
+ "the session it describes cannot be confirmed.");
return true;
}
var username = raw.ToString();
if (string.Equals(username, FileLoggerProvider.ShopFloorUserName,
StringComparison.OrdinalIgnoreCase))
{
return false;
}
logger.LogWarning(
"The user-context spool was written by {Actual}, not {Expected}; "
+ "ignoring it. Session data is per logon session, so this would "
+ "report the wrong account's mapped drives.",
username, FileLoggerProvider.ShopFloorUserName);
return true;
}
// An elevated probe runs under a different logon token to the operator's
// interactive one and enumerates a different, usually empty, drive table.
// The agent refuses to collect while elevated, so a spool stamped elevated
// should not exist; this rejects any that predates that guard or arrives
// from a hand-run --collect-usercontext in an admin shell.
//
// Note the asymmetry with IsWrongAccount: a MISSING stamp is accepted here
// rather than rejected. An unstamped spool is simply one written by an older
// agent, and treating that as elevated would blank out session data across a
// fleet mid-upgrade. An unstamped spool has already had to pass the username
// check to reach this point.
private bool IsElevatedCollection(Dictionary<string, object?> parsed)
{
if (!parsed.TryGetValue("elevated", out var raw) || raw is null)
return false;
var elevated = raw is JsonElement element
? element.ValueKind == JsonValueKind.True
: string.Equals(raw.ToString(), "true", StringComparison.OrdinalIgnoreCase);
if (!elevated)
return false;
logger.LogWarning(
"The user-context spool was collected by an ELEVATED process; ignoring "
+ "it. Mapped drives are per logon token, so an elevated probe reports "
+ "the wrong drive table even under the right account. Collection should "
+ "come from the 'ShopDB user-context probe' scheduled task, which runs "
+ "at RunLevel Limited.");
return true;
}
// Age is taken from the stamp the probe wrote, not from the file's last
// write time, because the file can be copied or restored with a roaming
// profile and carry a misleading timestamp. A result with no readable stamp
// is treated as expired: we cannot prove it is current, so we will not claim
// it is.
private bool IsExpired(Dictionary<string, object?> parsed, string resultPath)
{
if (!parsed.TryGetValue("collectedat", out var raw) || raw is null
|| !DateTimeOffset.TryParse(
raw.ToString(),
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out var collectedat))
{
logger.LogDebug(
"The user-context spool file has no usable collectedat stamp; ignoring it.");
return true;
}
var age = DateTimeOffset.UtcNow - collectedat;
if (age <= MaxResultAge)
return false;
logger.LogDebug(
"The user-context spool file is {Hours} hours old, past the {Limit} hour limit.",
Math.Round(age.TotalHours, 1), MaxResultAge.TotalHours);
ClearStaleResult(resultPath);
return true;
}
}

View File

@@ -92,6 +92,57 @@ else
var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
// --- ScheduledTaskPolicy ----------------------------------------------------
// Pure string logic, unlike LaunchPathPolicy: a task path names an entry in the
// Task Scheduler namespace, not a file, so there is no disk state to plant. The
// cases that matter are the ones where a candidate LOOKS like an approved entry
// as a string but is not the same task.
void CheckTask(string label, bool expectAccept, string candidate, ScheduledTaskPolicy 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}");
}
Console.WriteLine();
Console.WriteLine("--- ScheduledTaskPolicy ---");
var taskPolicy = new ScheduledTaskPolicy(new[] { @"\ReportIP", @"\SFLD\" });
CheckTask("exact approved task", true, @"\ReportIP", taskPolicy);
// Task Scheduler is case-insensitive, so the policy must be too, or the same
// task typed differently would be refused.
CheckTask("approved task, different case", true, @"\reportip", taskPolicy);
// The operator should not have to know the rooted form.
CheckTask("approved task without leading separator", true, "ReportIP", taskPolicy);
CheckTask("approved task with forward slashes", true, "/ReportIP", taskPolicy);
// The prefix-suffix confusion this policy exists to prevent: "\ReportIP" must
// NOT authorize "\ReportIPEvil".
CheckTask("sibling task sharing an approved prefix", false, @"\ReportIPEvil", taskPolicy);
CheckTask("unapproved task", false, @"\Evil", taskPolicy);
CheckTask("empty task path", false, "", taskPolicy);
// Folder entries authorize everything beneath them, but not a sibling folder
// whose name merely starts the same way.
CheckTask("task inside an approved folder", true, @"\SFLD\ApplyDSCConfig", taskPolicy);
CheckTask("folder itself", true, @"\SFLD", taskPolicy);
CheckTask("sibling folder sharing the prefix", false, @"\SFLDEvil\Thing", taskPolicy);
// Traversal would let an approved prefix string-match something else entirely.
CheckTask("traversal out of an approved folder", false, @"\SFLD\..\Evil", taskPolicy);
// Defense in depth: the invocation uses ArgumentList, but a task path with
// these characters is not one we should be starting.
CheckTask("argument-injection characters", false, "\\ReportIP\" & calc", taskPolicy);
CheckTask("control character", false, "\\Report\u0000IP", taskPolicy);
// An empty override must fall back to the shipped defaults, never to "allow
// everything".
var defaultPolicy = new ScheduledTaskPolicy(Array.Empty<string>());
CheckTask("shipped default is honored when override is empty", true, @"\ReportIP", defaultPolicy);
CheckTask("empty override does not mean allow-all", false, @"\Evil", defaultPolicy);
Console.WriteLine();
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
return failures == 0 ? 0 : 1;

View File

@@ -16,5 +16,9 @@
</PropertyGroup>
<ItemGroup>
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
<!-- ScheduledTaskPolicy is pure string logic with no filesystem state to
plant, so unlike LaunchPathPolicy it would run anywhere. It is linked
here anyway to keep both allowlist policies provable from one harness. -->
<Compile Include="../ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs" Link="ScheduledTaskPolicy.cs" />
</ItemGroup>
</Project>

View File

@@ -8,11 +8,70 @@
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
<!-- 1.1.0: the tray now collects session-scoped user context. It invokes
shopdbagent.exe with the collect-usercontext verb on a timer (once
shortly after logon, then periodically) and the agent service reads the
resulting spool file. The tray owns this because it is the only ShopDB
process already running as the operator: mapped drives, user-owned
listeners, and input idle time do not exist in the service's session 0.
Requires agent 1.8.0 or newer: an older agent does not recognize the
verb and falls through to starting a full host, so the tray and agent
must be deployed together. -->
<!-- 1.0.0: first explicitly stamped tray version. The tray and the agent
service version independently (they are separate exes and can be pushed
apart), so the tray carries its own <Version> and the agent reports it
as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). -->
<Version>1.0.0</Version>
<!-- 1.1.1: the tray now logs. Every user-context failure path was silent -
a missing shopdbagent.exe returned bare, and the probe launch was
wrapped in an empty catch - so "no mapped drives" produced no evidence
anywhere on the machine. The tray writes
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log (the operator's
own profile; it cannot write to ProgramData or Program Files) covering
tray startup, agent-exe resolution, probe launch and probe exit code. -->
<!-- 1.2.0: the tray stops claiming success it did not verify. The probe
completion message printed a hardcoded spool path regardless of what
happened, so a probe that exited 0 without writing anything read as a
clean collection; it now reports the real pinned path and distinguishes
spool-present, exit-0-but-no-file, and non-zero exit. Startup probe
delay is chosen from session uptime: the 2-minute delay still applies at
logon (drive mappings are not established yet), but a tray started into
an already-running session - which is what an installer does - probes
after 15 seconds instead of leaving a fresh install blind for the full
30-minute cycle. A log sink that cannot open its directory raises a
one-time balloon, since it cannot report its own failure through the
log. Requires agent 1.9.0: the two carry the shared pinned spool path
and must be deployed together. -->
<!-- 1.3.0: the tray is no longer the authoritative user-context collector.
The 'ShopDB user-context probe' scheduled task now owns collection,
because it is the only mechanism that runs in the operator's own logon
session at RunLevel Limited; the tray keeps its own probe cadence as
belt-and-braces and writes the same spool. The installer no longer
starts the tray directly (explorer.exe de-elevated it but did not
change ACCOUNT, so an admin install produced an admin tray whose
probe saw the wrong drive table). Autostart at logon is the Run key;
install-time startup on a machine someone is already signed in to is
a transient 'ShopDB tray first launch' scheduled task, which is the
only way to enter the operator's existing session unelevated. Probe
exit code 2 - the agent refusing to collect while elevated - is
reported by name instead of as a bare code. Requires agent 1.10.1. -->
<!-- 1.4.0: the tray answers on-demand refresh requests. A background
listener waits on Global\ShopDbEndpointAgent.UserContextRefresh and
runs the probe when the service asks, which is what makes a WebUI
"Refresh Status" click produce current mapped drives instead of the
last cadence's answer - the service cannot collect this itself at any
price, since drive letters live on the operator's logon token, so the
request has to be serviced HERE. Each probe is marshalled onto the UI
thread: the cadence timer and a service request could otherwise both
pass the in-flight guard and race the same spool file. "Send Status"
now awaits a probe before invoking the once verb, because that verb
reads the spool and would otherwise publish the previous probe's
answer to an operator who just changed something and is watching
for it. The probe
body is one shared method used by all three callers rather than three
copies of the exit-code handling. A tray that cannot open the channel
logs and keeps its timer, so the failure mode is "refresh is not
immediate", not "session data stops working". Requires agent 1.11.0. -->
<Version>1.4.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -1,6 +1,8 @@
using System.Diagnostics;
using System.Reflection;
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
@@ -25,8 +27,83 @@ internal sealed class TrayContext : ApplicationContext
private readonly Icon brandIcon;
private bool sending;
// Session-scoped collection. The service is LocalSystem and cannot see the
// operator's mapped drives, user-owned listeners, or input idle time, so the
// tray - which is already the operator - collects them and leaves the result
// in a spool file the service reads on its own cadence.
//
// Deliberately longer than the telemetry cadence: this data changes rarely
// (a mapped drive is mapped at logon and stays mapped), and each run spawns
// a short PowerShell process on a machine an operator is actively using.
private static readonly TimeSpan UserContextInterval = TimeSpan.FromMinutes(30);
// Logon is the worst moment to add work: drive mappings and profile setup
// are still settling, and a probe fired now would capture an incomplete
// picture while competing with everything else starting up.
private static readonly TimeSpan UserContextStartupDelay = TimeSpan.FromMinutes(2);
// ...but that reasoning only applies at logon. When the tray starts into a
// session that has been up for a while - which is what an installer restart
// or a manual relaunch does - nothing is settling and the full delay just
// extends the window where the server has no session data. The installer
// kills and restarts the tray on every install, so the 2-minute delay was
// being paid at exactly the moment someone was watching the card to see
// whether the install had worked.
private static readonly TimeSpan UserContextEstablishedDelay = TimeSpan.FromSeconds(15);
// How long the session must have been up to count as established. Comfortably
// past the logon storm, well short of any realistic install-to-check gap.
private static readonly TimeSpan EstablishedSessionUptime = TimeSpan.FromMinutes(5);
private readonly System.Windows.Forms.Timer userContextTimer;
private bool userContextStarted;
private bool collectingUserContext;
// Lets the service ask this tray to re-probe on demand, so a "Refresh
// Status" click in the WebUI produces genuinely current session data instead
// of whatever the last 30-minute tick happened to capture. The service
// cannot collect this itself at any price - mapped drives live on the
// operator's logon token - so the only way for an on-demand refresh to be
// accurate is for the request to end up here, in the operator's session.
private readonly UserContextRefreshChannel? refreshChannel;
private readonly CancellationTokenSource refreshCancellation = new();
// How long each wait blocks before looping. The wait returns false on
// timeout and the loop simply waits again, so this is not a deadline for
// anything - it just bounds how long the handle stays open, which lets a
// channel that was somehow broken get re-opened on the next pass instead of
// leaving the tray deaf until it restarts.
private static readonly TimeSpan RefreshWaitTimeout = TimeSpan.FromMinutes(5);
// The UI thread's context, captured in the constructor. The listener runs on
// a background thread, but every probe attempt is marshalled back here so
// that collectingUserContext is only ever read or written from one thread.
// Without this the timer tick and a service request could both pass the
// guard and launch two probes at the same spool file.
private readonly SynchronizationContext? uiContext;
// The tray logs into the operator's own profile
// (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log), NOT into the
// service's ProgramData directory. The operator cannot write there, and the
// file sink fails silently when it cannot create its directory, so tray-side
// failures previously left no trace anywhere. Keeping the two apart means a
// tray problem is diagnosable from a file the operator can actually reach.
private readonly FileLoggerProvider logProvider;
private readonly ILogger logger;
public TrayContext()
{
logProvider = new FileLoggerProvider(
FileLoggerProvider.UserProfileLogDirectory(),
filePrefix: "trayagent");
logger = logProvider.CreateLogger("Tray");
var startupDelay = ResolveStartupDelay();
logger.LogInformation(
"Tray {Version} started; user-context probe in {Delay}, then every {Interval}.",
typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown",
startupDelay, UserContextInterval);
var menu = new ContextMenuStrip();
menu.Items.Add("Send Status now", null, OnSendStatus);
menu.Items.Add("Report an issue...", null, OnReportIssue);
@@ -45,6 +122,218 @@ internal sealed class TrayContext : ApplicationContext
ContextMenuStrip = menu,
};
trayIcon.DoubleClick += OnSendStatus;
// A dead log sink cannot report itself through the log. Complaining via
// the logger would write the one message that explains the silence into
// the sink that is swallowing messages, so the balloon is the only
// channel that survives. Shown once at startup and never repeated: this
// is a "your diagnostics are blind" notice for whoever is standing at the
// machine, not a recurring nag about something the operator cannot fix.
if (logProvider.SinkProblem is not null)
{
trayIcon.ShowBalloonTip(
10000,
"ShopDB Endpoint Agent",
"Tray logging is unavailable, so tray diagnostics will not be "
+ $"recorded. {logProvider.SinkProblem}",
ToolTipIcon.Warning);
}
userContextTimer = new System.Windows.Forms.Timer
{
Interval = (int)startupDelay.TotalMilliseconds,
};
userContextTimer.Tick += OnUserContextTimer;
userContextTimer.Start();
uiContext = SynchronizationContext.Current;
// Best-effort. A tray that cannot open the channel still probes on its
// timer, so the failure mode is "refresh is not immediate", not "session
// data stops working" - which is why this is caught and logged rather
// than allowed to take the tray down at startup.
try
{
refreshChannel = new UserContextRefreshChannel(
logProvider.CreateLogger(nameof(UserContextRefreshChannel)));
StartRefreshListener();
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not open the user-context refresh channel; on-demand "
+ "refresh requests from the service will be ignored and session "
+ "data will only update on the {Interval} timer.",
UserContextInterval);
}
}
// Waits for service refresh requests for the life of the tray.
//
// A dedicated background thread rather than a Task: this blocks on a kernel
// wait handle essentially forever, which is exactly the workload that should
// not sit on a thread-pool thread. IsBackground keeps it from holding the
// process open at exit.
private void StartRefreshListener()
{
var thread = new Thread(() =>
{
var token = refreshCancellation.Token;
while (!token.IsCancellationRequested)
{
try
{
if (!refreshChannel!.WaitForRequest(RefreshWaitTimeout, token))
continue;
logger.LogInformation(
"The service requested a user-context refresh.");
// Marshalled, not awaited: the service is not waiting on a
// reply through this channel, it is watching the spool file's
// collectedat stamp. Our only job is to get the probe started
// on the UI thread and go back to waiting.
var post = uiContext;
if (post is not null)
post.Post(_ => _ = CollectUserContextAsync(), null);
else
_ = CollectUserContextAsync();
}
catch (OperationCanceledException)
{
return;
}
catch (Exception exception)
{
// Never let a bad wait kill the listener - if this thread
// exits, on-demand refresh is silently dead for the rest of
// the session with no other symptom.
logger.LogWarning(exception,
"The user-context refresh listener failed; retrying.");
try
{
Thread.Sleep(TimeSpan.FromSeconds(5));
}
catch (Exception)
{
return;
}
}
}
})
{
IsBackground = true,
Name = "ShopDB user-context refresh listener",
};
thread.Start();
}
// Fires once after the startup delay, then re-arms at the steady-state
// interval. Failures are swallowed on purpose: session data is a
// nice-to-have, and an operator must never see a dialog or a crashed tray
// because a probe could not read a drive mapping.
private async void OnUserContextTimer(object? sender, EventArgs e)
{
if (!userContextStarted)
{
userContextStarted = true;
userContextTimer.Interval = (int)UserContextInterval.TotalMilliseconds;
}
await CollectUserContextAsync();
}
// Runs the probe once and returns when the spool has been rewritten.
//
// Shared by all three callers - the cadence timer, an on-demand service
// request, and "Send Status now" - so there is exactly one place that knows
// how to invoke the probe and how to interpret its exit codes. Returns true
// only when a spool file is actually on disk afterwards, which is what lets
// Send Status decide whether it is worth waiting on.
private async Task<bool> CollectUserContextAsync()
{
if (collectingUserContext)
{
// Another probe is already in flight against the same spool path.
// Its result is at most a second or two old and satisfies whatever
// this caller wanted, so coalescing is correct here, not a shortcut.
logger.LogDebug(
"A user-context probe is already running; skipping this request.");
return false;
}
var agentExe = ResolveAgentExe();
if (agentExe is null)
{
// Previously a bare return. This is the single most likely reason for
// "no mapped drives" with nothing at all in any log, so it must say so.
logger.LogWarning(
"Skipping the user-context probe: {Agent} was not found beside the tray "
+ "({Beside}) or at {Installed}.",
AgentExeName, AppContext.BaseDirectory, DefaultInstallDir);
return false;
}
collectingUserContext = true;
try
{
logger.LogInformation("Running the user-context probe: {Agent}", agentExe);
var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext");
// Check the disk rather than announcing what the exit code implies.
// This line used to print a hardcoded path whenever the child exited
// 0, so it reported "spool written to C:\Users\ShopFloor\ShopDB" for
// weeks while the probe was actually writing into a different profile
// and the service was finding nothing.
var resultPath = UserContextSpool.PinnedResultPath();
if (exitCode == 0 && File.Exists(resultPath))
{
logger.LogInformation(
"The user-context probe finished; spool present at {Spool}.",
resultPath);
return true;
}
else if (exitCode == 0)
{
logger.LogWarning(
"The user-context probe reported success but no spool file is "
+ "present at {Spool}.", resultPath);
}
else if (exitCode == 2)
{
// The agent refused because it was elevated. Called out by name
// because as a bare exit code it reads like a probe fault and
// sends the reader looking for a drive or WMI problem that is
// not there - the fix is to start the tray unelevated, or let
// the scheduled task do the collecting.
logger.LogWarning(
"The user-context probe refused to run because this tray is "
+ "ELEVATED. Mapped drives are per logon token, so an elevated "
+ "probe would report the wrong drive table. Start the tray "
+ "unelevated, or rely on the 'ShopDB user-context probe' "
+ "scheduled task.");
}
else
{
logger.LogWarning(
"The user-context probe exited with code {ExitCode}; expected a "
+ "spool at {Spool}.", exitCode, resultPath);
}
return false;
}
catch (Exception exception)
{
// No longer silent. The agent logs its own detail, but if the process
// could not even be started the agent never ran and never logged.
logger.LogWarning(exception, "Could not run the user-context probe.");
return false;
}
finally
{
collectingUserContext = false;
}
}
private async void OnReportIssue(object? sender, EventArgs e)
@@ -169,6 +458,18 @@ internal sealed class TrayContext : ApplicationContext
try
{
// Probe BEFORE reporting, and await it. The operator clicking this
// has just changed something - remapped a drive, signed in - and
// expects the result to show up. --once reads the spool file, so a
// probe started after it, or not awaited, would publish the previous
// probe's answer and the operator would click again and see the same
// stale card.
//
// Not fatal if it fails: a report with older session data still
// carries the heartbeat, inventory and events, which is most of what
// this button is for.
await CollectUserContextAsync();
int exitCode = await RunAgentAsync(agentExe, "--once");
if (exitCode == 0)
ShowBalloon("Send Status", "Status sent to ShopDB.",
@@ -249,6 +550,35 @@ internal sealed class TrayContext : ApplicationContext
startInfo.Environment.Remove(key);
}
// Full delay at logon, short delay when the session is already established.
//
// Environment.TickCount64 is machine uptime, not session uptime, which is a
// deliberate simplification: the two differ only when someone signs out and
// back in without rebooting, and treating that as "established" is harmless -
// the profile is already built and the drives are already mapped in that
// case too. A failure to read it falls back to the conservative long delay.
private TimeSpan ResolveStartupDelay()
{
try
{
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
if (uptime >= EstablishedSessionUptime)
{
logger.LogInformation(
"Session has been up {Uptime}; using the established-session probe delay.",
uptime);
return UserContextEstablishedDelay;
}
}
catch (Exception exception)
{
logger.LogDebug(exception,
"Could not read system uptime; using the logon probe delay.");
}
return UserContextStartupDelay;
}
// Same directory first (the installer ships the tray next to the agent),
// then the known Program Files install directory as a fallback.
private static string? ResolveAgentExe()
@@ -316,6 +646,15 @@ internal sealed class TrayContext : ApplicationContext
{
if (disposing)
{
userContextTimer.Stop();
userContextTimer.Dispose();
// Cancel and let the listener unwind on its own. The channel opens
// and closes its handle inside each wait rather than holding one, so
// there is nothing here to dispose.
refreshCancellation.Cancel();
refreshCancellation.Dispose();
trayIcon.Dispose();
brandIcon.Dispose();
}

View File

@@ -22,15 +22,110 @@ 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 collectUserContext = args.Any(a =>
string.Equals(a, "--collect-usercontext", StringComparison.OrdinalIgnoreCase));
var reportPayloadPath = ArgValue(args, "--report");
var agentVersion = Assembly.GetExecutingAssembly()
.GetName().Version?.ToString() ?? "0.0.0";
// FIRST, ahead of everything else. This verb runs as the signed-in operator (the
// tray invokes it) and must not touch a single privileged resource on the way in.
//
// It used to sit below the bootstrap logger, the fault monitor, and the config
// load, which meant the operator-context process opened the SYSTEM-owned
// ProgramData log directory, attempted an EventLog source registration that only
// an administrator can perform, and read CollectorKey out of HKLM - all before it
// discovered it was only being asked to write a file into the user's own profile.
// On an EPM-managed host (CyberArk) those are exactly the calls a broker
// intercepts, and the registry read put the collector secret in a process that
// has no business holding it.
//
// It also has no config dependency by design: it only writes into the caller's
// own profile and never talks to the server, so it must still work on a machine
// whose CollectorKey has not been provisioned yet - otherwise session data would
// be missing on precisely the endpoints that are hardest to diagnose.
if (collectUserContext)
{
// First statement in this branch. The scheduled task runs this exe directly
// in the operator's interactive session, and shopdbagent.exe is a console
// app, so without this a black window flashes up on every collection cycle.
// Done before any logging or probing so the window is gone immediately
// rather than lingering for the life of the probe.
ConsoleWindow.Hide();
// Logs to the OPERATOR's profile, not the service's ProgramData directory.
// A non-privileged user cannot write to the SYSTEM-owned log directory, and
// the file sink degrades to a no-op when it cannot create its directory, so
// pointing this at the service location meant every probe failure was
// discarded unread - which is exactly how a broken probe looked identical to
// a probe that was never invoked.
using var probeLogProvider = new FileLoggerProvider(
FileLoggerProvider.UserProfileLogDirectory(),
filePrefix: "trayagent");
using var probeLoggerFactory = LoggerFactory.Create(
logging => logging.AddProvider(probeLogProvider));
var probeLogger = probeLoggerFactory.CreateLogger<UserContextProbe>();
ReportSinkProblem(probeLogProvider, "user-context probe");
// Refuse to collect from an elevated process. Drive letters are a property
// of the logon token, not the machine: an elevated process gets a different
// token to the operator's interactive one and enumerates a DIFFERENT, almost
// always empty, drive table. Measured on a live endpoint, the same two
// shares reported Unavailable from the elevated shell and OK from the
// operator's, in both Win32_NetworkConnection and Get-SmbMapping.
//
// Refusing here rather than filtering later is deliberate. An elevated probe
// does not fail - it succeeds and reports every mapped drive as broken,
// which is indistinguishable from a genuine outage at the far end. Better to
// write nothing and say why than to publish a confident wrong answer.
if (IsProcessElevated())
{
probeLogger.LogError(
"Refusing to collect user context: this process is elevated. Mapped drives " +
"are per-logon-token, so an elevated probe reports the wrong drive table. " +
"Run the '{Task}' scheduled task (RunLevel Limited) instead of invoking " +
"--collect-usercontext from an admin shell.",
"ShopDB user-context probe");
Console.Error.WriteLine(
"shopdbagent: refusing to collect user context from an elevated process; " +
"mapped drives would be wrong. Use the scheduled task.");
return 2;
}
var probe = new UserContextProbe(probeLogger);
return probe.Collect() ? 0 : 1;
}
// True when the current process holds the elevated (high-integrity) token.
static bool IsProcessElevated()
{
if (!OperatingSystem.IsWindows())
{
return false;
}
try
{
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}
catch
{
// Cannot tell. Treat as not elevated so an unreadable token does not
// suppress collection on a host where the probe would have worked; the
// spool's own account stamp is still checked on the way back in.
return false;
}
}
// Bootstrap logger first so config loading (including the registry read that
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
ReportSinkProblem(bootstrapFileLogger, "service");
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
faultMonitor.Install();
@@ -191,10 +286,37 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
sp.GetRequiredService<ILogger<PowerShellUtilityRunner>>()));
}
// Persisted per-channel event watermark so each Windows event record is
// reported exactly once, instead of re-sending the newest window of every
// channel on every cadence tick.
services.AddSingleton(sp => new EventWatermarkStore(
sp.GetRequiredService<ILogger<EventWatermarkStore>>()));
// Session-scoped collection. The agent is LocalSystem, so mapped drives and
// user-owned listeners are invisible to it. The tray, which already runs as
// the operator, writes the spool file; the service only reads it.
//
// UserContextProbe is deliberately NOT registered here. The service must
// never run the probe: descending into the session with CreateProcessAsUser
// is the reverted design, and leaving a resolvable dependency in the
// container is an invitation to reintroduce it. The probe is constructed
// only on the --collect-usercontext path, which the tray invokes.
services.AddSingleton(sp => new UserContextSpool(
sp.GetRequiredService<ILogger<UserContextSpool>>()));
// The ask-the-tray side of the same split. Registering this is NOT a
// loophole around the paragraph above: the service still cannot collect
// session data, it can only request that the process which can does so, and
// then read the file that appears. The probe stays out of this container.
services.AddSingleton(sp => new UserContextRefreshChannel(
sp.GetRequiredService<ILogger<UserContextRefreshChannel>>()));
services.AddSingleton<ICollector>(sp => new EndpointCollector(
sp.GetRequiredService<ILogger<EndpointCollector>>(),
config,
sp.GetService<IAgentUtilityRunner>()));
sp.GetService<IAgentUtilityRunner>(),
sp.GetRequiredService<EventWatermarkStore>(),
sp.GetRequiredService<UserContextSpool>()));
services.AddSingleton(sp => new CollectorClient(
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
@@ -206,7 +328,9 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
agentVersion,
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
sp.GetService<IAgentUtilityRunner>(),
sp.GetRequiredService<CollectSignal>()));
sp.GetRequiredService<CollectSignal>(),
sp.GetRequiredService<UserContextSpool>(),
sp.GetRequiredService<UserContextRefreshChannel>()));
// Command channel: outbound long-poll for allowlisted WebUI commands, run
// through the fail-closed executor. Hosted alongside telemetry but on its
@@ -231,3 +355,22 @@ static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
logging.AddProvider(new FileLoggerProvider(logDirectory));
logging.SetMinimumLevel(LogLevel.Information);
}
// Say so, loudly, when a file sink could not open its directory.
//
// FileLoggerProvider never throws on construction and silently drops lines when
// its directory is unusable. That is the right behaviour - logging must not take
// the agent down - but it made two different failures indistinguishable: a probe
// that ran and failed, and a probe whose log had nowhere to go. The provider
// records the reason; this is the part that was missing, the caller actually
// asking for it. stderr is the only sink guaranteed to exist at this point, so
// that is where the complaint goes.
static void ReportSinkProblem(FileLoggerProvider provider, string which)
{
if (provider.SinkProblem is null)
return;
Console.Error.WriteLine(
$"shopdbagent: the {which} log sink is unusable and log lines will be "
+ $"dropped - {provider.SinkProblem}");
}

View File

@@ -7,7 +7,88 @@
<Nullable>enable</Nullable>
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
<AssemblyName>shopdbagent</AssemblyName>
<!-- 1.6.3: operator issue reports stage under the interactive operator's
<!-- 1.10.1: the collect-usercontext verb hides its console window. The agent
is a console exe, which the tray used to mask by launching the probe with
CreateNoWindow; the scheduled task runs the exe directly in the operator's
session, so a black window appeared on every 30-minute collection. -->
<!-- 1.10.0: user context is collected by a scheduled task in the operator's
own logon session, not by a process the elevated installer launches.
Drive letters are per logon token: an elevated probe enumerates a
different, usually empty, drive table, so an install performed by an
admin produced a spool reporting every mapped drive as Unavailable while
the operator's own session showed OK. The installer no longer starts the
tray via explorer.exe (which de-elevates but does NOT change account) and
instead registers 'ShopDB user-context probe' against BUILTIN\Users at
RunLevel Limited, verifies it by reading the task back, and records the
outcome in the install ledger. The collect-usercontext verb now refuses
to run elevated (exit 2) rather than writing a confident wrong answer,
the probe stamps its token elevation, and the service rejects any spool
stamped elevated. Probe runs log the drive and listener counts. -->
<!-- 1.9.0: the spool is finally written where the service looks for it. The
probe script resolved its output directory from $env:USERPROFILE while
the reader used the pinned C:\Users\ShopFloor path; under a brokered or
re-parented tray launch those differ, so the probe reported success and
wrote the file somewhere the service never read. Both sides now derive
the path from one helper (UserContextSpool.PinnedResultPath). Collect()
verifies the file exists on disk before reporting success, because a
zero exit code from powershell.exe proved nothing about the write.
Minor rather than patch because the spool handoff itself changed shape
(AGENT-CONTRACT.md 5): the spool is now PERSISTED and overwritten each
run rather than consumed on read. The collector cache is in-memory only,
so consume-on-read plus a service restart blanked mapped drives until the
next tray cycle. Max age tightened 24h -> 90m now that staleness, not
deletion, is the retirement mechanism. A spool whose username stamp is
absent or not ShopFloor is rejected outright - wrong-session drive data
is worse than none.
The collect-usercontext branch moved to the top of Program.cs: it used to
open the SYSTEM-owned log directory, register an EventLog source, and
read CollectorKey out of HKLM before discovering it only needed to write
a file into the caller's own profile - all as the operator, and all
exactly what an EPM broker intercepts. Log filenames unified on
<date>-<component>.log across service, tray, and installer. A log sink
that cannot open its directory now says so on stderr instead of
discarding every line unread. Pairs with tray 1.2.0. -->
<!-- 1.8.1: user-context logging and spool location are split from the
service's. The probe runs as the signed-in operator but logged into
%ProgramData%\ShopDB\EndpointAgent\logs, which is SYSTEM-owned and not
writable from a user session; the file sink swallows that failure, so a
probe that failed and a probe that never ran looked identical - no log
line either way. The probe and the tray now log to
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log, which the
operator owns. The spool path is pinned to C:\Users\ShopFloor\ShopDB
instead of being resolved through WTSQueryUserToken (a privileged call
endpoint protection blocks, after which writer and reader disagreed
silently about the location). The service now CONSUMES the spool -
removing it after a successful parse - so a dead tray stops re-reporting
stale drives instead of pinning them forever. The probe itself is
launched with -EncodedCommand rather than a staged .ps1 run via -File:
a quarantined or missing temp script makes powershell.exe print usage
and exit -196608 without running a statement. -->
<!-- 1.8.0: user-context collection is inverted. The service no longer
descends into the operator's session with CreateProcessAsUser to run the
probe; that path needs to duplicate a token, is what endpoint protection
watches for, and produced no spool file at all on a live endpoint. The
tray, which is already running as the operator, now invokes
shopdbagent.exe with the collect-usercontext verb on its own cadence and
the service only reads the spool. Requires the 1.1.0 tray: an older tray
the new verb, so usercontext stays null until the tray is updated.
Spool freshness moved from delete-around-read to the in-file collectedat
stamp with a max age, because writer and reader are now independent
processes and a delete-then-read would race the tray and discard good
results. -->
<!-- 1.7.0: curated events are harvested against a persisted per-channel
Windows record-id watermark and carry eventrecordid, so one real event
is reported and stored exactly once instead of once per cadence tick
for as long as it stayed in the newest-25 window. Adds WER 1000/1002,
event XML fields, listening ports, an on-demand network_connections
command, pendingreboot, disk freepercent, and populates cpupercent/
memorypercent/idleseconds (previously declared but always null).
Session-scoped data (mapped drives, user-owned listeners, idle time)
comes from a probe run in the interactive user's session via a spool
file, because none of it exists in the agent's LocalSystem session 0.
run_script user-context waits are now bounded; the previous Infinite
wait could pin the command worker on a hung script.
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
@@ -50,7 +131,26 @@
(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.6.3</Version>
<!-- 1.11.0: on-demand user-context refresh. "Refresh Status" (collect_now)
and the tray's "Send Status" re-sent whatever was already in the spool,
so a drive the operator had just unmapped kept reporting OK until the
next 30-minute probe and no amount of clicking changed it. The service
still does not - and must not - collect session data itself; it signals
Global\ShopDbEndpointAgent.UserContextRefresh and waits up to 12s for the
spool's collectedat stamp to ADVANCE before collecting, which is the only
proof the file in front of it answers this request and not the previous
one. The wait is bounded and non-fatal by design: telemetry is the
liveness signal, so a timeout reports slightly stale session data rather
than skipping the report. UserContextSpool.TryReadCollectedAt reads the
stamp outside the usability gates, so a spool rejected as expired or
wrong-account does not burn the full timeout every cycle. Minor because
the spool handoff gains a second channel (AGENT-CONTRACT.md 5) and
because the installer now registers the probe task to the ShopFloor USER
rather than BUILTIN\Users: a group-principal task cannot be started by a
member of that group, so Start-ScheduledTask returned access denied and
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
is the side that listens, so an older tray simply never answers. -->
<Version>1.11.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -1,355 +0,0 @@
# install-shopdbagent.ps1
# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED
# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies
# the agent files to Program Files, runs a one-shot smoke test, then installs
# and starts the Windows service.
#
# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts
# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same
# values are also stamped into the installed appsettings.json as a fallback.
#
# The agent and tray are published as single-file self-contained executables,
# so copying the two .exe files plus appsettings.json is the complete payload.
[CmdletBinding()]
param(
# ShopDB server root URL for this instance. REQUIRED - there is no default,
# because a default here means one site's hostname shipped to every site
# (ADR-015) and, worse, a working install pointed at somebody else's server.
#
# https is required. The collector key travels in the X-API-Key header and
# every telemetry payload follows it, so plain http puts both on the wire in
# clear. Pass -AllowInsecureHttp only for a local test instance.
[Parameter(Mandatory = $true)]
[string] $BaseUrl,
# Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS.
# REQUIRED and never defaulted - a key in source is a key in git history, and
# this one is also the command channel's kill switch.
[Parameter(Mandatory = $true)]
[string] $CollectorKey,
# Permit an http:// BaseUrl. For a local test instance only.
[switch] $AllowInsecureHttp,
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
[string] $ServiceName = "ShopDbEndpointAgent",
# Interactive tray app that shows the system-tray icon and the on-demand
# "Send Status now" action. Separate process from the SYSTEM service (a
# service cannot draw a tray icon in a user session). Pass -NoTray to skip.
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[switch] $NoTray,
# Add a Microsoft Defender path exclusion for the install directory. The
# agent and tray are unsigned single-file self-contained executables, which
# Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as
# a false positive and quarantine, killing the tray before its icon appears.
# Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions
# centrally or the binaries are code-signed).
[switch] $NoDefenderExclusion
)
# Refuse cleartext unless it was asked for explicitly. The key is a bearer
# credential for the whole fleet's telemetry and command channel.
if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) {
throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance."
}
$ErrorActionPreference = "Stop"
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
function Assert-Admin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "This script must be run from an elevated (Run as administrator) PowerShell."
}
}
Assert-Admin
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
}
# Write HKLM values through the explicit 64-bit registry view. If this script
# runs under 32-bit PowerShell (some management tooling launches x86), the
# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent
# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and
# see nothing, so config "goes missing" and the tray never autostarts. Pinning
# the view removes that dependency on the launching shell's bitness.
function Set-Hklm64Value {
param(
[Parameter(Mandatory)] [string] $SubKey,
[Parameter(Mandatory)] [string] $Name,
[Parameter(Mandatory)] [string] $Value
)
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $base.CreateSubKey($SubKey)
try {
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String)
}
finally { $key.Dispose() }
}
finally { $base.Dispose() }
}
# Tear down any previously installed agent BEFORE touching files. The agent and
# tray are single-file self-contained exes; while the old service or tray is
# running it holds those .exe files open, so Copy-Item over them fails with a
# file-in-use error. Stopping the service, deleting it, and killing any lingering
# agent/tray processes up front makes the install a clean overwrite. Best-effort
# throughout: a missing service or process is the normal fresh-install case.
function Uninstall-PreviousAgent {
param(
[Parameter(Mandatory)] [string] $Service,
[Parameter(Mandatory)] [string] $TrayExe,
[Parameter(Mandatory)] [string] $TargetDir
)
$existing = Get-Service -Name $Service -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " stopping and removing existing service $Service ..."
if ($existing.Status -ne "Stopped") {
try { Stop-Service -Name $Service -Force -ErrorAction Stop }
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
}
# sc.exe delete only marks the service for deletion; the SCM finishes the
# removal once every handle closes. Give it a moment so the subsequent
# sc.exe create does not hit a "marked for deletion" (1072) error.
sc.exe delete $Service | Out-Null
Start-Sleep -Seconds 2
}
# Kill any lingering worker or tray processes that would keep the exes
# locked. Match by the installed exe base names; the service host process is
# shopdbagent, the tray is the TrayExe base name.
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe)
foreach ($name in @("shopdbagent", $trayBase)) {
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
foreach ($p in $procs) {
Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Also kill anything still running out of the target install directory,
# regardless of exe name. A tray from an older build (different exe name) or
# one launched from a stale path would otherwise survive the name-based sweep
# above, keep its exe locked, and leave a duplicate icon once step 5 starts
# the fresh tray. Match on the executable path so we only touch our own dir.
$targetPrefix = $TargetDir.TrimEnd('\') + '\'
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
$path = $null
try { $path = $p.Path } catch { $path = $null }
if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..."
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
}
}
# Brief settle so file handles are released before the copy overwrites them.
Start-Sleep -Milliseconds 500
}
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
Write-Host "BaseUrl : $BaseUrl"
Write-Host "InstallDir : $InstallDir"
Write-Host "ServiceName : $ServiceName"
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
# typically extracts installer.zip into Downloads, so every file carries a
# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with
# "Windows protected your PC" the first time they run - including the [3/5]
# smoke test below, which launches shopdbagent.exe out of $sourceDir before the
# installed copy exists. Unblocking the source folder up front keeps the smoke
# test (and any manual run from the extract folder) clean; the installed copies
# get unblocked again in step 2a after they land in Program Files. Best-effort:
# a file with no MotW is a no-op.
Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..."
Get-ChildItem -Path $sourceDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 0. Uninstall any previous agent first, so the file copy is a clean overwrite
# and does not fail on exes locked by a running service or tray.
Write-Host "`n[0/5] Removing any previously installed agent ..."
Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir
# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value).
$regPath = "HKLM:\SOFTWARE\GE\ShopDB"
Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
# 2. Copy agent files to the install directory. The agent and tray are
# single-file self-contained executables, so the .exe plus appsettings.json is
# the whole payload; there are no side-by-side runtime DLLs to copy.
Write-Host "[2/5] Copying agent files to $InstallDir ..."
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
if (-not (Test-Path $agentSource)) {
throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder."
}
Copy-Item -Path $agentSource -Destination $InstallDir -Force
$appsettings = Join-Path $sourceDir "appsettings.json"
$installedSettings = Join-Path $InstallDir "appsettings.json"
if (Test-Path $appsettings) {
Copy-Item -Path $appsettings -Destination $InstallDir -Force
}
# Copy the interactive tray app next to the agent so its same-dir resolution
# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a
# service-only (headless) build.
$trayInstalled = $null
if (-not $NoTray) {
$traySource = Join-Path $sourceDir $TrayExeName
if (Test-Path $traySource) {
Copy-Item -Path $traySource -Destination $InstallDir -Force
$trayInstalled = Join-Path $InstallDir $TrayExeName
Write-Host " copied tray app $TrayExeName"
}
else {
Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)"
}
}
# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM
# hive so the agent cannot READ the values the registry step just wrote. Stamp
# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads
# appsettings first, then lets the registry override it, so this is a safe
# no-op where the registry read works and the rescue where it does not.
if (Test-Path $installedSettings) {
try {
$json = Get-Content $installedSettings -Raw | ConvertFrom-Json
$json.baseurl = $BaseUrl
$json.apikey = $CollectorKey
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8
Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)"
}
catch {
Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)"
}
}
$exePath = Join-Path $InstallDir "shopdbagent.exe"
# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files
# extracted from a downloaded zip with a Zone.Identifier alternate data stream;
# SmartScreen reads that and blocks the unsigned exe with "Windows protected
# your PC" the first time it runs, and the operator sees only a "Don't run"
# button. Unblock-File removes the ADS so the smoke test and the tray launch
# start clean. Best-effort: a file with no MotW is a no-op.
Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..."
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
}
# 2b. Defender path exclusion for the install dir. Unsigned single-file
# self-contained binaries (and the tray's icon P/Invoke) can trip Defender
# heuristics and get quarantined, which is why the tray dies before showing an
# icon. Excluding the fixed install directory is the least-privilege fix short
# of code-signing. Best-effort: Set-MpPreference is absent on servers without
# Defender and can be blocked by tamper protection; a failure is not fatal.
if (-not $NoDefenderExclusion) {
Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..."
if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) {
try {
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
Write-Host " excluded $InstallDir from Defender scanning."
}
catch {
Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)"
Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy."
}
}
else {
Write-Host " Defender cmdlets not present; skipping exclusion."
}
}
# 3. One-shot smoke test (exact production collection path, then exits).
Write-Host "[3/5] Running one-shot smoke test (--once) ..."
$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow
Write-Host " smoke test exit code: $($proc.ExitCode)"
$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs"
$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
if (Test-Path $logFile) {
Write-Host " --- last log lines ---" -ForegroundColor DarkGray
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
}
if ($proc.ExitCode -ne 0) {
throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service."
}
# 4. Install the Windows service. Any previous instance was already stopped,
# deleted, and its processes killed in step 0, so this is a clean create. Guard
# against the rare case where the SCM has not finished a pending deletion yet.
Write-Host "[4/5] Installing service $ServiceName ..."
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " service still present (pending deletion); removing again ..."
if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force }
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 2
}
$binPath = '"' + $exePath + '"'
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
Start-Service -Name $ServiceName
Write-Host " service started."
# 5. Register the interactive tray app to launch at user logon (per-machine).
Write-Host "[5/5] Registering tray app for logon ..."
if ($trayInstalled -and (Test-Path $trayInstalled)) {
# Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A
# 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores.
Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" `
-Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"')
Write-Host " tray app registered to start at logon (Registry64): $trayInstalled"
# Start it now so the icon appears without waiting for the next logon. The
# Run key only fires at logon, so a fresh install would otherwise show no
# tray until the operator signs out and back in.
#
# The installer runs elevated, so a direct Start-Process would launch the
# tray ELEVATED too. That is wrong for a tray app (it should run at the
# user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an
# unsigned exe triggers a broker prompt that comes back as Win32 1223
# "The operation was canceled by the user" - which is exactly what we hit.
# Launching through explorer.exe hands the exe to the already-running user
# shell, which spawns it de-elevated in the interactive session, sidestepping
# the elevation broker entirely.
$interactiveUser = $env:USERNAME
if ($interactiveUser -and $interactiveUser -ne "SYSTEM") {
try {
# explorer.exe <path> re-parents the launch under the user shell at
# medium integrity; it returns immediately regardless of the tray's
# own lifetime, so there is nothing to wait on here.
Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null
Write-Host " tray app started for the current session (de-elevated via explorer)."
}
catch {
Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon."
}
}
else {
Write-Host " no interactive user session; tray will start at next logon."
}
}
else {
Write-Host " tray skipped (headless install)."
}
Write-Host "`nInstall complete." -ForegroundColor Green
Write-Host "Service '$ServiceName' is running and set to auto-start."
Write-Host "Logs: $logDir"

View File

@@ -0,0 +1,119 @@
<#
.SYNOPSIS
ShopDB agent utility: reports pending Dell Command | Update items.
.DESCRIPTION
Discovered and executed by PowerShellUtilityRunner on the telemetry cadence.
Emits a single JSON object on stdout, which the agent attaches to the report
as `dellupdates`. Never throws and never writes anything but JSON: a failure
here must not break a telemetry run, so every error path still emits a valid
object with a `status` explaining why the list is empty.
THIS SCRIPT DOES NOT SCAN. It only reads the log a previous scan wrote. The
scan itself is fired by the \ShopDB-DellCommandScan scheduled task, which is
what pins dcu-cli.exe's -outputLog to the path below. Keeping the two apart
is deliberate: a telemetry collector that shelled out to dcu-cli would block
the cadence for minutes and hammer Dell's servers every 15 minutes.
OUTPUT CONTRACT (keep in sync with plugins/endpoints/plugin.py):
status unknown | ok | error - "unknown" means no scan has ever run,
which is NOT the same as "ok" with an empty list (known clean).
scannedat ISO-8601 UTC timestamp of the scan log's last write, or null.
count total pending updates found.
updates up to $MaxUpdates entries of { name, severity, version }.
truncated true when count exceeded $MaxUpdates.
Dell's own long description text is deliberately NOT carried: it is
sentence-length marketing prose per update, which would add roughly an order
of magnitude to every report for no operational value. Name plus severity is
what an operator decides on.
#>
$ErrorActionPreference = 'Stop'
# Cap the list rather than the payload. A machine that is years behind can have
# dozens of pending updates, and an unbounded list would bloat every report on
# the cadence. Mirrors the MaxReportedConnections precedent in CommandExecutor:
# a truncated answer is more useful than a rejected payload.
$MaxUpdates = 25
# Must match the -outputLog path set in the \ShopDB-DellCommandScan task
# definition. ShopDB-initiated scans write HERE rather than to Dell's default
# location, which is what keeps them distinguishable from the corporate-managed
# DCU tasks that also run on these machines.
$ScanLog = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\dellcommand\scan.xml'
function Write-Result {
param($Status, $ScannedAt, $Count, $Updates, $Truncated)
# Compress so the payload carries no pretty-printing whitespace. Depth 3
# covers the nested updates array.
[pscustomobject]@{
status = $Status
scannedat = $ScannedAt
count = $Count
updates = @($Updates)
truncated = [bool]$Truncated
} | ConvertTo-Json -Depth 3 -Compress
}
try {
if (-not (Test-Path -LiteralPath $ScanLog)) {
# No scan has run yet. This is the normal state for a freshly adopted
# endpoint, not an error, but it must stay distinct from "scanned and
# found nothing" or the UI would claim a machine is up to date when
# nobody has ever checked.
Write-Result -Status 'unknown' -ScannedAt $null -Count 0 -Updates @() -Truncated $false
return
}
$logFile = Get-Item -LiteralPath $ScanLog
$scannedAt = $logFile.LastWriteTimeUtc.ToString('o')
[xml]$report = Get-Content -LiteralPath $ScanLog -Raw
# dcu-cli's scan report shape has varied across DCU versions, so select the
# update nodes by name anywhere in the tree rather than by a fixed path.
$nodes = @($report.SelectNodes('//update'))
if ($nodes.Count -eq 0) {
$nodes = @($report.SelectNodes('//Update'))
}
$updates = foreach ($node in $nodes) {
# Attribute casing also varies by version; take whichever is present and
# fall back rather than emitting a null-filled row.
$name = $node.name
if (-not $name) { $name = $node.Name }
if (-not $name) { $name = $node.display }
if (-not $name) { continue }
$severity = $node.urgency
if (-not $severity) { $severity = $node.Urgency }
if (-not $severity) { $severity = $node.criticality }
$version = $node.version
if (-not $version) { $version = $node.Version }
[pscustomobject]@{
name = [string]$name
severity = if ($severity) { [string]$severity } else { 'unknown' }
version = if ($version) { [string]$version } else { $null }
}
}
$updates = @($updates)
$count = $updates.Count
$truncated = $count -gt $MaxUpdates
if ($truncated) {
$updates = $updates[0..($MaxUpdates - 1)]
}
Write-Result -Status 'ok' -ScannedAt $scannedAt -Count $count `
-Updates $updates -Truncated $truncated
}
catch {
# Emit a valid object rather than failing: the runner would log a non-JSON
# utility and drop it, which looks identical to "never ran" on the server.
# An explicit error status tells an operator the scan log exists but could
# not be read, which is a different problem from no scan at all.
Write-Result -Status 'error' -ScannedAt $null -Count 0 -Updates @() -Truncated $false
}

View File

@@ -101,6 +101,17 @@
</rewrite>
SHOPDB-CLIENTIP-END -->
<security>
<requestFiltering>
<!-- IIS request filtering caps a request body at 30,000,000 bytes by
default and answers 413 before waitress sees it. The agent package
upload accepts 128 MB (MAX_CONTENT_LENGTH is 256 MB), and the tray
binary alone is ~69 MB, so the IIS cap has to be raised to match or
every agent upload fails on Windows while working under Docker. -->
<requestLimits maxAllowedContentLength="268435456" />
</requestFiltering>
</security>
</system.webServer>
<!--

175
docker-compose.backup.yml Normal file
View File

@@ -0,0 +1,175 @@
# shopdb-flask BACKUP overlay - opt-in, per-site.
#
# WHY a separate overlay: backups are not part of the base stack every site must
# run. A facility turns them on by adding this file to its compose invocation:
#
# docker compose -f docker-compose.yml -f docker-compose.backup.yml \
# --profile backup up -d
#
# That brings up THREE cooperating pieces:
# - backup-store : a resident, isolated restic REST server (the third
# container). Holds every snapshot in its own volume and is
# reachable ONLY over the compose network - never published
# to the host - so the app can touch backups solely through
# the backup/restore utility, not with a shell or db creds.
# - backup-watcher : a resident poller that actuates the "Back up now" sentinel
# the api drops, running the one-shot against backup-store.
# This is what makes the in-app button produce a real backup
# given the api container cannot run docker.
# - backup : the same one-shot as a manual `run --rm backup` for sites
# that prefer a host scheduler (cron / Task Scheduler).
#
# WHAT each run captures, as one recoverable set:
# - db.sql.gz : mysqldump --single-transaction of the whole DB (users,
# auth, ledger, every plugin table). InnoDB-consistent,
# non-blocking.
# - instance/ : restic snapshot of /app/instance (deduped, encrypted),
# the files MySQL rows point at.
# - manifest.json: core+plugin Alembic revisions and image tag at capture
# time - the stamp that makes a restore a CHECKED operation.
#
# Retention (restic-native): --keep-daily 5 --keep-weekly 4 --keep-monthly 6,
# satisfying "any day in the last 5" plus a cheap long-horizon tail.
#
# DESTINATION defaults to the third container via RESTIC_REPOSITORY:
# third container : rest:http://backup-store:8000/ (default, ships with app)
# AWS S3 : s3:s3.amazonaws.com/shopdb-<site>-backups
# Azure Blob : azure:shopdbbackups:<site>
# SMB share : mount the share on the host, point at a local path
# Credentials come from the site's env/secret store, never committed - same
# pattern as the collector key.
# The THIRD container: an isolated backup store the app can only reach through
# the backup/restore utility, never with a shell or the db credentials. It runs
# restic's REST server over the compose network; the only thing that speaks to
# it is the backup watcher/one-shot via RESTIC_REPOSITORY=rest:http://... . Its
# data lives in its own named volume (backupstore_data), so it is container
# agnostic - later a pull/migration utility can point at this same REST endpoint
# from anywhere without touching the app or the database.
services:
backup-store:
image: restic/rest-server:0.13.0
profiles: ["backup"]
restart: unless-stopped
environment:
# Private (no public /create) so repos are provisioned by the backup
# client's `restic init`, and append-only OFF here because the same
# utility both writes and prunes; isolation comes from this being the
# only service on the network with access, not from client trust.
DISABLE_AUTHENTICATION: "true"
OPTIONS: "--no-auth"
volumes:
- backupstore_data:/data
# Deliberately NOT published to the host: reachable only over the compose
# network by service name, so nothing outside the stack can touch backups.
expose:
- "8000"
healthcheck:
test: ["CMD", "sh", "-c", "wget -q -O - http://127.0.0.1:8000/ >/dev/null 2>&1 || true"]
interval: 30s
timeout: 5s
retries: 3
# Resident watcher: actuates the "Back up now" sentinel the api drops. This is
# the piece that makes the UI button run a real backup, since the api itself
# cannot run docker. It shares the base image with the one-shot and calls the
# same backup.sh.
backup-watcher:
image: shopdb-backup:local
build:
context: .
dockerfile: scripts/backup/Dockerfile.backup
profiles: ["backup"]
restart: unless-stopped
depends_on:
db:
condition: service_healthy
backup-store:
condition: service_started
environment:
# DB access - same credentials the api uses, reaching db over the compose
# network by service name.
MYSQL_HOST: db
MYSQL_DATABASE: shopdb_flask
MYSQL_USER: shopdb
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
# Destination is the third container by default: restic's REST backend on
# the isolated backup-store. A site can still override RESTIC_REPOSITORY
# in .env to send offsite instead, but out of the box backups land in the
# dedicated container.
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:-rest:http://backup-store:8000/}
RESTIC_PASSWORD: ${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set}
# Optional cloud creds, passed through when set (empty otherwise).
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AZURE_ACCOUNT_NAME: ${AZURE_ACCOUNT_NAME:-}
AZURE_ACCOUNT_KEY: ${AZURE_ACCOUNT_KEY:-}
# Site tag stamped into every snapshot + manifest.
SHOPDB_SITE: ${SHOPDB_SITE:-shopdb}
SHOPDB_IMAGE_TAG: ${SHOPDB_IMAGE:-source}
SHOPDB_BACKUPS_ROOT: /backups
BACKUP_POLL_SECONDS: ${BACKUP_POLL_SECONDS:-10}
volumes:
# The watcher services both backup (read) and restore (write) requests, so
# the instance tree is mounted read-write here. Restore has to lay the
# files half of a set back down next to the DB rows that reference it; the
# backup half still only ever reads. The manual one-shot below stays :ro.
- instance_data:/app/instance
# Alembic chain, read-only, so the manifest can stamp the schema revision
# the running code is at.
- ./migrations:/app/migrations:ro
- ./plugins:/app/plugins:ro
# Shared backups volume: holds the fast local db.sql.gz + manifest and the
# .backup-requested sentinel the api drops. The api mounts this same volume
# (see docker-compose.yml) so the UI can enumerate sets and request runs.
- backup_data:/backups
- ./scripts/backup:/opt/backup:ro
entrypoint: ["/opt/backup/watch.sh"]
# Manual one-shot, unchanged usage: `docker compose ... run --rm backup`.
# Kept for host-scheduler / cron sites that prefer a scheduled pass over the
# in-app button. Shares everything with the watcher but runs once and exits.
backup:
image: shopdb-backup:local
build:
context: .
dockerfile: scripts/backup/Dockerfile.backup
profiles: ["backup"]
restart: "no"
depends_on:
db:
condition: service_healthy
backup-store:
condition: service_started
environment:
MYSQL_HOST: db
MYSQL_DATABASE: shopdb_flask
MYSQL_USER: shopdb
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY:-rest:http://backup-store:8000/}
RESTIC_PASSWORD: ${RESTIC_PASSWORD:?RESTIC_PASSWORD must be set}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
AZURE_ACCOUNT_NAME: ${AZURE_ACCOUNT_NAME:-}
AZURE_ACCOUNT_KEY: ${AZURE_ACCOUNT_KEY:-}
SHOPDB_SITE: ${SHOPDB_SITE:-shopdb}
SHOPDB_IMAGE_TAG: ${SHOPDB_IMAGE:-source}
SHOPDB_BACKUPS_ROOT: /backups
volumes:
- instance_data:/app/instance:ro
- ./migrations:/app/migrations:ro
- ./plugins:/app/plugins:ro
- backup_data:/backups
- ./scripts/backup:/opt/backup:ro
entrypoint: ["/opt/backup/backup.sh"]
# Give the api read/write on the shared backups volume so the UI can list sets
# and drop the "Back up now" sentinel the watcher actuates. This extends the
# api service defined in docker-compose.yml (merged by compose).
api:
volumes:
- backup_data:/backups
volumes:
backup_data:
backupstore_data:

View File

@@ -194,6 +194,9 @@
},
{
"name": "endpoints-siteagent"
},
{
"name": "core-shopdbbackups"
}
],
"paths": {
@@ -18765,9 +18768,8 @@
"plugin-printers"
],
"summary": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).",
"description": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).\n\n**Auth:** jwt-optional\n\n**Params:** ip or fqdn (one required; value matched against communication ipaddress)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'\n```",
"description": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).\n\n**Auth:** printers.view\n\n**Params:** ip or fqdn (one required; ip matches a communication address, fqdn matches the printer's hostname)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'\n```",
"security": [
{},
{
"bearerAuth": []
}
@@ -18788,6 +18790,12 @@
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
@@ -23332,6 +23340,414 @@
]
}
},
"/api/endpoints/asset/{asset_id}/hardware": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Asset-detail panel: processor, memory and chassis inventory. Agent 1.7.0 and later only; an older agent leaves the...",
"description": "Asset-detail panel: processor, memory and chassis inventory. Agent 1.7.0 and later only; an older agent leaves the columns null and the panel hides itself rather than rendering blanks\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/hardware\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/asset/{asset_id}/osinfo": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Asset-detail panel: operating system, firmware and domain membership as one card (both answer \"what is this box...",
"description": "Asset-detail panel: operating system, firmware and domain membership as one card (both answer \"what is this box running\")\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/osinfo\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/asset/{asset_id}/storage": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Asset-detail panel: volume capacity and physical drive health as two sections. They share no key (volumes vs physical...",
"description": "Asset-detail panel: volume capacity and physical drive health as two sections. They share no key (volumes vs physical devices) and are never reconciled across sections\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/storage\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/asset/{asset_id}/diskhealth": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Physical drive health rows, anything predicting failure sorted first. No longer backs a panel of its own (Storage...",
"description": "Physical drive health rows, anything predicting failure sorted first. No longer backs a panel of its own (Storage renders it as a section) but kept as a stable read for callers outside the panel renderer\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/diskhealth\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/asset/{asset_id}/listeners": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Asset-detail panel: TCP sockets in LISTEN with the owning process, sorted by port. The full connection table is an...",
"description": "Asset-detail panel: TCP sockets in LISTEN with the owning process, sorted by port. The full connection table is an on-demand command, not standard telemetry\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/listeners\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/asset/{asset_id}/mappeddrives": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Asset-detail panel: mapped network drives with a health badge each. Session-scoped, so an empty list when nobody is...",
"description": "Asset-detail panel: mapped network drives with a health badge each. Session-scoped, so an empty list when nobody is signed in is a valid state, not a failure\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/mappeddrives\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/asset/{asset_id}/usercontext": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Asset-detail panel: session-scoped data from the interactive user probe. Mapped drives arrive from two sources that can...",
"description": "Asset-detail panel: session-scoped data from the interactive user probe. Mapped drives arrive from two sources that can disagree and the worse status wins, so a half-broken share is never shown healthy\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/usercontext\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/asset/{asset_id}/iphistory": {
"get": {
"tags": [
"plugin-endpoints"
],
"summary": "Asset-detail panel: every address this asset has held, newest change first. \"Last changed\" is when a field on the row...",
"description": "Asset-detail panel: every address this asset has held, newest change first. \"Last changed\" is when a field on the row moved, NOT when the address was last seen\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/iphistory\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/scripts": {
"get": {
"tags": [
@@ -24394,6 +24810,418 @@
}
}
}
},
"/api/shopdbbackups": {
"get": {
"tags": [
"core-shopdbbackups"
],
"summary": "List every retained backup set, newest first, each with a compatibility flag saying whether it restores onto this build",
"description": "List every retained backup set, newest first, each with a compatibility flag saying whether it restores onto this build\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups\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": [
"core-shopdbbackups"
],
"summary": "Kick off an on-demand backup run. Requires the backup overlay: without a configured backups root there is nothing...",
"description": "Kick off an on-demand backup run. Requires the backup overlay: without a configured backups root there is nothing behind it\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups\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": "none"
}
}
}
}
}
},
"/api/shopdbbackups/{snapshotid}": {
"get": {
"tags": [
"core-shopdbbackups"
],
"summary": "Return one set's full manifest plus a resolved compatibility verdict",
"description": "Return one set's full manifest plus a resolved compatibility verdict\n\n**Auth:** admin\n\n**Params:** path: snapshotid\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/2026-09-06T120000Z\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": "snapshotid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/shopdbbackups/{snapshotid}/restore": {
"post": {
"tags": [
"core-shopdbbackups"
],
"summary": "Queue a restore of a set, gated on schema compatibility. DESTRUCTIVE: the load drops and replaces every table",
"description": "Queue a restore of a set, gated on schema compatibility. DESTRUCTIVE: the load drops and replaces every table\n\n**Auth:** admin\n\n**Params:** path: snapshotid\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/2026-09-06T120000Z/restore\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": "snapshotid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: snapshotid"
}
}
}
}
}
},
"/api/shopdbbackups/finalize-restore": {
"post": {
"tags": [
"core-shopdbbackups"
],
"summary": "Queue the post-restore schema upgrade for the in-container finalizer, and retry it if a previous attempt failed. Does...",
"description": "Queue the post-restore schema upgrade for the in-container finalizer, and retry it if a previous attempt failed. Does NOT run the upgrade in the request: that watcher runs it out of band, so a restore completes whether or not the browser that started it is still open, and Alembic cannot be killed by the worker timeout mid-chain\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/finalize-restore\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": "none"
}
}
}
}
}
},
"/api/shopdbbackups/status": {
"get": {
"tags": [
"core-shopdbbackups"
],
"summary": "Live status of the most recent backup and restore runs, or idle when neither is moving",
"description": "Live status of the most recent backup and restore runs, or idle when neither is moving\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/status\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/shopdbbackups/history": {
"get": {
"tags": [
"core-shopdbbackups"
],
"summary": "Recorded backup and restore run history, newest first",
"description": "Recorded backup and restore run history, newest first\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/history\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/shopdbbackups/settings": {
"get": {
"tags": [
"core-shopdbbackups"
],
"summary": "Read the backup settings, with secret values masked",
"description": "Read the backup settings, with secret values masked\n\n**Auth:** admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/shopdbbackups/settings\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."
}
}
},
"put": {
"tags": [
"core-shopdbbackups"
],
"summary": "Update the backup settings. A masked secret submitted unchanged leaves the stored value alone",
"description": "Update the backup settings. A masked secret submitted unchanged leaves the stored value alone\n\n**Auth:** admin\n\n**Params:** body: backup settings keys\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"backup_schedule\":\"0 2 * * *\"}' http://localhost:5001/api/shopdbbackups/settings\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: backup settings keys"
}
}
}
}
}
}
}
}

View File

@@ -3,7 +3,7 @@
a panel as JSON (title, endpoint, render mode, field map); this renders it
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
<template v-for="panel in groupedPanels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': panel.render === 'table' }">
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': spansFullWidth(panel) }">
<!-- collapsible panels get a clickable header with a caret and, when the
panel declares a status rollup, a green/yellow/red dot for the worst
row (a stopped service or a warning/critical event). --><h3
@@ -52,7 +52,20 @@
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
<div class="pap-item-top">
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
<span v-if="mapBadge(panel, item)" class="status-badge"
<!-- On a row carrying a body the severity badge IS the expander:
it already says what the row is, and a second control beside
it only repeated that. Rows with no body keep the plain
non-interactive badge. -->
<button
v-if="mapBadge(panel, item) && mapBody(panel, item)"
type="button"
class="status-badge pap-badge-toggle"
:style="colorStyle(mapBadge(panel, item).color)"
:aria-expanded="isBodyOpen(panel, index) ? 'true' : 'false'"
:title="isBodyOpen(panel, index) ? 'Hide details' : 'Show details'"
@click="toggleBody(panel, index)"
>{{ mapBadge(panel, item).label }}</button>
<span v-else-if="mapBadge(panel, item)" class="status-badge"
:style="colorStyle(mapBadge(panel, item).color)">
{{ mapBadge(panel, item).label }}
</span>
@@ -68,6 +81,9 @@
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
:class="{ mono: m.mono }">{{ m.text }}</span>
</div>
<div v-if="isBodyOpen(panel, index) && mapBody(panel, item)" class="pap-body">
{{ mapBody(panel, item) }}
</div>
</div>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.label || 'Manage' }}
@@ -104,7 +120,42 @@
@click="setTab(panel, si)"
>{{ section.label }}</button>
</div>
<div class="pap-kv">
<!-- A section carrying columns renders as a table; otherwise it is the
original label/value grid. Both shapes coexist in one panel. -->
<div
v-if="((tabSections(panel)[activeTab(panel)] || {}).columns || []).length"
class="pap-table-wrap"
>
<table
v-if="((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
class="pap-table"
>
<thead>
<tr>
<th
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
:key="col.key"
>{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, ri) in (tabSections(panel)[activeTab(panel)] || {}).rows || []"
:key="ri"
>
<td
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
:key="col.key"
>{{ cell(row, col) }}</td>
</tr>
</tbody>
</table>
<div
v-if="!((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
class="pap-empty"
>{{ (tabSections(panel)[activeTab(panel)] || {}).empty || 'Nothing reported.' }}</div>
</div>
<div v-else class="pap-kv">
<div
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
:key="fi"
@@ -126,7 +177,10 @@
<!-- table: columns declared by the panel (or inferred from row keys) -->
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
<table class="pap-table">
<!-- Headers are suppressed when there are no rows. A bare header row
over an empty body reads as a broken card; the declared empty
message says which of "nothing here" or "nothing reported" it is. -->
<table v-if="rows(panel).length" class="pap-table">
<thead>
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
</thead>
@@ -136,6 +190,9 @@
</tr>
</tbody>
</table>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
</div>
</div>
<!-- badge: a row of colored badges -->
@@ -240,9 +297,9 @@ 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,
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction, facets, facetedRows, formatValue,
rowAction, facets, facetedRows, formatValue, spansFullWidth,
} from './pluginAssetPanels'
// Operator report screenshots are served by a JWT-gated route, so a bare
@@ -365,12 +422,39 @@ function visibleRows(panel) {
return facetedRows(panel, facetSelection.value[panel.id])
}
// Which row bodies are expanded, keyed by panel id and row index. Closed on
// every panel refresh: the row at index 3 after a refresh is not necessarily
// the row that was opened, so keeping it open would expand an unrelated event.
const bodyOpen = ref({})
function bodyKey(panel, index) {
return `${panel.id}:${index}`
}
function isBodyOpen(panel, index) {
return !!bodyOpen.value[bodyKey(panel, index)]
}
function toggleBody(panel, index) {
const key = bodyKey(panel, index)
bodyOpen.value = { ...bodyOpen.value, [key]: !bodyOpen.value[key] }
}
const props = defineProps({
assetid: { type: [Number, String], default: null },
// Which detail-page tab to render. Null renders every panel, which is what
// the detail pages that have not adopted tabs still do - so this stays
// additive and no existing page changes behaviour.
group: { type: String, default: null },
// Render exactly these panel ids, ignoring group. For lifting one card out
// of its tab and showing it somewhere else - Network Interfaces is pinned to
// the top of Details while still belonging to monitors. Kept as a panel-id
// filter rather than a new group because a group would need the four-file
// vocabulary change AND would add a tab nobody asked for.
only: { type: Array, default: null },
// The other half of `only`: the group instance must drop what was lifted out
// of it, or the card renders twice on two different tabs.
exclude: { type: Array, default: null },
})
const panels = ref([])
@@ -386,14 +470,19 @@ const panels = ref([])
// 'actions', this build had no such tab, and the card was simply gone with
// nothing logged. The same applies to the 'metrics' to 'monitors' rename.
// Fall back the same way the server does rather than drop it.
const RENDERED_GROUPS = ['details', 'monitors', 'software', 'activity']
const RENDERED_GROUPS = ['details', 'monitors', 'serviceprocess', 'reports', 'software', 'activity']
function groupOf(panel) {
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
}
const groupedPanels = computed(() =>
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
)
const groupedPanels = computed(() => {
if (props.only) return panels.value.filter((p) => props.only.includes(p.id))
let list = props.group
? panels.value.filter((p) => groupOf(p) === props.group)
: panels.value
if (props.exclude) list = list.filter((p) => !props.exclude.includes(p.id))
return list
})
// Which groups actually have a panel with data, so a parent can hide a tab
// rather than offering an empty one.
@@ -525,6 +614,7 @@ async function load() {
panels.value = []
revokeReportImageUrls()
closeReport()
bodyOpen.value = {}
if (!props.assetid) return
let declared
try {
@@ -562,6 +652,31 @@ defineExpose({ refresh: load, populatedGroups })
.pap-title { font-weight: 600; color: var(--text); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
/* The badge doubles as the expander, so it needs to look pressable without
becoming a second style of badge: same pill, just a pointer and a hover
lift. margin-left:auto pins it right even on a row that also has an
action button, where space-between alone would centre it. */
.pap-badge-toggle {
margin-left: auto;
border: none;
cursor: pointer;
font-family: inherit;
line-height: inherit;
}
.pap-badge-toggle:hover { filter: brightness(1.1); }
.pap-badge-toggle:focus-visible { outline: 2px solid var(--primary, #4181ff); outline-offset: 2px; }
/* pre-wrap, not normal: the message keeps the line breaks the event was
written with, which is what makes a multi-line fault readable. */
.pap-body {
margin-top: 0.5rem;
padding: 0.5rem 0.6rem;
background: var(--surface-2, rgba(127, 127, 127, 0.08));
border-radius: 4px;
font-size: 0.82rem;
color: var(--text-light);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-facets {

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
// A `table` panel with no rows used to render a bare header row over an empty
// body, which reads as a broken card rather than as an answer. `list` had
// honoured panel.empty for a long time; `table` had not, for no reason anybody
// wrote down. Found on a live page where Network Interfaces and Listening
// Ports both showed column headers and nothing else.
//
// Asserted against the SOURCE because the condition lives in the template, not
// in an exported helper - the same reason panelGroups.spec.js reads source.
const source = readFileSync(
resolve(__dirname, 'PluginAssetPanels.vue'), 'utf8',
)
function tableBlock() {
const start = source.indexOf("panel.render === 'table'")
expect(start).toBeGreaterThan(-1)
// Up to the next render branch, so we only look at the table path.
const end = source.indexOf("panel.render === 'badge'", start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('an empty table panel', () => {
it('does not render the table element when there are no rows', () => {
expect(tableBlock()).toMatch(/<table[^>]*v-if="rows\(panel\)\.length"/)
})
it('falls back to the panel-declared empty message', () => {
expect(tableBlock()).toContain('panel.empty')
})
it('keeps a default message for a panel that declares none', () => {
// An empty card with no words in it is indistinguishable from a card that
// failed to load.
expect(tableBlock()).toMatch(/panel\.empty \|\| '[^']+'/)
})
})
describe('an empty table SECTION inside a tabs panel', () => {
it('also suppresses its header row', () => {
// The section path was added with the Storage merge and had the same bug.
const start = source.indexOf("panel.render === 'tabs'")
const end = source.indexOf("panel.render === 'keyvalue'", start)
const block = source.slice(start, end)
expect(block).toMatch(/<table\s+v-if="\(\(tabSections\(panel\)/)
})
})

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
// The group vocabulary is split across four files and there is no runtime that
// checks they agree. When they disagree the failure is SILENT: a panel in a
// group nothing renders is fetched, held, and shown on no tab, with nothing
// logged. That has eaten cards twice - tracked services moving out of
// 'actions', and the 'metrics' to 'monitors' rename.
//
// So this reads the sources and asserts the vocabularies match. It is a source
// check rather than a behaviour check because RENDERED_GROUPS is a module-local
// constant in the .vue file with no export to assert against.
function read(relative) {
// .href, not the URL object: the jsdom test environment supplies its own
// URL global, and node's fileURLToPath rejects a foreign instance with
// "must be ... an instance of URL. Received an instance of URL".
return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8')
}
function renderedGroups() {
const source = read('./PluginAssetPanels.vue')
const match = source.match(/const RENDERED_GROUPS = \[([^\]]*)\]/)
if (!match) throw new Error('RENDERED_GROUPS not found in PluginAssetPanels.vue')
return match[1].split(',')
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
.filter(Boolean)
}
function pcDetailSource() {
return read('../../../plugins/computers/frontend/views/PCDetail.vue')
}
// The tabs that actually HOST panels - those where PCDetail mounts a
// PluginAssetPanels for the group. A tab is only bound by the vocabulary if a
// panel can land in it. Corporate Tools is the first tab that hosts none: it is
// permission-gated and renders a fixed action list, so RENDERED_GROUPS has
// nothing to say about it and requiring it there would make 'corptools' a legal
// panel group with no panel host - fetched, held, rendered nowhere, which is
// the very failure this file exists to catch.
function panelHostedGroups(source) {
return [...source.matchAll(/group="([a-z]+)"/g)].map((match) => match[1])
}
describe('panel group vocabulary', () => {
it('renders a group for every panel-backed tab PCDetail offers', () => {
const groups = renderedGroups()
const source = pcDetailSource()
const hosted = panelHostedGroups(source)
const tabKeys = [...source.matchAll(/tabs\.push\(\{\s*key:\s*'([a-z]+)'/g)]
.map((match) => match[1])
.filter((key) => hosted.includes(key))
expect(tabKeys.length).toBeGreaterThan(0)
for (const key of tabKeys) {
expect(groups).toContain(key)
}
})
it('never hosts panels in a group this build does not render', () => {
// The mirror of that exemption, so opting a tab out of panels cannot become
// a loophole: the moment a tab mounts a PluginAssetPanels, its group MUST
// be rendered, or the panels are fetched and dropped with nothing logged.
const groups = renderedGroups()
const hosted = panelHostedGroups(pcDetailSource())
expect(hosted.length).toBeGreaterThan(0)
for (const group of hosted) {
expect(groups).toContain(group)
}
})
it('gives every rendered group somewhere to actually show', () => {
// A group in RENDERED_GROUPS with no v-show block is the mirror failure:
// the panel is kept rather than dropped, but still appears nowhere.
const source = pcDetailSource()
for (const group of renderedGroups()) {
expect(source).toContain(`activegroup === '${group}'`)
}
})
it('keeps the service/process split wired end to end', () => {
// Pins the specific split rather than only the general rule, so removing
// the tab is a deliberate edit to this test and not a silent regression.
const source = pcDetailSource()
expect(renderedGroups()).toContain('serviceprocess')
expect(source).toContain("key: 'serviceprocess'")
expect(source).toContain("group=\"serviceprocess\"")
})
it('still renders the system monitors group after the rename', () => {
// The rename is label-only; the group key must not drift with it.
expect(renderedGroups()).toContain('monitors')
expect(pcDetailSource()).toContain("label: 'System Monitors'")
})
it('agrees with the server vocabulary that resolves groups first', () => {
// The fourth file this comment block claims to cover, and the one that was
// NOT actually being checked. asset_panels() rewrites any group missing
// from ASSET_PANEL_GROUPS to 'details' before the client sees it, so the
// frontend can be perfectly consistent with itself and still show nothing:
// that is exactly how the Service/Process tab went missing after Phase 3.
const source = read('../../../shopdb/core/api/pluginui.py')
const match = source.match(/ASSET_PANEL_GROUPS = \(([\s\S]*?)\)/)
if (!match) throw new Error('ASSET_PANEL_GROUPS not found in pluginui.py')
const serverGroups = match[1].split(',')
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
.filter(Boolean)
for (const group of renderedGroups()) {
expect(serverGroups).toContain(group)
}
})
it('keeps operator reports in a tab of its own', () => {
const source = pcDetailSource()
expect(renderedGroups()).toContain('reports')
expect(source).toContain("key: 'reports'")
expect(source).toContain('group="reports"')
})
})

View File

@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
// Network Interfaces is DECLARED in the monitors group but RENDERED at the top
// of the Details tab. That is done with a panel-id filter (`only`) rather than
// a new group, because a new group would need the four-file vocabulary change
// and would add a tab nobody asked for.
//
// The pair is the fragile part: `only` lifts the panel out, and `exclude` must
// drop it from the group instance it was lifted from. Miss the second half and
// the same card renders on two tabs, which looks like duplicated data rather
// than a layout bug.
function read(relative) {
// .href, not the URL object: the jsdom test environment supplies its own
// URL global, and node's fileURLToPath rejects a foreign instance with
// "must be ... an instance of URL. Received an instance of URL".
return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8')
}
const panelsSource = () => read('./PluginAssetPanels.vue')
const pcDetailSource = () => read('../../../plugins/computers/frontend/views/PCDetail.vue')
describe('lifting one panel out of its group', () => {
it('accepts an only prop', () => {
expect(panelsSource()).toMatch(/only:\s*\{\s*type:\s*Array/)
})
it('accepts an exclude prop', () => {
expect(panelsSource()).toMatch(/exclude:\s*\{\s*type:\s*Array/)
})
it('lets only bypass the group filter entirely', () => {
// Details renders a panel belonging to monitors, so `only` must NOT be
// and-ed with the group or it would match nothing.
const source = panelsSource()
const start = source.indexOf('const groupedPanels')
const block = source.slice(start, start + 400)
expect(block).toMatch(/if\s*\(props\.only\)/)
})
})
describe('the pinned Network Interfaces card', () => {
it('renders at the top of Details by panel id', () => {
expect(pcDetailSource()).toContain("only=\"['endpoints-interfaces']\"")
})
it('is excluded from System Monitors so it cannot render twice', () => {
// Scoped to the monitors instance and matched on membership, not on the
// exact array text: more than one panel is lifted now.
const source = pcDetailSource()
const start = source.indexOf('group="monitors"')
const block = source.slice(start, start + 200)
expect(block).toContain('endpoints-interfaces')
})
it('is still declared in monitors, not moved to a new group', () => {
// One declaration, no vocabulary change. If somebody "fixes" this by
// adding a reports-style group, the four-file dance applies again.
// Bounded by the NEXT panel's id: the columns list contains '},' so
// slicing to that would cut the block off before the group key.
const plugin = read('../../../plugins/endpoints/plugin.py')
const start = plugin.indexOf("'id': 'endpoints-interfaces'")
const next = plugin.indexOf("'id': '", start + 10)
const block = plugin.slice(start, next === -1 ? undefined : next)
expect(block).toContain("'group': 'monitors'")
})
})
describe('the header cloud identity block', () => {
it('shows the block on template presence, not on this PC having an id', () => {
// A site with no Intune/Entra at all must not see a permanent request for
// an id it will never have.
const source = pcDetailSource()
const start = source.indexOf('const hasCloudIdentity')
const block = source.slice(start, start + 260)
expect(block).toContain('entraUrlTemplate.value')
expect(block).toContain('intuneUrlTemplate.value')
})
it('waits for the portal templates before rendering', () => {
// The templates come from an async settings fetch. Rendering the ids
// first and adding the buttons a moment later looked like the buttons
// were missing until the page was manually refreshed.
const source = pcDetailSource()
const start = source.indexOf('const hasCloudIdentity')
const block = source.slice(start, start + 260)
expect(block).toContain('portalTemplatesLoaded.value')
})
it('marks the templates settled even when the fetch fails', () => {
// Otherwise a down settings backend hides the block permanently.
// Bounded by the function's own closing brace rather than a character
// count: a fixed window silently excluded the finally block as soon as
// the retry logic grew, which reported as "the finally is missing" when
// the real code was correct. Slice to the next top-level declaration.
const source = pcDetailSource()
const start = source.indexOf('async function loadPortalTemplates')
const next = source.indexOf('\nonUnmounted(', start)
const block = source.slice(start, next === -1 ? undefined : next)
expect(block).toContain('finally')
expect(block).toContain('portalTemplatesLoaded.value = true')
})
it('names which id is missing rather than rendering an empty block', () => {
const source = pcDetailSource()
expect(source).toContain('Please provide DeviceID')
expect(source).toContain('Please provide ObjectID')
})
it('no longer carries the old Entra Device Info card', () => {
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Entra Device Info</h3>')
})
it('no longer carries the hand-written Network card', () => {
// Its two halves are the IP History and IP Configuration panels now.
expect(pcDetailSource()).not.toContain('<h3 class="section-title">Network</h3>')
})
it('keeps the full ids on screen so they can be copied', () => {
// The move to the header was a MOVE, not a redesign. Rendering only an
// "Open device" link loses the id itself, which gets pasted into tickets
// and Graph queries. Both ids must still be printed in full.
const source = pcDetailSource()
expect(source).toContain('computer.computer.azuredeviceid }}')
expect(source).toContain('computer.computer.azureobjectid }}')
})
it('offers a copy button for each id', () => {
// Clicking to select still leaves the operator to press ctrl+c; these ids
// get pasted into tickets and Graph queries constantly.
const source = pcDetailSource()
expect(source).toContain("copyId(computer.computer.azuredeviceid, 'Device ID')")
expect(source).toContain("copyId(computer.computer.azureobjectid, 'Object ID')")
})
it('reports a failed copy instead of a false success', () => {
// clipboard.writeText is undefined over plain HTTP and rejects when the
// tab is unfocused. Claiming success there would be a lie.
const source = pcDetailSource()
const start = source.indexOf('async function copyId')
const block = source.slice(start, start + 420)
expect(block).toContain('catch')
expect(block).toContain('toast.error')
})
it('keeps the id selectable as a clipboard fallback', () => {
const source = pcDetailSource()
const start = source.indexOf('.hero-portal-id {')
const block = source.slice(start, start + 260)
expect(block).toContain('user-select: all')
})
it('does not truncate an id with an ellipsis', () => {
// A half-copied GUID is worse than no GUID: it looks complete.
const source = pcDetailSource()
const start = source.indexOf('.hero-portal-id {')
const block = source.slice(start, start + 260)
expect(block).not.toContain('text-overflow: ellipsis')
expect(block).toContain('user-select: all')
})
it('keeps the original Intune and Entra buttons', () => {
// These carry a race-condition fix. Relabelling them to "Open device" /
// "Open object" threw that away once already.
const source = pcDetailSource()
expect(source).toContain('>Intune</a>')
expect(source).toContain('>Entra</a>')
})
it('lays the portal block out horizontally, not as a narrow rail', () => {
// A fixed-width right rail wrapped 36-character GUIDs into a tall ragged
// stack that grew past the identity block beside it.
const source = pcDetailSource()
const start = source.indexOf('.hero-portals {')
const block = source.slice(start, start + 260)
expect(block).toContain('flex-wrap: wrap')
expect(block).not.toContain('width: 300px')
})
it('does not restyle the header classes other assets share', () => {
// Machines, Printers and friends share .hero-details/.hero-detail-* from
// global style.css. A PC-only header must not redefine them.
const source = pcDetailSource()
const style = source.slice(source.indexOf('<style scoped>'))
expect(style).not.toContain('.hero-details {')
expect(style).not.toContain('.hero-detail-label {')
expect(style).not.toContain('.hero-detail-value {')
})
})
describe('the lifted Mapped drives card', () => {
it('renders on Details', () => {
expect(pcDetailSource()).toContain("['endpoints-mappeddrives']")
})
it('is excluded from System Monitors so it renders once', () => {
const source = pcDetailSource()
const start = source.indexOf('group="monitors"')
const block = source.slice(start, start + 200)
expect(block).toContain('endpoints-mappeddrives')
})
it('is still declared in monitors, not moved to a new group', () => {
const plugin = read('../../../plugins/endpoints/plugin.py')
const start = plugin.indexOf("'id': 'endpoints-mappeddrives'")
const block = plugin.slice(start, start + 1200)
expect(block).toContain("'group': 'monitors'")
})
})

View File

@@ -19,6 +19,15 @@ export function formatValue(value, spec = {}) {
const raw = String(value)
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
}
// Timestamps arrive as UTC ISO ("2026-09-04T16:13:29Z") because the wire
// format is UTC by contract. Render in the viewer's local zone: an operator
// comparing "last reported" against the clock on the wall cannot do the
// offset in their head, and the Z suffix invites reading it as local.
if (spec.format === 'datetime') {
const parsed = new Date(String(value))
if (isNaN(parsed)) return value
return parsed.toLocaleString()
}
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return value
}
@@ -138,6 +147,14 @@ export function mapMeta(panel, item) {
.filter(Boolean)
}
export function mapBody(panel, item) {
const key = panel.map && panel.map.body
if (!key) return null
const value = item[key]
if (value === null || value === undefined || value === '') return null
return String(value)
}
export function manageLink(panel, assetid) {
return panel.manage ? subst(panel.manage.to, assetid) : null
}
@@ -210,17 +227,38 @@ export function rollupStatus(panel) {
// tabs: [{ label, fields: [{label, value, mono}] }]. One section is shown at a
// time, so a panel with 30 values stays the height of its largest section
// instead of dragging one multicol column far past the other.
//
// A section may instead carry { columns, rows, empty } and render as a table.
// This exists so two tables that belong side by side for the reader but share
// NO key can sit in one card - Storage is per volume, Disk health is per
// physical device. The sections are independent tables by design: nothing here
// reconciles rows across them, unlike the mapped-drive merge where two sources
// described the same drive.
export function tabSections(panel) {
const data = panel._data
const sections = (data && Array.isArray(data.sections)) ? data.sections : []
return sections
.map((s) => ({
label: s.label,
fields: (s.fields || []).map((f) => ({
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
})),
}))
.filter((s) => s.fields.length > 0)
.map((s) => {
if (Array.isArray(s.columns) && s.columns.length) {
return {
label: s.label,
columns: s.columns.map((c) => ({ key: c.key, label: c.label || humanize(c.key) })),
rows: Array.isArray(s.rows) ? s.rows : [],
empty: s.empty || '',
fields: [],
}
}
return {
label: s.label,
fields: (s.fields || []).map((f) => ({
label: f.label, value: formatValue(f.value, f), mono: !!f.mono,
})),
}
})
// A table section declares its columns up front, so it survives having no
// rows and shows its own empty message. Dropping it would take the tab with
// it and read as "this machine has no disks" rather than "nothing reported".
.filter((s) => s.fields.length > 0 || (s.columns && s.columns.length > 0))
}
// Inline row action for a list panel. A monitored services/processes row may
@@ -247,6 +285,16 @@ export function rowAction(panel, item) {
}
}
// A table needs the full card width. `table` panels get it via panel.render,
// but a `tabs` panel only needs it when one of its sections is a table, and
// that is not knowable from the declaration - it depends on fetched data.
export function spansFullWidth(panel) {
if (!panel) return false
if (panel.render === 'table') return true
if (panel.render !== 'tabs') return false
return tabSections(panel).some((s) => (s.columns || []).length > 0)
}
// A panel declaring `empty` is asking to stay visible with nothing in it and
// say so - "No installed software reported." is an answer, where an absent
// card is a question. `list` honoured that and `table` did not, for no reason

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, manageLink,
toApiPath, subst, rows, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
keyvalueFields, tableColumns, badges, panelVisible, tabSections, rollupStatus,
rowAction, facets, facetedRows,
} from './pluginAssetPanels'
@@ -64,7 +64,6 @@ describe('list render mapping (warranty)', () => {
it('builds the manage link with the assetid', () => {
expect(manageLink(warrantyPanel, 7)).toBe('/warranties?addfor=7')
})
it('is visible when it has rows or an empty message', () => {
expect(panelVisible(warrantyPanel)).toBe(true)
expect(panelVisible({ ...warrantyPanel, _data: [] })).toBe(true) // has empty
@@ -72,6 +71,24 @@ describe('list render mapping (warranty)', () => {
})
})
describe('expandable row body (activity feed)', () => {
const activityPanel = {
id: 'endpoints-activity', render: 'list',
map: { title: 'title', body: 'body' },
}
it('returns the body only for rows that carry one', () => {
expect(mapBody(activityPanel, { title: 'Crash', body: 'Faulting module' }))
.toBe('Faulting module')
expect(mapBody(activityPanel, { title: 'Crash' })).toBeNull()
expect(mapBody(activityPanel, { title: 'Crash', body: '' })).toBeNull()
})
it('returns null when the panel declares no body key', () => {
expect(mapBody(warrantyPanel, { body: 'ignored' })).toBeNull()
})
})
describe('list row filtering (tracked services vs processes)', () => {
const feedPanel = {
id: 'endpoints-tracked-services', render: 'list',

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest'
import { tabSections, panelVisible, spansFullWidth } from './pluginAssetPanels'
// The tabs render type carried label/value sections only. It now also carries
// table sections, so that two tables which belong together for the reader but
// share no key can sit in one card. This is the shared renderer every plugin
// uses, so the label/value path is pinned here alongside the new one.
function tabsPanel(sections) {
return { id: 'p', render: 'tabs', _data: { sections } }
}
const FIELD_SECTION = { label: 'Fields', fields: [{ label: 'A', value: '1' }] }
const TABLE_SECTION = {
label: 'Volumes',
columns: [{ key: 'drive', label: 'Drive' }],
rows: [{ drive: 'C:' }],
empty: 'No disks reported.',
}
describe('tabSections with table sections', () => {
it('keeps rendering label/value sections as before', () => {
const sections = tabSections(tabsPanel([FIELD_SECTION]))
expect(sections).toHaveLength(1)
expect(sections[0].fields).toEqual([{ label: 'A', value: '1', mono: false }])
expect(sections[0].columns).toBeUndefined()
})
it('carries columns and rows through for a table section', () => {
const sections = tabSections(tabsPanel([TABLE_SECTION]))
expect(sections[0].columns).toEqual([{ key: 'drive', label: 'Drive' }])
expect(sections[0].rows).toEqual([{ drive: 'C:' }])
})
it('lets one panel mix a table section and a field section', () => {
const sections = tabSections(tabsPanel([TABLE_SECTION, FIELD_SECTION]))
expect(sections.map((s) => s.label)).toEqual(['Volumes', 'Fields'])
})
// The reason the empty-section filter needed a table branch at all. A card
// whose second section is dropped loses that tab, and "no disks" is a very
// different claim from "the collector reported nothing".
it('keeps an empty table section so its tab and message survive', () => {
const sections = tabSections(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))
expect(sections).toHaveLength(1)
expect(sections[0].empty).toBe('No disks reported.')
})
it('still drops a label/value section with no fields', () => {
const sections = tabSections(tabsPanel([{ label: 'Empty', fields: [] }]))
expect(sections).toHaveLength(0)
})
it('derives a column label from the key when none is given', () => {
const sections = tabSections(tabsPanel([
{ label: 'V', columns: [{ key: 'drive' }], rows: [] },
]))
expect(sections[0].columns[0].label).toBe('Drive')
})
it('treats a missing rows array as no rows rather than throwing', () => {
const sections = tabSections(tabsPanel([{ label: 'V', columns: [{ key: 'drive' }] }]))
expect(sections[0].rows).toEqual([])
})
})
describe('panelVisible for a tabs panel of tables', () => {
it('shows a panel whose only section is an empty table', () => {
expect(panelVisible(tabsPanel([{ ...TABLE_SECTION, rows: [] }]))).toBe(true)
})
it('still hides a tabs panel with no sections at all', () => {
expect(panelVisible(tabsPanel([]))).toBe(false)
})
})
describe('spansFullWidth', () => {
// A six-column table squeezed into one half of a two-column grid is the
// failure this prevents.
it('spans a tabs panel that contains a table section', () => {
expect(spansFullWidth(tabsPanel([TABLE_SECTION]))).toBe(true)
})
it('does not span a tabs panel of label/value sections', () => {
expect(spansFullWidth(tabsPanel([FIELD_SECTION]))).toBe(false)
})
it('still spans a plain table panel', () => {
expect(spansFullWidth({ render: 'table' })).toBe(true)
})
it('does not span a keyvalue panel', () => {
expect(spansFullWidth({ render: 'keyvalue' })).toBe(false)
})
it('tolerates a null panel', () => {
expect(spansFullWidth(null)).toBe(false)
})
})

View File

@@ -221,5 +221,11 @@ export default [
name: 'users',
component: () => import('../../views/settings/UsersList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/shopdbbackups',
name: 'shopdbbackups',
component: () => import('../../views/settings/ShopdbBackupsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -5,19 +5,46 @@ import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath'
let settingsCache = null
let inFlight = null
let retryAfter = 0
// Whether the last resolved load actually produced settings. A caller cannot
// tell that from the value alone: a failed load and a site that simply has not
// configured a key both read as empty, and those want opposite handling -
// hide the feature vs try again.
export function settingsLoadFailed() {
return settingsCache === null
}
// A failed load is cached briefly, not for the page session. A transient blip
// used to blank every settings-driven link until the operator reloaded, and
// they have no reason to know that would fix it. A short window still bounds a
// hard-down backend to two requests a minute per client.
const FAILURE_RETRY_MS = 30000
// The in-flight promise is cached alongside the result. Callers that arrive
// during the first load share one request instead of each firing their own;
// applyBranding() alone awaits five settings in sequence at bootstrap.
async function loadSettings() {
if (settingsCache) return settingsCache
try {
const response = await settingsApi.list()
const items = response.data?.data || response.data || []
settingsCache = {}
for (const s of items) settingsCache[s.key] = s.value
} catch (err) {
console.error('Error loading site settings:', err)
settingsCache = {}
}
return settingsCache
if (inFlight) return inFlight
if (retryAfter && Date.now() < retryAfter) return {}
inFlight = settingsApi.list()
.then((response) => {
const items = response.data?.data || response.data || []
const loaded = {}
for (const s of items) loaded[s.key] = s.value
settingsCache = loaded
retryAfter = 0
return loaded
})
.catch((error) => {
console.error('Error loading site settings:', error)
retryAfter = Date.now() + FAILURE_RETRY_MS
return {}
})
.finally(() => { inFlight = null })
return inFlight
}
export async function getSetting(key, fallback = '') {
@@ -132,10 +159,14 @@ export async function getServicenowUrls() {
// setting instead of waiting for a release.
//
// Empty is the default and means "no link" - the id still renders as text.
//
// `loaded` distinguishes a real answer from a failed fetch. Both return empty
// strings, but only one of them means "this site has no portal links".
export async function getEntraDeviceUrls() {
const entraUrl = await getSetting('entra_device_url', '')
const intuneUrl = await getSetting('intune_device_url', '')
return { entraUrl, intuneUrl }
const lapsUrl = await getSetting('laps_device_url', '')
return { entraUrl, intuneUrl, lapsUrl, loaded: !settingsLoadFailed() }
}
// Printer hostname template. {ip} is replaced with the dash-separated IP.

View File

@@ -0,0 +1,119 @@
// Site settings load once, and a blip does not blank the page for good.
//
// Two bugs motivated these. The cache used to hold only the RESOLVED object, so
// every caller arriving before the first response fired its own request -
// applyBranding() alone awaits five settings in sequence at bootstrap. And a
// failed load cached {} for the page session, so one bad GET silently blanked
// every settings-driven link (the Intune/Entra buttons) until a manual reload.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
const list = vi.fn()
vi.mock('@/api', () => ({ settingsApi: { list: () => list() } }))
// The module caches in module scope, so each test needs a fresh copy.
async function freshModule() {
vi.resetModules()
return import('./siteSettings')
}
function respond(items) {
return { data: { data: items } }
}
beforeEach(() => {
list.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('loading', () => {
it('fetches once for concurrent callers rather than once per caller', async () => {
let release
list.mockReturnValue(new Promise((resolve) => { release = resolve }))
const settings = await freshModule()
const waiting = Promise.all([
settings.getSetting('entra_device_url'),
settings.getSetting('intune_device_url'),
settings.getFacilityName(),
])
release(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
const [entra, intune, facility] = await waiting
expect(list).toHaveBeenCalledTimes(1)
expect(entra).toBe('https://entra/{id}')
expect(intune).toBe('')
expect(facility).toBe('ShopDB')
})
it('serves later callers from cache without refetching', async () => {
list.mockResolvedValue(respond([{ key: 'facility_name', value: 'Wilmington' }]))
const settings = await freshModule()
expect(await settings.getFacilityName()).toBe('Wilmington')
expect(await settings.getFacilityName()).toBe('Wilmington')
expect(list).toHaveBeenCalledTimes(1)
})
it('gives both portal templates to a single caller from one fetch', async () => {
list.mockResolvedValue(respond([
{ key: 'entra_device_url', value: 'https://entra/{id}' },
{ key: 'intune_device_url', value: 'https://intune/{id}' },
]))
const settings = await freshModule()
expect(await settings.getEntraDeviceUrls()).toEqual({
entraUrl: 'https://entra/{id}',
intuneUrl: 'https://intune/{id}',
lapsUrl: '',
loaded: true,
})
expect(list).toHaveBeenCalledTimes(1)
})
})
describe('failure', () => {
it('falls back without throwing so a caller still renders', async () => {
list.mockRejectedValue(new Error('network down'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const settings = await freshModule()
expect(await settings.getFacilityName()).toBe('ShopDB')
// loaded:false is the point - a failed fetch and an unconfigured site both
// read as empty strings, and the caller needs to tell them apart.
expect(await settings.getEntraDeviceUrls()).toEqual({
entraUrl: '', intuneUrl: '', lapsUrl: '', loaded: false,
})
})
it('does not hammer a backend that is down', async () => {
list.mockRejectedValue(new Error('network down'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const settings = await freshModule()
await settings.getFacilityName()
await settings.getFacilityName()
await settings.getFacilityName()
expect(list).toHaveBeenCalledTimes(1)
})
it('recovers after the retry window instead of staying blank for the session', async () => {
list.mockRejectedValueOnce(new Error('network down'))
vi.spyOn(console, 'error').mockImplementation(() => {})
const settings = await freshModule()
expect(await settings.getSetting('entra_device_url')).toBe('')
vi.setSystemTime(Date.now() + 31000)
list.mockResolvedValue(respond([{ key: 'entra_device_url', value: 'https://entra/{id}' }]))
expect(await settings.getSetting('entra_device_url')).toBe('https://entra/{id}')
expect(list).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,891 @@
<template>
<div class="backups-page">
<div class="page-header">
<h2>Backups</h2>
</div>
<div v-if="!isConfigured" class="not-configured">
<strong>Site Backups is not configured on this deployment.</strong>
<span>
The feature needs the backup overlay (a separate container writing sets
onto a shared volume) and a readable backups root. A Windows/IIS install
does not run it, so nothing here would have a store behind it.
</span>
</div>
<!-- Confidence banner: the single most reassuring thing an admin wants to
see - is there a recent, verified backup? Everything else is secondary. -->
<div v-if="isConfigured" class="confidence-banner" :class="bannerClass">
<span class="banner-icon">
<component :is="bannerIcon" :size="22" />
</span>
<div class="banner-text">
<strong>{{ bannerHeadline }}</strong>
<span class="banner-sub">{{ bannerSub }}</span>
</div>
<button class="btn-primary" :disabled="isBusy" @click="runBackup">
{{ isBusy ? 'Working...' : 'Back up now' }}
</button>
</div>
<!-- Light explanation, details tucked away. Leads the UI-simplification
charge: one plain sentence up top, the depth behind a disclosure. -->
<details class="explain">
<summary>What is backed up, and how restore works</summary>
<div class="explain-body">
<p>
Each backup is one recoverable set: the full database (users,
settings, and every asset), the uploaded files, and a manifest that
records the exact code version so a restore can never land on the
wrong schema.
</p>
<p>
Backups keep <strong>any day in the last 5</strong>, plus weekly and
monthly copies. Restoring your <em>data</em> is separate from rolling
back <em>code</em>: code is handled in git. If a restore needs a newer
code version than you are running, this screen tells you before it
lets you proceed.
</p>
</div>
</details>
<!-- Destination configuration: where sets ship and the credentials to get
there. SMB and AWS are wired but not yet armed (banner says so)
because their client libraries wait on the security review. -->
<details class="explain destination" :open="settingsOpen">
<summary @click.prevent="settingsOpen = !settingsOpen">
Backup destination
<span class="dest-chip">{{ destinationLabel(settings.destinationkind) }}</span>
</summary>
<div class="explain-body">
<div v-if="settingsLoading" class="muted">Loading destination...</div>
<template v-else>
<label class="field">
<span class="field-label">Ship backups to</span>
<select v-model="settings.destinationkind" class="input">
<option
v-for="kind in settings.destinationkinds"
:key="kind"
:value="kind"
>
{{ destinationLabel(kind) }}
</option>
</select>
</label>
<p v-if="settings.destinationkind === 'local'" class="muted small">
Sets stay on the mounted backups volume. No credentials needed.
</p>
<div
v-else
class="not-armed"
>
<component :is="ShieldAlert" :size="16" />
<span>
This destination is configured but not yet armed. Its client
library is held back until the security review clears it against
external exploit; until then sets keep writing locally.
</span>
</div>
<template v-if="settings.destinationkind === 'smb'">
<label class="field">
<span class="field-label">Server host</span>
<input v-model="settings.fields.backup_smb_host" class="input" />
</label>
<label class="field">
<span class="field-label">Share</span>
<input v-model="settings.fields.backup_smb_share" class="input" />
</label>
<label class="field">
<span class="field-label">Path within share</span>
<input v-model="settings.fields.backup_smb_path" class="input" />
</label>
<label class="field">
<span class="field-label">Username</span>
<input v-model="settings.fields.backup_smb_username" class="input" />
</label>
<label class="field">
<span class="field-label">Password</span>
<input
v-model="settings.fields.backup_smb_password"
type="password"
class="input"
autocomplete="new-password"
/>
</label>
</template>
<template v-else-if="settings.destinationkind === 'aws'">
<label class="field">
<span class="field-label">Region</span>
<input v-model="settings.fields.backup_aws_region" class="input" />
</label>
<label class="field">
<span class="field-label">Bucket</span>
<input v-model="settings.fields.backup_aws_bucket" class="input" />
</label>
<label class="field">
<span class="field-label">Key prefix</span>
<input v-model="settings.fields.backup_aws_prefix" class="input" />
</label>
<label class="field">
<span class="field-label">Access key id</span>
<input v-model="settings.fields.backup_aws_accesskeyid" class="input" />
</label>
<label class="field">
<span class="field-label">Secret access key</span>
<input
v-model="settings.fields.backup_aws_secretaccesskey"
type="password"
class="input"
autocomplete="new-password"
/>
</label>
</template>
<div class="modal-actions">
<button class="btn-primary" :disabled="savingSettings" @click="saveSettings">
{{ savingSettings ? 'Saving...' : 'Save destination' }}
</button>
</div>
</template>
</div>
</details>
<div v-if="loading" class="muted">Loading backups...</div>
<div v-else-if="!sets.length" class="empty-state">
<p>No backups yet. Run your first one above - it is safe to do anytime.</p>
</div>
<table v-else class="backups-table">
<thead>
<tr>
<th>Taken</th>
<th>Size</th>
<th>Status</th>
<th>Restore</th>
</tr>
</thead>
<tbody>
<tr v-for="set in sets" :key="set.snapshotid">
<td>
<span class="taken">{{ formatTaken(set.takenat) }}</span>
<span class="setid">{{ set.snapshotid }}</span>
</td>
<td>{{ formatSize(set.sizebytes) }}</td>
<td>
<span class="chip" :class="statusClass(set)">
{{ statusLabel(set) }}
</span>
</td>
<td>
<button
class="btn-restore"
:class="{ blocked: !set.iscompatible }"
:disabled="isBusy"
@click="openRestore(set)"
>
Restore...
</button>
</td>
</tr>
</tbody>
</table>
<!-- Operational run records, written by the backup container onto the
shared volume. Two cards: what was backed up, and what was restored. -->
<div class="history-cards">
<div class="history-card">
<h3 class="history-title">Backup history</h3>
<p v-if="!backups.length" class="muted">No backup runs recorded yet.</p>
<ul v-else class="history-list">
<li v-for="(entry, index) in backups" :key="'b' + index" class="history-row">
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
{{ outcomeLabel(entry.outcome) }}
</span>
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
</li>
</ul>
</div>
<div class="history-card">
<h3 class="history-title">Restore history</h3>
<p v-if="!restores.length" class="muted">No restore runs recorded yet.</p>
<ul v-else class="history-list">
<li v-for="(entry, index) in restores" :key="'r' + index" class="history-row">
<span class="history-outcome" :class="outcomeClass(entry.outcome)">
{{ outcomeLabel(entry.outcome) }}
</span>
<span class="history-set">{{ entry.setname || 'unknown set' }}</span>
<span class="history-when">{{ formatTaken(entry.finishedat) }}</span>
</li>
</ul>
</div>
</div>
<!-- Guarded restore modal: the one genuinely risky action, so it spends the
UI budget on the compatibility verdict before allowing the click. -->
<div v-if="restoreTarget" class="modal-backdrop" @click.self="closeRestore">
<div class="modal">
<h3>Restore this backup?</h3>
<p class="modal-set">{{ formatTaken(restoreTarget.takenat || restoreTarget.snapshotid) }}</p>
<!-- Progress mode: once a restore is queued we swap the compatibility
body for an indeterminate spinner and the current phase label. -->
<template v-if="restorePhase">
<div class="restore-progress" :class="{ failed: restorePhase === 'failed' }">
<span v-if="isRestoreRunning" class="spinner" aria-hidden="true"></span>
<component
:is="restorePhase === 'done' ? ShieldCheck : AlertTriangle"
v-else
:size="18"
/>
<span class="restore-phase">{{ restorePhaseLabel }}</span>
</div>
<p class="restore-hint">
Keep this open until the restore finishes. This can take a moment.
</p>
<div class="modal-actions">
<button
class="btn-ghost"
:disabled="isRestoreRunning"
@click="closeRestore"
>
Close
</button>
</div>
</template>
<template v-else>
<div class="compat" :class="restoreTarget.iscompatible ? 'ok' : 'warn'">
<component :is="restoreTarget.iscompatible ? ShieldCheck : AlertTriangle" :size="18" />
<span v-if="restoreTarget.iscompatible">
Compatible with the code you are running. Safe to restore.
</span>
<span v-else>
This backup was taken on a newer schema than the running code.
Upgrade the code first, then retry - restore is blocked until then.
</span>
</div>
<details class="explain small">
<summary>What restoring does</summary>
<div class="explain-body">
<p>
Restoring replaces the current database and uploaded files with the
contents of this backup. Code is not changed. This cannot be undone,
so it is gated on the compatibility check above.
</p>
</div>
</details>
<div class="modal-actions">
<button class="btn-ghost" @click="closeRestore">Cancel</button>
<button
class="btn-danger"
:disabled="!restoreTarget.iscompatible || isBusy"
@click="confirmRestore"
>
Restore
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ShieldCheck, AlertTriangle, ShieldAlert, Clock } from 'lucide-vue-next'
import api from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const sets = ref([])
const loading = ref(true)
const isBusy = ref(false)
const lastGoodAt = ref(null)
// Assume configured until the list says otherwise, so a slow first load
// does not flash the not-configured notice on a working install.
const isConfigured = ref(true)
const restoreTarget = ref(null)
// Live restore progress. restoreStatus mirrors the api /status.restore payload;
// restorePhase drives the indeterminate spinner and label in the modal.
const restoreStatus = ref(null)
let statusTimer = null
let restoreStartedAt = 0
// The Flask-less backup sidecar loads the dump and parks the restore in
// 'upgrading', because it cannot run `flask db upgrade` itself. The api can, so
// the browser pokes the finalize endpoint once to roll the restored data up to
// the running code. Guarded so we fire it exactly once per restore run.
let finalizeRequested = false
const backups = ref([])
const restores = ref([])
// Human labels for the phase names the watcher scripts write. Anything we do
// not recognize falls back to the raw phase so a new phase still shows.
const RESTORE_PHASE_LABELS = {
queued: 'Queued...',
checking: 'Checking compatibility...',
files: 'Restoring files...',
database: 'Loading database...',
upgrading: 'Upgrading schema to code...',
done: 'Restore complete',
failed: 'Restore failed',
}
const restorePhase = computed(() => restoreStatus.value?.phase || null)
const isRestoreRunning = computed(() => {
const phase = restorePhase.value
return !!phase && phase !== 'done' && phase !== 'failed'
})
const restorePhaseLabel = computed(() => {
const phase = restorePhase.value
if (!phase) return ''
return RESTORE_PHASE_LABELS[phase] || phase
})
const settingsOpen = ref(false)
const settingsLoading = ref(true)
const savingSettings = ref(false)
const settings = ref({
destinationkind: 'local',
destinationkinds: ['local'],
fields: {},
})
const DESTINATION_LABELS = {
local: 'Local volume',
smb: 'SMB / CIFS share',
aws: 'AWS S3 bucket',
}
function destinationLabel(kind) {
return DESTINATION_LABELS[kind] || kind
}
async function loadSettings() {
settingsLoading.value = true
try {
const response = await api.get('/shopdbbackups/settings')
const data = response.data.data || {}
settings.value = {
destinationkind: data.destinationkind || 'local',
destinationkinds: data.destinationkinds || ['local'],
fields: data.fields || {},
}
} catch (error) {
toast.error('Could not load backup destination.')
} finally {
settingsLoading.value = false
}
}
async function saveSettings() {
savingSettings.value = true
try {
const response = await api.put('/shopdbbackups/settings', {
destinationkind: settings.value.destinationkind,
fields: settings.value.fields,
})
const data = response.data.data || {}
settings.value = {
destinationkind: data.destinationkind || 'local',
destinationkinds: data.destinationkinds || ['local'],
fields: data.fields || {},
}
toast.success('Backup destination saved.')
} catch (error) {
const message = error.response?.data?.data?.error?.message
toast.error(message || 'Could not save the backup destination.')
} finally {
savingSettings.value = false
}
}
async function load() {
loading.value = true
try {
const response = await api.get('/shopdbbackups')
sets.value = response.data.data || []
lastGoodAt.value = response.data.meta?.lastgoodat || null
// A deployment without the backup overlay has no store behind any of
// this. Say so instead of showing 'No verified backup yet', which reads
// as a backup that failed rather than a feature that is not installed.
isConfigured.value = response.data.meta?.configured !== false
} catch (error) {
toast.error('Could not load backups.')
} finally {
loading.value = false
}
}
async function runBackup() {
isBusy.value = true
try {
const response = await api.post('/shopdbbackups')
toast.success(response.data.message || 'Backup queued.')
} catch (error) {
toast.error('Could not queue a backup.')
} finally {
isBusy.value = false
load()
}
}
function openRestore(set) {
restoreTarget.value = set
}
function closeRestore() {
stopStatusPolling()
restoreStatus.value = null
restoreTarget.value = null
}
async function confirmRestore() {
if (!restoreTarget.value?.iscompatible) return
isBusy.value = true
try {
await api.post(`/shopdbbackups/${restoreTarget.value.snapshotid}/restore`)
toast.success('Restore queued and validated against the live schema.')
// Keep the modal open and switch it to progress mode; polling drives the
// spinner through each phase until the watcher reports done or failed.
startStatusPolling()
} catch (error) {
const message = error.response?.data?.data?.error?.message
toast.error(message || 'Could not queue the restore.')
} finally {
isBusy.value = false
}
}
async function loadHistory() {
try {
const response = await api.get('/shopdbbackups/history')
const data = response.data.data || {}
backups.value = data.backups || []
restores.value = data.restores || []
} catch (error) {
// History is non-critical; a failure here should not disrupt the page.
}
}
async function pollStatus() {
let status
try {
const response = await api.get('/shopdbbackups/status')
status = response.data.data?.restore || null
} catch (error) {
return
}
// Ignore a terminal status left over from a previous restore: if it finished
// before we started this one, it is stale and would close the modal instantly
// before the spinner ever paints. Wait for a fresh status from this run.
if (status && (status.phase === 'done' || status.phase === 'failed')) {
const finishedAt = new Date(normalizeStamp(status.updatedat || '')).getTime()
if (Number.isNaN(finishedAt) || finishedAt < restoreStartedAt) {
return
}
}
restoreStatus.value = status
const phase = status?.phase
if (phase === 'upgrading' && !finalizeRequested) {
// Hand off to the api container, which owns the Flask CLI, to run the
// post-load schema upgrade. Fire once; polling keeps tracking until it
// reports done or failed.
finalizeRequested = true
try {
await api.post('/shopdbbackups/finalize-restore')
} catch (error) {
// The finalize endpoint stamps its own failed status on error, which the
// next poll surfaces; nothing extra to do here.
}
}
if (phase === 'done' || phase === 'failed') {
stopStatusPolling()
if (phase === 'done') {
toast.success('Restore complete.')
closeRestore()
} else {
toast.error(status?.message || 'Restore failed.')
}
load()
loadHistory()
}
}
function startStatusPolling() {
stopStatusPolling()
restoreStartedAt = Date.now()
finalizeRequested = false
restoreStatus.value = { phase: 'queued', message: 'Restore queued' }
// Poll on an interval only; skip the immediate call so the seeded "queued"
// phase is guaranteed to paint before the first real status can replace it.
statusTimer = setInterval(pollStatus, 1000)
}
function stopStatusPolling() {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
}
// -- Confidence banner state ------------------------------------------------
const STALE_HOURS = 48
const hoursSinceLastGood = computed(() => {
if (!lastGoodAt.value) return null
const then = new Date(normalizeStamp(lastGoodAt.value)).getTime()
return (Date.now() - then) / 36e5
})
const bannerState = computed(() => {
if (!lastGoodAt.value) return 'none'
if (hoursSinceLastGood.value > STALE_HOURS) return 'stale'
return 'good'
})
const bannerClass = computed(() => `banner-${bannerState.value}`)
const bannerIcon = computed(() => ({
good: ShieldCheck, stale: Clock, none: ShieldAlert,
}[bannerState.value]))
const bannerHeadline = computed(() => ({
good: 'Your data is protected',
stale: 'Backup is overdue',
none: 'No verified backup yet',
}[bannerState.value]))
const bannerSub = computed(() => {
if (bannerState.value === 'none') return 'Run one now - it only takes a moment.'
return `Last verified backup ${formatTaken(lastGoodAt.value)}.`
})
// -- Formatting -------------------------------------------------------------
function formatTaken(value) {
if (!value) return 'unknown'
const date = new Date(normalizeStamp(value))
if (Number.isNaN(date.getTime())) return value
// Stamps are UTC; render them in UTC so the displayed time matches the
// Zulu-stamped set id rather than shifting to the operator's local zone.
return date.toLocaleString(undefined, { timeZone: 'UTC', timeZoneName: 'short' })
}
function outcomeClass(outcome) {
return outcome === 'success' ? 'outcome-success' : 'outcome-failed'
}
function outcomeLabel(outcome) {
return outcome === 'success' ? 'Success' : 'Failed'
}
// Backup ids and manifest stamps use a filename-safe UTC form with no colons
// (YYYY-MM-DDTHHMMSSZ, sometimes prefixed with a site name), which the Date
// parser rejects. Reinsert the colons so it parses as real ISO 8601.
function normalizeStamp(value) {
const match = String(value).match(/(\d{4}-\d{2}-\d{2})T(\d{2})(\d{2})(\d{2})Z$/)
if (!match) return value
const [, day, hour, minute, second] = match
return `${day}T${hour}:${minute}:${second}Z`
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
}
function statusClass(set) {
if (set.ischeckpassed) return 'chip-verified'
if (!set.hasmanifest) return 'chip-unverified'
return 'chip-pending'
}
function statusLabel(set) {
if (set.ischeckpassed) return 'Verified'
if (!set.hasmanifest) return 'Unverified'
return 'Not checked'
}
onMounted(() => {
load()
loadSettings()
loadHistory()
})
onUnmounted(() => {
stopStatusPolling()
})
</script>
<style scoped>
.page-header {
margin-bottom: 1rem;
}
.confidence-banner {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.1rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
margin-bottom: 1rem;
}
.confidence-banner .banner-text {
display: flex;
flex-direction: column;
flex: 1;
}
.banner-sub {
color: var(--text-light);
font-size: 0.85rem;
}
.banner-good { border-color: #2f855a; }
.banner-good .banner-icon { color: #38a169; }
.banner-stale { border-color: #b7791f; }
.banner-stale .banner-icon { color: #d69e2e; }
.banner-none { border-color: #c53030; }
.banner-none .banner-icon { color: #e53e3e; }
.not-configured {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
border: 1px solid var(--border-color, #d8dbe0);
border-radius: 8px;
background: var(--surface-muted, #f6f7f9);
}
.explain {
margin-bottom: 1.25rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
}
.explain.small { margin: 0.75rem 0; }
.explain summary {
cursor: pointer;
padding: 0.6rem 0.9rem;
font-size: 0.86rem;
color: var(--text-light);
user-select: none;
}
.explain-body {
padding: 0 0.9rem 0.8rem 0.9rem;
}
.explain-body p {
margin: 0 0 0.6rem 0;
color: var(--text-light);
font-size: 0.86rem;
line-height: 1.5;
}
.backups-table {
width: 100%;
border-collapse: collapse;
}
.backups-table th,
.backups-table td {
text-align: left;
padding: 0.6rem 0.7rem;
border-bottom: 1px solid var(--border);
font-size: 0.88rem;
}
.backups-table th {
color: var(--text-light);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.taken { display: block; color: var(--text); }
.setid { display: block; color: var(--text-light); font-size: 0.74rem; }
.chip {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
}
.chip-verified { background: rgba(56, 161, 105, 0.18); color: #48bb78; }
.chip-pending { background: rgba(214, 158, 46, 0.18); color: #ecc94b; }
.chip-unverified { background: rgba(229, 62, 62, 0.18); color: #fc8181; }
.btn-primary {
background: var(--primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-weight: 600;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-restore {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.7rem;
cursor: pointer;
}
.btn-restore.blocked { opacity: 0.55; }
.empty-state, .muted {
color: var(--text-light);
padding: 1rem 0;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
width: min(460px, 92vw);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.2rem;
}
.modal h3 { margin: 0 0 0.3rem 0; }
.modal-set { color: var(--text-light); font-size: 0.8rem; margin: 0 0 0.9rem 0; }
.compat {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.4;
}
.compat.ok { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
.compat.warn { background: rgba(214, 158, 46, 0.15); color: #f6e05e; }
.restore-progress {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
background: rgba(56, 161, 105, 0.15);
color: #9ae6b4;
font-size: 0.9rem;
}
.restore-progress.failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
.restore-phase { font-weight: 600; }
.restore-hint {
color: var(--text-light);
font-size: 0.8rem;
margin: 0.5rem 0 0 0;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(154, 230, 180, 0.35);
border-top-color: #9ae6b4;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.history-cards {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
.history-card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
padding: 1rem 1.1rem;
}
.history-title {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
}
.history-list {
list-style: none;
margin: 0;
padding: 0;
}
.history-row {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.5rem 0;
border-top: 1px solid var(--border);
font-size: 0.85rem;
}
.history-row:first-child { border-top: none; }
.history-outcome {
flex-shrink: 0;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
}
.outcome-success { background: rgba(56, 161, 105, 0.15); color: #9ae6b4; }
.outcome-failed { background: rgba(197, 48, 48, 0.15); color: #feb2b2; }
.history-set {
flex: 1;
font-family: monospace;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-when { color: var(--text-light); flex-shrink: 0; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1rem;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
}
.btn-danger {
background: #c53030;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 0.9rem;
cursor: pointer;
font-weight: 600;
}
.btn-danger:disabled { opacity: 0.5; cursor: default; }
</style>

View File

@@ -4,7 +4,7 @@
// Order: site identity + the reference-data catalogs users touch daily come
// first, then the platform/system groups (integrations, communication, search,
// plugins, access) cluster together at the end.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload } from 'lucide-vue-next'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, History, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Mail, ShieldCheck, KeyRound, Fingerprint, Search, Key, Upload, DatabaseBackup } from 'lucide-vue-next'
export const settingsGroups = [
{
@@ -113,6 +113,7 @@ export const settingsGroups = [
title: 'System',
cards: [
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
{ to: '/settings/shopdbbackups', icon: DatabaseBackup, title: 'Site Backups', description: 'Scheduled database and file backups with guided restore; keeps any day in the last 5' },
],
},
{

View File

@@ -0,0 +1,264 @@
#requires -Version 5.1
<#
.SYNOPSIS
Seed HKLM:\SOFTWARE\GE\ShopDB\DeviceId from whatever identity a bay already
carries, so device identity can move off C:\Enrollment and into the
registry.
.DESCRIPTION
Phase 3 of docs/proposals/device-identity.md. The registry is the
destination because it survives the profile cleanup and disk hygiene that
eats C:\Enrollment, is readable as SYSTEM with no file ACL to get wrong, and
is already where the collector looks for BaseUrl and ApiToken.
This script does the one-time backfill: it reads the identity a bay holds
today and writes it to DeviceId. It is idempotent and safe to run every
GE-Enforce cycle.
THE SOURCE DEPENDS ON THE PC TYPE, AND THAT IS THE WHOLE POINT.
DeviceId names the DEVICE hanging off this PC. A machine number is a
different fact - "which bay is this" rather than "which device is this" -
and the two coincide for exactly one family:
CMM bays the machine number IS the instrument id (CMM4), which is
why cmmid.txt has always doubled as identity. Copying it
is correct.
part markers the machine number is the OPERATION (0613), and several
markers serve one operation. Copying it would name the
operation as the device. The collector refuses that
("asset X is not a Part Marker; not linked") because a
machine filed under a device label reads as a marker
everywhere downstream. So machine number is NEVER a
source here.
Keyence, no per-bay id exists at all. asset-id.txt is the only
Genspect, identity these have ever had.
wax-trace
everything not a device bay. DeviceId is not consumed for these
else pc-types, so writing one is a landmine for the day that
pc-type gains a device spec. Writes nothing.
9999 is the unassigned placeholder and is skipped everywhere, matching
Report-AssetToShopDB.ps1 and the server.
.PARAMETER Force
Overwrite a DeviceId that is already set to a DIFFERENT value.
Off by default on purpose. asset-id.txt is written ONCE at imaging and
nothing updates it when a device is swapped, so a stale file overwriting a
corrected registry value would re-introduce the exact defect this move is
meant to fix. A disagreement is reported and left alone for a human.
.PARAMETER PCType
Override the pc-type instead of reading C:\Enrollment\pc-type.txt. For
testing and for a bay whose file is missing.
.PARAMETER DeviceId
Write this value directly, skipping resolution. Still honours -Force and
the 9999 rule.
.PARAMETER LogPath
Append a transcript here as well as writing to the pipeline.
.EXAMPLE
.\Set-ShopDBDeviceId.ps1 -WhatIf
Show what would be written on this bay without touching the registry.
.EXAMPLE
.\Set-ShopDBDeviceId.ps1
Seed DeviceId. Safe to run repeatedly and from a GE-Enforce PS1 entry.
.NOTES
Exit codes:
0 wrote a value, it was already correct, or this is not a device bay
1 an error stopped the write
2 an existing DeviceId disagrees with the resolved value; needs a human
or -Force
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[switch]$Force,
[string]$PCType,
[string]$DeviceId,
[string]$LogPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RegPath = 'HKLM:\SOFTWARE\GE\ShopDB'
$RegValueName = 'DeviceId'
$Placeholder = '9999'
function Log {
param([string]$Message)
$line = "{0} [Set-ShopDBDeviceId] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Output $line
if ($LogPath) {
try { Add-Content -LiteralPath $LogPath -Value $line -ErrorAction Stop } catch { }
}
}
function Read-FirstLine {
# One line, trimmed, empty when absent or unreadable. A bay missing a file
# is the normal case, not an error.
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return '' }
try {
$v = [string](Get-Content -LiteralPath $Path -First 1 -ErrorAction Stop)
return $v.Trim()
} catch {
Log "WARN could not read ${Path}: $($_.Exception.Message)"
return ''
}
}
function Get-MachineNoFromRegistry {
# eDNC/NTLARS, WOW6432Node first, matching Report-AssetToShopDB.ps1.
foreach ($rp in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) {
try {
if (Test-Path $rp) {
$v = [string](Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo
if ($v) {
$v = $v.Trim()
if ($v -and $v -ne $Placeholder) { return $v }
}
}
} catch {
Log "WARN could not read MachineNo from ${rp}: $($_.Exception.Message)"
}
}
return ''
}
# Which pc-types drive a device, and whether the machine number is a legitimate
# identity for that device. Mirrors SUBORDINATE_DEVICE_MAP in
# plugins/computers/pctypemap.py. A site that added its own device pc-type
# through a subordinatedevice_<pctype> setting adds it here too.
$DeviceFamilies = @{
'gea-shopfloor-cmm' = @{ Label = 'CMM'; MachineNoIsDevice = $true }
'gea-shopfloor-keyence' = @{ Label = 'Vision System'; MachineNoIsDevice = $false }
'gea-shopfloor-genspect' = @{ Label = 'Genspect'; MachineNoIsDevice = $false }
'gea-shopfloor-waxtrace' = @{ Label = 'Form Tracer'; MachineNoIsDevice = $false }
'gea-shopfloor-partmarker'= @{ Label = 'Part Marker'; MachineNoIsDevice = $false }
}
# --- 1. what kind of bay is this -------------------------------------------
if (-not $PCType) { $PCType = Read-FirstLine 'C:\Enrollment\pc-type.txt' }
if (-not $PCType) {
Log 'no pc-type on this PC (C:\Enrollment\pc-type.txt missing or empty); nothing to do'
exit 0
}
if (-not $DeviceFamilies.ContainsKey($PCType)) {
Log "pc-type '$PCType' does not drive a device; DeviceId not written"
exit 0
}
$family = $DeviceFamilies[$PCType]
Log "pc-type '$PCType' drives a $($family.Label)"
# --- 2. resolve the identity ------------------------------------------------
$resolved = ''
$source = ''
if ($DeviceId) {
$resolved = $DeviceId.Trim()
$source = '-DeviceId parameter'
} else {
# Most stable identity first, and the machine number only where it really
# names the device.
if ($family.MachineNoIsDevice) {
$v = Read-FirstLine 'C:\Enrollment\cmm\cmmid.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\cmm\cmmid.txt' }
if (-not $resolved) {
$v = Get-MachineNoFromRegistry
if ($v) { $resolved = $v; $source = 'eDNC MachineNo registry' }
}
}
if (-not $resolved) {
$v = Read-FirstLine 'C:\Enrollment\asset-id.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\asset-id.txt' }
}
if (-not $resolved) {
# The 0.12.0 name, before the file was generalised.
$v = Read-FirstLine 'C:\Enrollment\measuringtool-id.txt'
if ($v -and $v -ne $Placeholder) { $resolved = $v; $source = 'C:\Enrollment\measuringtool-id.txt' }
}
}
if (-not $resolved) {
# Nothing to copy. Silence is the right answer: the collector still adopts
# or mints as it does today, and inventing a value here would name a device
# that nobody can account for.
Log "no identity found for this $($family.Label) bay; DeviceId not written"
exit 0
}
if ($resolved -eq $Placeholder) {
Log "resolved value is the $Placeholder placeholder; DeviceId not written"
exit 0
}
Log "resolved DeviceId '$resolved' from $source"
# --- 3. write it ------------------------------------------------------------
$existing = ''
try {
if (Test-Path $RegPath) {
$prop = Get-ItemProperty -Path $RegPath -Name $RegValueName -ErrorAction SilentlyContinue
if ($prop) { $existing = [string]$prop.$RegValueName; $existing = $existing.Trim() }
}
} catch {
Log "WARN could not read existing ${RegValueName}: $($_.Exception.Message)"
}
if ($existing -eq $resolved) {
Log "DeviceId already '$resolved'; nothing to do"
exit 0
}
if ($existing -and -not $Force) {
# The registry value wins by default. C:\Enrollment is written once at
# imaging and never updated on a device swap, so the file is the more
# likely one to be wrong.
Log "REFUSING to overwrite: DeviceId is '$existing', $source says '$resolved'"
Log 'Re-run with -Force if the file is right, or fix the file if the registry is.'
exit 2
}
try {
if (-not (Test-Path $RegPath)) {
if ($PSCmdlet.ShouldProcess($RegPath, 'Create registry key')) {
New-Item -Path $RegPath -Force | Out-Null
Log "created $RegPath"
}
}
$action = if ($existing) { "Set $RegValueName '$existing' -> '$resolved'" }
else { "Set $RegValueName '$resolved'" }
if ($PSCmdlet.ShouldProcess($RegPath, $action)) {
New-ItemProperty -Path $RegPath -Name $RegValueName -Value $resolved `
-PropertyType String -Force | Out-Null
Log "wrote $RegPath\$RegValueName = '$resolved'"
} else {
Log "WHATIF would write $RegPath\$RegValueName = '$resolved'"
}
} catch {
Log "ERROR could not write ${RegValueName}: $($_.Exception.Message)"
exit 1
}
exit 0

View File

@@ -174,7 +174,9 @@ async function loadData() {
}
if (types.status === 'fulfilled') {
pctypes.value = types.value.data.data || []
// Plain array, same as applications below. Reading .data.data threw
// inside this try, so apps, actions and monitors never got assigned.
pctypes.value = types.value || []
} else {
failures.push('PC types')
console.error('Error loading PC types:', types.reason)

File diff suppressed because it is too large Load Diff

View File

@@ -573,12 +573,14 @@ onMounted(async () => {
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list(),
businessunitsApi.listAll(),
// Handles its own failure: a site without printers still gets a form.
])
pcTypes.value = ptRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
// listAll() pages past the server's 100-row cap and returns the ROWS, not
// an axios response, so these are read directly. Reading .data.data here
// throws and takes the whole form down with it.
vendors.value = vendorRes || []
models.value = allModels
locations.value = locRes || []
operatingsystems.value = osRes.data.data || []
@@ -636,7 +638,11 @@ onMounted(async () => {
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
// A client-side TypeError has no err.response, so apiError() would report
// only the fallback and hide which field broke. Name it either way.
error.value = err?.response
? apiError(err, 'Failed to load data')
: `Failed to load data: ${err?.message || err}`
} finally {
loading.value = false
}

View File

@@ -107,8 +107,10 @@ onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
// listAll resolves to a plain array (fetchAllPages), not an axios
// response. Reading .data.data threw and left the page empty.
const response = await computersApi.types.listAll({ active: false })
pcTypes.value = response.data.data || []
pcTypes.value = response || []
} catch (err) {
console.error('Error loading PC types:', err)
} finally {

View File

@@ -10,8 +10,8 @@ schema changes, update this file in the same change.
- Method/URL: `POST {baseurl}/api/collector/endpoints`
- Auth header: `X-API-Key: <collector key>` (Bearer token also accepted). The
key is a UI-managed, endpoints-only service token minted from Settings ->
Endpoint agent key (section 6). A bootstrap `.env` collector key still works
until the first UI key is generated, then it is switched off (section 6). No
Endpoint agent key (section 7). A bootstrap `.env` collector key still works
until the first UI key is generated, then it is switched off (section 7). No
querystring fallback.
- Body: `application/json`, a single flat object (NOT an array). One POST per
report per endpoint.
@@ -51,9 +51,69 @@ arrays. `adopt` is first-contact only.
}
],
"scheduledtasks": [ /* opaque array */ ],
"disks": [
{
"drive": "C:",
"label": "OS",
"filesystem": "NTFS",
"sizebytes": 511000000000,
"freebytes": 128000000000,
"usedbytes": 383000000000,
"freepercent": 25.0 // precomputed; a threshold is a direct compare
}
],
"bios": { /* opaque object: vendor, version, release date */ },
"memory": { /* opaque object: totalbytes, freebytes */ },
"processor": { /* opaque object: name, cores, logicalprocessors, maxclockmhz */ },
"osinfo": { /* opaque object: build, version, install date */ },
"domain": "shopfloor.example.com", // domain or workgroup membership
"pendingreboot": false, // omit when unknown; absent != false
"rebootreasons": [], // [] = checked, nothing pending; see notes
"bootinfo": {
"hiberbootenabled": false, // fast startup CONFIGURED
"lastboottype": "Cold" // what the last boot DID: Cold|Fast startup|Resume|Unknown
},
"defender": { // persisted, deliberately not surfaced
"serviceenabled": true,
"realtimeenabled": true,
"signatureagedays": 0, // real 0 = updated today; null = did not report
"antispywareagedays": 0,
"quickscanagedays": 0
},
"diskhealth": [ // per PHYSICAL device; see notes
{
"deviceid": "0",
"model": "Samsung SSD 990 PRO 1TB",
"mediatype": "SSD",
"bustype": "NVMe",
"healthstatus": "Healthy",
"predictfailure": false, // precomputed; null = drive did not answer
"source": "physicaldisk" // or "diskdrive"
}
],
"usercontext": { // omitted entirely when nobody is signed in
"netuse": [
{
"localname": "Z:",
"remotename": "\\\\fileserver\\programs",
"status": "OK",
"username": "SHOPFLOOR\\operator",
"persistent": true
}
],
"userlisteners": [
{ "localaddress": "0.0.0.0", "localport": 7878, "processid": 4512, "processname": "DncMain" }
],
"idleseconds": 45,
"username": "operator",
"elevated": false, // token elevation of the collecting process;
// a spool stamped true is REJECTED (see 5.3)
"collectedat": "2025-01-14T06:12:00Z"
},
"events": [
{
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
"eventrecordid": 184412, // Windows record id; dedupe key, see notes
"eventchannel": "System",
"eventsource": "EventLog",
"eventtime": "2025-01-14T06:11:58Z",
@@ -81,6 +141,71 @@ Notes:
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
(DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none.
- `eventrecordid` is the Windows event record id: monotonic and unique within a
channel on a machine. The agent uses it as a per-channel harvest watermark and
the server enforces a unique index on
`(assetid, eventchannel, eventrecordid)`, so one real event is stored exactly
once. It is nullable: an older agent omits it, and MySQL permits repeated
NULLs in a unique index, so those reports keep the previous append behavior
rather than failing ingest. Send it whenever the source record exposes one.
- `disks`/`bios`/`memory`/`processor`/`osinfo`/`usercontext` are stored verbatim
as JSON snapshots on the endpoint state, newest-wins. `domain` and
`pendingreboot` are scalar columns because the fleet view filters and alerts
on them.
- OMITTING a key is not the same as sending an empty value. Every one of these
fields is applied only when the key is PRESENT, so a report that omits it
preserves the last-known value. Send `null`/`[]` only when you mean "this is
genuinely empty now"; omit when you simply did not collect it this cycle.
- `pendingreboot` follows that rule strictly: omit it when the check failed. A
literal `false` asserts the endpoint is confirmed NOT holding a reboot.
- `rebootreasons` is NOT redundant with `pendingreboot`. That key is a single
bool OR-ed from three registry signals and cannot say which fired, and the
difference is operational: `pendingfilerename` means files are staged for
replacement on next boot, so the box is mid-servicing, while a lone
`componentbasedservicing` or `windowsupdate` key can sit set for weeks on a
machine nobody has rebooted. Same boolean, very different urgency. An EMPTY
array means checked and nothing pending; OMIT the key when the check failed.
- `bootinfo` carries two distinct facts that must not be collapsed:
`hiberbootenabled` is whether fast startup is CONFIGURED (registry, always
readable) and `lastboottype` is what the last boot ACTUALLY did (Kernel-Boot
event 27). The registry cannot tell you what the last boot did, and the event
may have aged out of the log, so neither substitutes for the other. This
exists because uptime LIES under fast startup: a box reports days of uptime
after what the operator experienced as a shutdown.
- `defender` is stored but deliberately NOT surfaced. Defender produces known
false positives in this environment, so wiring it to a card now would train
operators to ignore that card. Signature/scan ages are in DAYS; a real `0`
means updated today, so send `null` when Defender did not report rather than
defaulting to zero.
- `interfaces` entries gained `linkspeedbps` and `fullduplex`, paired on by MAC
from `MSFT_NetAdapter`. Both are `null` when link state was unreadable or the
adapter is disconnected - an unknown duplex must not read as half-duplex.
- `diskhealth` is per PHYSICAL device and is NOT the same list as `disks`, which
is per volume. One physical drive can back several volumes or none the
operator sees, so the two do not share a key and are not merged.
- `diskhealth` carries BOTH agent sources and the server does not reconcile
them. `source: "physicaldisk"` is MSFT_PhysicalDisk, the only source that
reports NVMe at all - the classic ATA SMART class
(`MSStorageDriver_FailurePredictStatus`) returns zero instances on an NVMe
machine, so an agent collecting only that would report an empty health list on
modern hardware and look like "no problems". `source: "diskdrive"` is
Win32_DiskDrive.Status, covering ATA/SATA and hosts where the Storage
namespace is unavailable. When both answer for one drive and disagree, both
rows are shown; that disagreement is the signal.
- `predictfailure` is precomputed by the agent so an alert is a boolean test
rather than a string match against a Windows enum the server would have to
keep in sync. `null` means the drive did not answer: unknown, NOT healthy.
`healthstatus` keeps the human-readable source value alongside it.
- `usercontext` is gathered by a probe run inside the signed-in operator's
session by the `ShopDB user-context probe` scheduled task (and, opportunistically,
by the tray), because mapped drives, user-owned listeners and idle time
do not exist in the agent's own LocalSystem session 0. It is omitted when no
one is signed in, which is a normal unattended state, not a fault. The
security-context split that makes this work is normative; see section 5.
- `ports` carries LISTENING sockets only. The full connection table is available
on demand via the `network_connections` command instead, because established
connections churn constantly and would change every report for no diagnostic
gain.
## 3. Response
@@ -137,13 +262,23 @@ public sealed class EndpointReport
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("disks")] public object[]? Disks { get; set; }
[JsonPropertyName("bios")] public object? Bios { get; set; }
[JsonPropertyName("memory")] public object? Memory { get; set; }
[JsonPropertyName("processor")] public object? Processor { get; set; }
[JsonPropertyName("osinfo")] public object? OsInfo { get; set; }
[JsonPropertyName("domain")] public string? Domain { get; set; }
[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
[JsonPropertyName("usercontext")] public object? UserContext { get; set; }
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
@@ -163,7 +298,102 @@ public sealed class CollectorResponse
Serializer settings: `DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull`
so omitted fields are not sent (server upserts only present keys). Send UTC ISO-8601.
## 5. "Build site agent" settings flow (build cadence)
## 5. Security contexts, spool handoff and log destinations (NORMATIVE)
Three processes run as three different principals. Confusing them is what makes
`usercontext` silently empty, so the boundaries are fixed by contract.
| Process | Runs as | May write to |
|---|---|---|
| Service (`shopdbagent.exe`) | LocalSystem, session 0 | `%ProgramData%\ShopDB\EndpointAgent\logs\<date>-agent.log` |
| Tray + probe (`shopdb-endpoint-tray.exe`, `shopdbagent.exe --collect-usercontext`) | `ShopFloor` operator, interactive session, UNELEVATED (task principal `S-1-5-32-545` at `RunLevel Limited`) | `C:\Users\ShopFloor\ShopDB\` and `C:\Users\ShopFloor\ShopDB\Logs\<date>-trayagent.log` |
| Installer (`install.ps1`, generated by `_install_script()` in `plugins/endpoints/api/siteagent.py`) | Elevated admin, one-shot | `%ProgramData%\ShopDB\Logs\<datetime>-shopdbinstall.log` and the append-only ledger `%ProgramData%\ShopDB\Logs\shopdbagent-installs.log` |
Rules:
1. **The operator cannot write to `C:\Program Files` or to the SYSTEM-owned
`%ProgramData%\ShopDB\EndpointAgent\logs`.** Anything running in the user's
session MUST log under `C:\Users\ShopFloor\ShopDB\Logs`. The file log sink
degrades to a silent no-op when it cannot create its directory, so violating
this does not raise an error - it discards the log line and the failure
becomes invisible.
2. **User context is collected in the operator's own logon session**, because
mapped drives, user-owned listeners and idle time are per-session and do not
exist in session 0. The service MUST NOT try to descend into the session to
collect them. The authoritative collector is the `ShopDB user-context probe`
scheduled task, registered by the installer against the `BUILTIN\Users` group
(`S-1-5-32-545`) at `RunLevel Limited`, triggered at logon and every 30
minutes. The tray also probes on its own cadence and writes the same spool;
the two are belt-and-braces and the last writer wins.
3. **The probe MUST NOT run elevated, and a spool stamped `elevated` is
REJECTED.** Drive letters are a property of the logon token, not the machine:
an elevated process gets a different token to the operator's interactive one
and enumerates a different, usually empty, drive table. This was measured on a
live endpoint, where the same two shares reported `Unavailable` from an
elevated shell and `OK` from the operator's, in both `Win32_NetworkConnection`
and `Get-SmbMapping`. `--collect-usercontext` therefore refuses to run when
elevated (exit code 2) rather than producing a confident wrong answer, and the
installer MUST NOT start the tray or probe as a child process - it is
elevated, and `explorer.exe` de-elevates without changing ACCOUNT, so an
install run by an admin produced a probe running as that admin. It MUST
instead reach the operator's session through a scheduled task principal.
`RunLevel Limited` on the task is load-bearing for the same reason; a task
promoted to `Highest` reproduces the bug while still looking registered.
4. **The probe WRITES the spool; the service only READS it.** The spool PERSISTS
and is overwritten by each probe run. The service MUST NOT delete it: the
collector's last-known-good cache is in-memory only, so consuming the file
meant a service restart blanked mapped drives until the next probe cycle.
Staleness is bounded by the in-file `collectedat` stamp (max age 90 minutes),
not by deletion, so a probe that has stopped running still stops re-reporting
stale drives - it just does so on a timer instead of on a read.
5. **The spool location is fixed at `C:\Users\ShopFloor\ShopDB\usercontext.json`,
and writer and reader MUST derive it from the same helper.** Every shop-floor
endpoint uses the same local `ShopFloor` account. Resolving the path through
`WTSQueryUserToken` or through `%USERPROFILE%` is forbidden: the former is
privileged and endpoint protection blocks it, the latter is wrong under a
brokered or re-parented tray launch, and in both cases the writer and reader
disagree about the location with no error on either side.
6. **The probe MUST verify the spool exists after writing it.** A zero exit code
from `powershell.exe` proves the interpreter started, not that a file was
produced; success is reported only when the file is present on disk.
7. **A spool not stamped `ShopFloor` is REJECTED.**
collecting `username` and the service discards any spool whose stamp is
missing or different. Drive mappings from the wrong session are worse than
no drive mappings, so this fails loudly rather than degrading quietly.
8. **Losing user context is never fatal.** If the `ShopFloor` profile is absent
or unwritable, `usercontext` is omitted and telemetry, commands, inventory
and the command channel continue unaffected.
9. **`--collect-usercontext` MUST NOT touch privileged resources.** It runs as
the operator and only writes into that operator's own profile, so it is
handled before the service's bootstrap logger, fault monitor and config load.
It MUST NOT open the SYSTEM-owned log directory, register an EventLog source,
or read `CollectorKey` from HKLM - those are precisely the calls an EPM broker
intercepts, and the registry read would put the collector secret in a process
that has no need of it. It therefore also works on a host whose CollectorKey
has not been provisioned yet.
10. **The installer provisions** `C:\Users\ShopFloor\ShopDB\` and its `Logs`
subdirectory with `ShopFloor: Modify` before the probe first runs.
11. **Every install appends one line to the install ledger**, including
reinstalls and repeat installs of the same version, recording timestamp,
host, installing user, service version, tray version, install directory and
the user-context task state (`ok:<principal>/<runlevel>`, `UNVERIFIED:...`,
`FAILED:...` or `skipped`). The task is the single point of failure for
session-scoped collection, so its registration outcome MUST be recorded
where it survives the install transcript.
12. **Task registration MUST be verified by reading the task back**, not inferred
from `Register-ScheduledTask` returning without error. Policy or a
management agent can rewrite or drop a task after registration, and a task
that exists but runs with the wrong principal or run level looks healthy in
the scheduler UI while producing wrong data.
13. **All log filenames use `<stamp>-<component>.log`** so service, tray and
installer logs sort chronologically and collect with one date-leading glob.
The three log destinations are deliberately separate: an install is one-shot and
elevated, the service log is a rolling runtime log, and the tray log is the only
one written from the operator's session. Merging them makes an install
indistinguishable from runtime behaviour when reading a failure back.
## 6. "Build site agent" settings flow (build cadence)
Goal: an operator in ShopDB Settings produces an instance-locked `.exe` that
knows its server URL and its collector key, needing no manual config on the
@@ -190,7 +420,7 @@ Open item (from DESIGN-DECISIONS-agent.md 1a): whether `adopt` stays a
per-report flag or is promoted to a site setting (strict vs self-adopting
fleet). Default proposed: agent sends `adopt=true` on first run only.
## 6. UI-managed agent key (kill switch)
## 7. UI-managed agent key (kill switch)
The endpoint agent authenticates with a single UI-managed key scoped to ONLY
`collector.ingest.endpoints`, so rotating or revoking it stops all
@@ -215,13 +445,13 @@ The bootstrap `.env` key (`COLLECTOR_API_KEY[_ENDPOINTS]`) remains only as a
first-run path before a UI key exists; it cannot be the runtime kill switch
because container env is injected at boot and cannot be rotated live.
## 7. Command channel (WebUI -> agent)
## 8. Command channel (WebUI -> agent)
The WebUI can send an endpoint an ALLOWLISTED command (never an arbitrary
command string). Delivery is agent-initiated long-poll, so shop-floor PCs open
no inbound port: the agent makes an outbound request the server holds open until
a command is queued, runs it locally, and posts the result back. All three
routes reuse the same endpoints collector key (section 6) via `X-API-Key`, so
routes reuse the same endpoints collector key (section 7) via `X-API-Key`, so
the command channel shares the kill switch and adds no second secret.
Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
@@ -230,6 +460,11 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
(`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`).
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
- `mtconnect_status` - report whether the MTConnect UDC agent is running.
- `network_connections` - return the full TCP connection table with owning
process ids and names. Read-only diagnostic. The result is capped at 500
connections with a `truncated` flag in `detail`, because an oversized payload
rejected by the server returns nothing at all and a partial answer is more
useful than a failed one. Standard reports carry listening ports only.
### 7.1 Claim the next command (long-poll)

View File

@@ -26,10 +26,18 @@ from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog,
from .routes import endpoints_bp
from . import agentstore
# Hard cap on an uploaded agent binary. The real agent is a few MB; this is a
# generous ceiling that still refuses an operator who points at a giant wrong
# file before it is buffered fully into memory-staged storage.
_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
# Hard cap on an uploaded agent binary. This is a generous ceiling that still
# refuses an operator who points at a giant wrong file before it is buffered
# fully into memory-staged storage.
#
# 128 MB, not 64: the self-contained single-file tray publishes at ~69 MB
# (it carries the whole .NET runtime plus WinForms), which the old 64 MB cap
# rejected outright. The upload failed on SIZE, but the operator saw only a
# version-detection failure, because the size check returns before the PE is
# ever parsed. Config.MAX_CONTENT_LENGTH must stay above this - it bounds the
# whole multipart envelope, so a cap equal to it would reject a legal file on
# the boundary bytes alone.
_MAX_UPLOAD_BYTES = 128 * 1024 * 1024
@endpoints_bp.route('/siteagent/binaries/<binkey>', methods=['GET'])
@@ -69,11 +77,6 @@ def upload_agent_binary(binkey):
ErrorCodes.VALIDATION_ERROR,
'binkey must be one of: agent, tray',
http_code=400)
if not agentstore.is_valid_version(version):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'version must be a dotted-numeric string such as 1.6.3',
http_code=400)
upload = request.files.get('file')
if upload is None or not upload.filename:
@@ -94,6 +97,22 @@ def upload_agent_binary(binkey):
'uploaded file is not a Windows executable',
http_code=400)
# Gate the version at the PE, not at the raw input. Product versions carry
# semver build metadata (1.5.0+abcdef...) that is not a legal directory
# name, so the operator's typed string is only a hint. The dotted-numeric
# FILE version stamped in the binary's PE resource is the authority; fall
# back to it whenever the typed value is missing or not dotted-numeric, and
# only reject when neither yields a usable version.
if not agentstore.is_valid_version(version):
detected = agentstore.read_pe_file_version(data)
if agentstore.is_valid_version(detected):
version = detected
else:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'version must be a dotted-numeric string such as 1.6.3',
http_code=400)
result = agentstore.store_version(binkey, version, data,
make_current=makecurrent)

View File

@@ -87,6 +87,81 @@ def _format_uptime(seconds):
return ' '.join(parts)
def _state_json(state, attr):
"""Parse one of the EndpointState inventory JSON blobs.
These columns are written straight from the agent snapshot, so a partial
or malformed blob from one bad check-in must not take the whole panel
down. Anything unparseable reads as absent.
"""
raw = getattr(state, attr, None) if state else None
if not raw:
return None
try:
return json.loads(raw)
except (TypeError, ValueError):
return None
def _format_bytes(value):
"""Byte count -> 'N.N GB'. Sizes here are disks and RAM, never sub-MB."""
if value is None:
return None
try:
value = float(value)
except (TypeError, ValueError):
return None
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
if abs(value) < 1024.0 or unit == 'TB':
return '{:.1f} {}'.format(value, unit) if unit != 'B' else '{:.0f} B'.format(value)
value /= 1024.0
return None
def _format_agent_date(value):
"""Agent timestamps are ISO strings, not datetimes; show the date only.
A BIOS release date or an OS install date is only ever read at day
resolution, and the agent sends a full 7-digit-fraction UTC stamp.
"""
if not value:
return None
text = str(value)
return text[:10] if len(text) >= 10 else text
# A Windows event message runs to several hundred words. It is shown in an
# expander that is closed by default, so the cap here only exists to keep one
# pathological message from bloating the payload - not to fit a line. Blank
# runs are collapsed but real line breaks are kept, because the body is read
# as a block once opened.
_EVENT_BODY_LIMIT = 4000
def _event_body(event):
"""Readable message body for one curated event, bounded and normalized.
Windows messages arrive padded with long runs of blank lines that render
as ragged gaps. Runs of blank lines collapse to one and trailing spaces
go, but single newlines survive so the text keeps its shape when opened.
"""
text = (getattr(event, 'eventmessage', None) or '').strip()
if not text:
return None
lines = [line.rstrip() for line in text.splitlines()]
cleaned = []
for line in lines:
if not line and cleaned and not cleaned[-1]:
continue
cleaned.append(line)
text = '\n'.join(cleaned).strip()
if not text:
return None
if len(text) <= _EVENT_BODY_LIMIT:
return text
return text[:_EVENT_BODY_LIMIT].rstrip() + '...'
# =============================================================================
# Endpoint state
# =============================================================================
@@ -419,6 +494,60 @@ def _drift_for_state(state):
return parsed if isinstance(parsed, list) else []
def _dell_updates_summary(state):
"""Parse the cached Dell Command | Update summary off an EndpointState.
dellupdatesjson is written on check-in from the shopdb-dellupdates agent
utility, which reads the log the \\ShopDB-DellCommandScan task produces.
Null means the endpoint has never reported a scan. Tolerant of a malformed
blob so a bad cache never breaks the panel.
"""
raw = getattr(state, 'dellupdatesjson', None) if state else None
if not raw:
return None
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, dict) else None
def _dell_updates_value(state):
"""Render the informational 'Pending Dell updates' status value.
Informational only - this row never claims an endpoint needs attention,
because a pending optional driver is not a support problem. Three states are
kept distinct on purpose, and all three are VISIBLE:
never scanned -> 'Never scanned'. Deliberately not 'No': those are
different facts, and claiming 'No' would tell an operator
the machine is current when nobody has ever checked. It
is also not hidden - a missing row reads as a broken
panel, so the unknown state says so out loud.
scanned clean -> 'No'.
pending -> 'Yes (n)', with the count because one optional update and
fourteen urgent ones warrant different urgency.
"""
summary = _dell_updates_summary(state)
if not summary:
return 'Never scanned'
status = summary.get('status')
if status == 'error':
# The scan log exists but could not be read. A different problem from
# never having scanned, and worth saying so rather than implying the
# endpoint was never checked.
return 'Unknown (scan unreadable)'
if status != 'ok':
return 'Never scanned'
try:
count = int(summary.get('count') or 0)
except (TypeError, ValueError):
return 'Unknown (scan unreadable)'
if count <= 0:
return 'No'
return 'Yes ({})'.format(count)
@endpoints_bp.route('/asset/<int:asset_id>/status', methods=['GET'])
@require_permission('endpoints.view')
def panel_status(asset_id: int):
@@ -432,24 +561,34 @@ def panel_status(asset_id: int):
if not state:
return success_response({'fields': []})
def field(label, value, mono=False):
return {'label': label, 'value': value, 'mono': mono}
def field(label, value, mono=False, fmt=None):
entry = {'label': label, 'value': value, 'mono': mono}
if fmt:
entry['format'] = fmt
return entry
fields = [
field('Logged in user', state.loggedinuser),
field('Uptime', _format_uptime(state.uptimeseconds)),
field('Idle', _format_uptime(state.idleseconds)),
field('Last boot',
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None),
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None,
fmt='datetime'),
field('CPU %', state.cpupercent),
field('Memory %', state.memorypercent),
field('Agent', state.agentversion, mono=True),
field('Tray', state.trayversion, mono=True),
field('Last reported',
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
fmt='datetime'),
]
fields = [f for f in fields if f['value'] not in (None, '')]
# Informational only, and appended after the null-filter above because it
# always renders - including an explicit "Never scanned", which a hidden row
# would leave looking like a broken panel.
fields.append(field('Pending Dell updates', _dell_updates_value(state)))
# 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.
@@ -465,6 +604,492 @@ def panel_status(asset_id: int):
return success_response({'fields': fields})
@endpoints_bp.route('/asset/<int:asset_id>/hardware', methods=['GET'])
@require_permission('endpoints.view')
def panel_hardware(asset_id: int):
"""Keyvalue panel: processor, memory and system chassis inventory.
Only reported by agent 1.7.0 and later. An older agent leaves these
columns null, so the panel yields no fields and hides itself rather than
rendering a card full of blanks.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state:
return success_response({'fields': []})
processor = _state_json(state, 'processorjson') or {}
memory = _state_json(state, 'memoryjson') or {}
fields = [
{'label': 'Processor', 'value': processor.get('name')},
{'label': 'Cores', 'value': processor.get('cores')},
{'label': 'Logical processors', 'value': processor.get('logicalprocessors')},
{'label': 'Max clock',
'value': '{} MHz'.format(processor['maxclockmhz'])
if processor.get('maxclockmhz') else None},
{'label': 'Total memory', 'value': _format_bytes(memory.get('totalbytes'))},
{'label': 'Free memory', 'value': _format_bytes(memory.get('freebytes'))},
]
fields = [f for f in fields if f['value'] not in (None, '')]
return success_response({'fields': fields})
# Wire values to operator-readable text. The wire keys are stable contract;
# these labels are not, so they live here rather than in the agent.
_REBOOT_REASON_LABELS = {
'componentbasedservicing': 'servicing',
'windowsupdate': 'Windows Update',
'pendingfilerename': 'files staged for replacement',
}
@endpoints_bp.route('/asset/<int:asset_id>/osinfo', methods=['GET'])
@require_permission('endpoints.view')
def panel_osinfo(asset_id: int):
"""Keyvalue panel: operating system, firmware and domain membership.
OS and BIOS are one card on purpose: both answer "what is this box
running", and split across two cards each would be three short rows.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state:
return success_response({'fields': []})
osinfo = _state_json(state, 'osinfojson') or {}
bios = _state_json(state, 'biosjson') or {}
bootinfo = _state_json(state, 'bootinfojson') or {}
pendingreboot = None
if state.pendingreboot is not None:
pendingreboot = 'Yes' if state.pendingreboot else 'No'
# Say WHICH signal fired when we know. A staged file rename means the
# box is mid-servicing; a lone servicing key can sit set for weeks on a
# machine nobody rebooted. Same "Yes", very different urgency.
reasons = _state_json(state, 'rebootreasonsjson')
if state.pendingreboot and isinstance(reasons, list) and reasons:
pendingreboot = '{} ({})'.format(
pendingreboot, ', '.join(_REBOOT_REASON_LABELS.get(r, r)
for r in reasons if isinstance(r, str)))
# Only worth a row when fast startup actually applies. On a box with it
# disabled and a cold last boot there is nothing to warn about, and a
# permanent "Cold" row is noise on a card that is already dense.
boottype = bootinfo.get('lastboottype')
if boottype in (None, '', 'Cold', 'Unknown'):
boottype = None
elif boottype == 'Fast startup':
# Uptime is measured from the resume, not the last true boot, so it
# understates how long the kernel has actually been up. Said plainly
# because this is the field that makes uptime read wrong.
boottype = 'Fast startup (uptime not since cold boot)'
fields = [
{'label': 'OS version', 'value': osinfo.get('version'), 'mono': True},
{'label': 'Build', 'value': osinfo.get('build'), 'mono': True},
{'label': 'Architecture', 'value': osinfo.get('architecture')},
{'label': 'Installed', 'value': _format_agent_date(osinfo.get('installdate'))},
{'label': 'Domain', 'value': state.domain},
{'label': 'BIOS vendor', 'value': bios.get('vendor')},
{'label': 'BIOS version', 'value': bios.get('version'), 'mono': True},
{'label': 'BIOS date', 'value': _format_agent_date(bios.get('releasedate'))},
{'label': 'Last boot', 'value': boottype},
{'label': 'Pending reboot', 'value': pendingreboot},
]
fields = [f for f in fields
if f['value'] not in (None, '')]
return success_response({'fields': fields})
def _storage_rows(disks):
"""Turn the agent's volume list into display rows.
freepercent is computed by the agent from 1.7.0; for a snapshot written
by an older agent it is derived here from the byte counts so the column
is not empty for the one field an operator actually scans.
"""
if not isinstance(disks, list):
return []
rows = []
for disk in disks:
if not isinstance(disk, dict):
continue
freepercent = disk.get('freepercent')
if freepercent is None:
size = disk.get('sizebytes') or 0
free = disk.get('freebytes')
if size and free is not None:
freepercent = round(free * 100.0 / size, 1)
rows.append({
'drive': disk.get('drive'),
'label': disk.get('label') or '',
'filesystem': disk.get('filesystem'),
'size': _format_bytes(disk.get('sizebytes')),
'free': _format_bytes(disk.get('freebytes')),
'freepercent': '{}%'.format(freepercent) if freepercent is not None else None,
})
return rows
_STORAGE_COLUMNS = [
{'key': 'drive', 'label': 'Drive'},
{'key': 'label', 'label': 'Label'},
{'key': 'filesystem', 'label': 'Filesystem'},
{'key': 'size', 'label': 'Size'},
{'key': 'free', 'label': 'Free'},
{'key': 'freepercent', 'label': 'Free %'},
]
_DISKHEALTH_COLUMNS = [
{'key': 'model', 'label': 'Drive'},
{'key': 'mediatype', 'label': 'Media'},
{'key': 'bustype', 'label': 'Bus'},
{'key': 'healthstatus', 'label': 'Health'},
{'key': 'verdict', 'label': 'Verdict'},
{'key': 'source', 'label': 'Source'},
]
@endpoints_bp.route('/asset/<int:asset_id>/storage', methods=['GET'])
@require_permission('endpoints.view')
def panel_storage(asset_id: int):
"""Tabs panel: volume capacity and physical drive health as two sections.
One card because an operator looking at a full disk and an operator looking
at a dying disk are usually the same person in the same minute. TWO
SECTIONS because the two do not share a key: storage is per volume (C:, D:)
and health is per physical device, and one drive can back several volumes
or none the operator sees. Nothing is reconciled across the sections -
this is not the mapped-drive merge, where two sources described the same
drive and the worse status won.
Both sections are emitted even when empty so the section keeps its own
empty message; a dropped section would take its tab with it and read as
"no disks" rather than "nothing reported".
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
disks = _state_json(state, 'disksjson') if state else None
drives = _state_json(state, 'diskhealthjson') if state else None
return success_response({'sections': [
{
'label': 'Volumes',
'columns': _STORAGE_COLUMNS,
'rows': _storage_rows(disks),
'empty': 'No disks reported.',
},
{
'label': 'Disk health',
'columns': _DISKHEALTH_COLUMNS,
'rows': _diskhealth_rows(drives),
'empty': 'No disk health reported.',
},
]})
def _diskhealth_rows(drives):
"""Turn the agent's disk health list into display rows, worst first.
Both agent sources are kept rather than merged. MSFT_PhysicalDisk is the
only source that reports NVMe at all, and Win32_DiskDrive covers hardware
and hosts where the Storage namespace is unavailable. When both answer for
the same hardware, two rows that disagree is the useful signal; collapsing
them would hide exactly the case worth looking at.
"""
if not isinstance(drives, list):
return []
rows = []
for drive in drives:
if not isinstance(drive, dict):
continue
predictfailure = drive.get('predictfailure')
if predictfailure is None:
verdict = 'Unknown'
elif predictfailure:
verdict = 'Failure predicted'
else:
verdict = 'OK'
rows.append({
'deviceid': drive.get('deviceid'),
'model': drive.get('model') or '',
'mediatype': drive.get('mediatype') or '',
'bustype': drive.get('bustype') or '',
'healthstatus': drive.get('healthstatus') or 'Unknown',
'verdict': verdict,
'source': drive.get('source') or '',
})
# Failure first, then unknown, then OK. Unknown outranks OK because a drive
# that stopped answering is a weaker claim of health than one that said OK.
# An unrecognised verdict sorts as unknown rather than last, so a future
# verdict string cannot bury itself under the healthy drives.
order = {'Failure predicted': 0, 'Unknown': 1, 'OK': 2}
rows.sort(key=lambda row: (order.get(row['verdict'], 1),
row['deviceid'] or ''))
return rows
@endpoints_bp.route('/asset/<int:asset_id>/diskhealth', methods=['GET'])
@require_permission('endpoints.view')
def panel_diskhealth(asset_id: int):
"""Physical drive health rows.
No longer backs a panel of its own - the Storage card renders it as a
section. Kept as a route because it is a stable read for callers outside
the panel renderer and removing it would break them for a UI-only change.
Rows are ordered so anything predicting failure sorts first: a failing
drive must not be below the fold on a machine with several disks.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
drives = _state_json(state, 'diskhealthjson') if state else None
return success_response(_diskhealth_rows(drives))
@endpoints_bp.route('/asset/<int:asset_id>/listeners', methods=['GET'])
@require_permission('endpoints.view')
def panel_listeners(asset_id: int):
"""Table panel: TCP sockets in LISTEN, with the owning process.
The listening set is the box's exposed surface, so it is sorted by port
to make an unexpected one easy to spot. The full connection table is a
deliberately on-demand command, not standard telemetry.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
ports = _state_json(state, 'portsjson') if state else None
if not isinstance(ports, list):
return success_response([])
rows = []
for entry in ports:
if not isinstance(entry, dict):
continue
# Sample-data rows predate the agent listener collector and carry a
# flat port/process shape; read both rather than showing them blank.
port = entry.get('localport', entry.get('port'))
rows.append({
'localport': port,
'localaddress': entry.get('localaddress'),
'protocol': (entry.get('protocol') or 'tcp').upper(),
'processname': entry.get('processname') or entry.get('process'),
'processid': entry.get('processid'),
})
rows.sort(key=lambda r: (r['localport'] is None, r['localport']))
return success_response(rows)
def _drive_letter(value):
"""Normalise a drive to its bare letter so the two sources can be paired.
net use reports 'Z:' and Get-SmbMapping reports 'Z:' or occasionally a
trailing separator, so compare on the letter alone.
"""
text = (value or '').strip().rstrip('\\/')
return text.upper() or None
# Worst-first. The panel shows the worse of the two sources for a drive, so a
# share that one source calls OK and the other calls Unavailable is reported as
# Unavailable: a drive that is broken in either view is broken for the operator.
#
# WARNING - ranks must stay DISTINCT across statuses that can genuinely differ.
# _worse_drive_status resolves by min(), which breaks a tie by argument order,
# so two statuses sharing a rank make the verdict depend on which SOURCE
# happened to report which - the same drive reads healthy or broken based on
# enumeration order. Deliberate synonyms are fine (disconnected/unavailable,
# ok/connected) because either answer is the same answer. Adding a NEW status at
# an existing rank is not. test_which_source_reported_a_status_does_not_change
# _the_verdict in tests/test_plugins/test_endpoints_mapped_drives.py asserts
# both directions of each pair and is what catches this; the single-direction
# tests stay green through it.
_DRIVE_STATUS_RANK = {
'unavailable': 0,
'disconnected': 0,
'paused': 1,
'reconnecting': 2,
'ok': 3,
'connected': 3,
}
def _worse_drive_status(left, right):
"""Return whichever status is worse, preferring a known one over None.
An unrecognised status ranks as Reconnecting, deliberately below OK: a
status we cannot interpret must not be assumed healthy.
"""
candidates = [s for s in (left, right) if s]
if not candidates:
return None
unknown_rank = _DRIVE_STATUS_RANK['reconnecting']
return min(
candidates,
key=lambda s: _DRIVE_STATUS_RANK.get(str(s).strip().lower(), unknown_rank)
)
def _mapped_drive_rows(context):
"""Merge the two mapped-drive views into one structured row per letter.
Windows exposes mapped drives through Win32_NetworkConnection (the 'net use'
table) and through Get-SmbMapping, and they do not always agree - a share can
be Unavailable in one and OK or absent in the other. The probe collects both
without reconciling them, so the disagreement survives to here.
A drive present in only one source is still a drive: it is reported with the
status that source gave it, rather than dropped for lack of a second opinion.
WARNING - three behaviours here are load-bearing and easy to break while
"simplifying". They are covered by tests/test_plugins/test_endpoints_mapped
_drives.py; if you touch this, read those first:
1. Worse-status-wins. Never report the healthier of the two sources. A
drive that either source calls Unavailable IS unavailable to the
operator, and rendering it OK sends someone to a share that is down.
2. Single-source drives are kept, not dropped. Get-SmbMapping is absent on
some builds and session types; requiring agreement would silently hide
every mapped drive on those machines.
3. Unknown statuses rank as Reconnecting, not as OK. An unrecognised
string must never be optimistically treated as healthy.
Changing any of these makes a broken drive look fine, which is a silent
failure - the card stays green and nobody is told.
"""
drives = {}
netuse = context.get('netuse')
if isinstance(netuse, list):
for entry in netuse:
if not isinstance(entry, dict):
continue
letter = _drive_letter(entry.get('localname') or entry.get('local'))
if not letter:
continue
drives[letter] = {
'remote': entry.get('remotename') or entry.get('remote') or '',
'status': entry.get('status'),
}
smbmapping = context.get('smbmapping')
if isinstance(smbmapping, list):
for entry in smbmapping:
if not isinstance(entry, dict):
continue
letter = _drive_letter(entry.get('localpath'))
if not letter:
continue
existing = drives.get(letter)
if existing:
existing['status'] = _worse_drive_status(
existing.get('status'), entry.get('status'))
existing['remote'] = existing['remote'] or entry.get('remotepath') or ''
else:
drives[letter] = {
'remote': entry.get('remotepath') or '',
'status': entry.get('status'),
}
rows = []
for letter in sorted(drives):
drive = drives[letter]
# A drive neither source gave a status for is Unknown, never blank. An
# unannotated row reads as healthy, so a failed check would render as a
# working drive - the one outcome this card must never produce.
status = (drive.get('status') or '').strip() or 'Unknown'
healthy = status.lower() in ('ok', 'connected')
rows.append({
'drive': letter,
'remote': (drive.get('remote') or '').strip(),
'statuslabel': status,
# Only unhealthy drives are coloured. Green on every working drive
# makes the broken one just another badge in a row of badges; the
# card's job is to make the exception findable at a glance.
'statecolor': 'green' if healthy else 'red',
})
return rows
def _mapped_drives(context):
"""The mapped drives as one-line labels, for the user context card.
Built on _mapped_drive_rows so the summary line and the card can never
disagree about a drive's status.
"""
labels = []
for row in _mapped_drive_rows(context):
label = '{} {}'.format(row['drive'], row['remote']).strip()
# Only the problem states are annotated. Tagging every healthy drive
# with "(OK)" would bury the one that is not.
if row['statecolor'] != 'green':
label = '{} ({})'.format(label, row['statuslabel'])
labels.append(label)
return labels
def _mapped_drives_unhealthy_first(rows):
"""Order drive rows so anything not healthy sorts first.
A user with a dozen mappings should not have to hunt for the broken one.
Sorting only on health keeps it stable: drive-letter order survives within
each group, so the list does not reshuffle between refreshes.
"""
return sorted(rows, key=lambda row: row['statecolor'] == 'green')
@endpoints_bp.route('/asset/<int:asset_id>/mappeddrives', methods=['GET'])
@require_permission('endpoints.view')
def panel_mappeddrives(asset_id: int):
"""List panel: mapped network drives with a health badge each.
Session-scoped: mapped drives do not exist in the agent's session 0, so
these come from the interactive user probe. Nothing reported when nobody
is signed in, which is a valid state and not a failure.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
context = _state_json(state, 'usercontextjson') if state else None
if not isinstance(context, dict) or not context:
return success_response([])
return success_response(_mapped_drives_unhealthy_first(_mapped_drive_rows(context)))
@endpoints_bp.route('/asset/<int:asset_id>/usercontext', methods=['GET'])
@require_permission('endpoints.view')
def panel_usercontext(asset_id: int):
"""Keyvalue panel: session-scoped data from the interactive user probe.
Mapped drives and user-owned listeners do not exist in the agent's
session 0, so they are collected by a probe run in the logged-in user's
session. Nothing is reported when no one is signed in, which is a valid
state and not a failure.
Mapped drives arrive from two sources that can disagree; the worse of the
two statuses is shown so a half-broken share is never rendered as healthy.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state:
return success_response({'fields': []})
context = _state_json(state, 'usercontextjson')
if not isinstance(context, dict) or not context:
return success_response({'fields': []})
drives = _mapped_drives(context)
listeners = context.get('userlisteners')
listenercount = len(listeners) if isinstance(listeners, list) else None
fields = [
{'label': 'Probed user', 'value': context.get('username')},
{'label': 'Collected', 'value': context.get('collectedat')},
{'label': 'Idle', 'value': _format_uptime(context.get('idleseconds'))},
{'label': 'Mapped drives',
'value': ', '.join(drives) if drives else None, 'mono': True},
{'label': 'User listeners', 'value': listenercount},
]
fields = [f for f in fields if f['value'] not in (None, '')]
return success_response({'fields': fields})
@endpoints_bp.route('/asset/<int:asset_id>/telemetrymeta', methods=['GET'])
@require_permission('endpoints.view')
def panel_telemetry_meta(asset_id: int):
@@ -723,10 +1348,18 @@ def panel_activity(asset_id: int):
rows = []
for event in events:
# The provider name is what distinguishes two events sharing an ID,
# and the message body is the line an operator actually reads. Both
# are already stored; the feed just was not showing them.
source = event.eventsource or event.eventchannel
detail = 'Windows event {}'.format(event.eventid)
if source:
detail = '{} - {}'.format(detail, source)
rows.append({
'title': event_title(event.eventid),
'severity': event_severity(event.eventid),
'detail': 'Windows event {}'.format(event.eventid),
'detail': detail,
'body': _event_body(event),
'eventtime': event.eventtime,
})
@@ -742,7 +1375,9 @@ def panel_activity(asset_id: int):
'title': '{}{}'.format(label, target),
# A failed command is the one an operator needs to notice.
'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info',
'detail': '{}{}'.format((command.status or 'queued').title(), who),
'detail': '{}{}{}'.format('Command - ', (command.status or 'queued').title(), who),
# A failed command's reason is the whole point of noticing it.
'body': (command.resultmessage or '').strip() or None,
'eventtime': command.createdat,
})
@@ -768,10 +1403,37 @@ def panel_activity(asset_id: int):
'severity': r['severity'],
'severitycolor': colours.get(r['severity'], 'gray'),
'detail': r['detail'],
'body': r['body'],
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
} for r in rows[:15]])
def _link_label(speedbps, fullduplex):
"""Render NIC link speed and duplex as one cell.
Null speed means the agent could not read link state OR the adapter is
disconnected - verified on a live box that a disconnected NIC returns empty
values. Either way we do not know, so it reads '-' rather than 0 Mb.
Half duplex is called out explicitly because that is the actionable case: a
NIC that negotiated half duplex on a switch port is a cable or port fault
that otherwise presents as "the machine is slow sometimes". Full duplex is
the normal case and gets no annotation, so the only thing standing out in
the column is the thing worth looking at.
"""
if not speedbps:
return '-'
if speedbps >= 1000000000:
speed = '{:g} Gb'.format(speedbps / 1000000000)
else:
speed = '{:g} Mb'.format(speedbps / 1000000)
if fullduplex is False:
return '{} half duplex'.format(speed)
return speed
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
@require_permission('endpoints.view')
def panel_interfaces(asset_id: int):
@@ -809,6 +1471,99 @@ def panel_interfaces(asset_id: int):
# A gatewayless NIC with an address is the machine link; the gateway
# NIC is the corporate LAN.
'role': 'Machine link' if (ipv4 and not gateway) else 'LAN',
'link': _link_label(entry.get('linkspeedbps'),
entry.get('fullduplex')),
})
return success_response(rows)
# Columns for the two cards carved out of the old hand-written Network card on
# the Details tab. That card listed current addresses with a Primary badge and
# nothing else, which undersold what the rows actually hold.
_IPHISTORY_COLUMNS = [
{'key': 'ipaddress', 'label': 'IP address'},
{'key': 'macaddress', 'label': 'MAC'},
{'key': 'status', 'label': 'Status'},
{'key': 'firstseen', 'label': 'First seen'},
{'key': 'lastchanged', 'label': 'Last changed'},
]
def _isostamp(value):
"""ISO-8601 with a Z suffix, matching every other timestamp this file emits.
Formatting is left to the client so it can render in the viewer's locale
and timezone; returning a preformatted string here would pin every reader
to the server's.
"""
return value.isoformat() + 'Z' if value else None
def _communication_rows(asset_id: int):
"""Every Communication row for an asset, newest change first.
These rows are an ACCIDENTAL history rather than a designed one.
_record_interfaces in the computers plugin matches an incoming NIC on MAC
then on IP and inserts when neither matches, and it never deletes - so an
address the machine has not held for a year is still sitting here next to
the current one. That is genuinely useful (it answers "what was this box on
before?"), so it is surfaced as history rather than cleaned away.
"""
from shopdb.api import Communication
# MySQL has no NULLS LAST, so sort on an explicit "is null" key first.
# Rows that never got a modifieddate sink to the bottom on every dialect.
return (Communication.query
.filter_by(assetid=asset_id)
.order_by(Communication.modifieddate.is_(None),
Communication.modifieddate.desc(),
Communication.communicationid.desc())
.all())
def _iphistory_status(comm):
"""What this row IS, in the vocabulary somebody troubleshooting would use.
Primary is the address the collector last picked as this box's main one.
APIPA is called out by name because a 169.254.x.x address is not an address
the machine was given - it is what Windows assigns when DHCP failed, so its
presence in the list is a record of a past network fault, not a past
subnet.
"""
address = (comm.ipaddress or '').strip()
if address.startswith('169.254.'):
return 'APIPA (no DHCP)'
if comm.isprimary:
return 'Primary'
if comm.ismachinenetwork:
return 'Machine network'
return 'Previous'
@endpoints_bp.route('/asset/<int:asset_id>/iphistory', methods=['GET'])
@require_permission('endpoints.view')
def panel_iphistory(asset_id: int):
"""Table panel: every address this asset has held, newest change first.
Replaces the hand-written Network card that used to sit on the Details tab
and show only the current addresses. Dates come from the row's own
lifecycle columns, so they are labelled for what they are: 'Last changed'
is when a field on the row last moved, NOT when the address was last seen.
An address the machine still holds and reports unchanged does not update
modifieddate, so calling that column 'Last seen' would be a lie the data
cannot support.
"""
rows = []
for comm in _communication_rows(asset_id):
address = (comm.ipaddress or '').strip()
if not address:
continue
rows.append({
'ipaddress': address,
'macaddress': (comm.macaddress or '').strip() or '-',
'status': _iphistory_status(comm),
'firstseen': _isostamp(comm.createddate),
'lastchanged': _isostamp(comm.modifieddate),
})
return success_response(rows)

View File

@@ -136,6 +136,12 @@ _BASE_APPSETTINGS = {
'UDC.exe': 'C:\\Program Files\\UDC\\UDC.exe',
'agent.exe': 'C:\\ProgramData\\udc\\mtconnect_udc\\agent\\run.bat',
},
# Approved scheduled task paths for scheduledtask_run. Shipped empty, which
# means the agent falls back to ScheduledTaskPolicy.DefaultApprovedTaskPaths
# (the Corporate Tools set). Listing entries here REPLACES those defaults, so
# a site can narrow or widen the set without an agent rebuild. Anything not
# matched fails closed.
'approvedtaskpaths': [],
}
_README = """ShopDB endpoint agent (site build)
@@ -217,7 +223,19 @@ param(
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
[string] $CertFileName = {certfile},
[switch] $NoTray
[switch] $NoTray,
# Scheduled task that runs the user-context probe INSIDE the operator's own
# logon session. Mapped drives are per logon token, so this is the only
# reliable way to collect them; see the registration block below.
[string] $UserContextTaskName = "ShopDB user-context probe",
[int] $UserContextTaskMinutes = 30,
# The account the probe task runs as. Must match the account whose profile
# the agent reads the spool from (FileLoggerProvider.ShopFloorUserName), or
# the probe writes session data the service never looks at.
[string] $UserContextTaskUser = "ShopFloor",
[switch] $NoUserContextTask
)
$ErrorActionPreference = "Stop"
@@ -362,10 +380,175 @@ if (-not $NoTray) {{
}}
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)" }}
# Deliberately NOT launched here. This installer runs elevated, so
# Start-Process would hand the tray an ADMIN token, and drive letters
# are per logon token: the probe would then enumerate the installing
# admin's drive table, not the operator's. Measured on a live endpoint,
# the same two shares reported Unavailable from the elevated shell and
# OK from the operator's own session, in both Win32_NetworkConnection
# and Get-SmbMapping. Launching via explorer.exe does not fix this
# either - it de-elevates but does NOT change account.
#
# The Run key above is the correct autostart: it fires at a genuine
# logon, in the operator's own session. Collection no longer depends on
# the tray at all - the scheduled task below owns it.
Write-Host " tray will start at next logon (not launched from this elevated install)."
}}
}}
# 2b. Register the user-context probe as a scheduled task owned by the SHOPFLOOR
# USER. This is what makes mapped drives, user listeners and idle time
# collectable: it is the only mechanism that starts a process in the operator's
# own session with the operator's own token.
#
# RunLevel Limited is load-bearing, not a hardening nicety - the unelevated
# token is precisely what sees the operator's mapped drives.
#
# UserId, NOT GroupId. A group-principal task is registered against the group
# rather than against any one account, and a member of that group cannot invoke
# it on demand: Start-ScheduledTask from the ShopFloor session returns access
# denied, so the task only ever ran on its own trigger. That is exactly the bug
# where "Refresh Status" appeared to do nothing. Registering to the specific
# account makes the task startable by that account, which is what both the tray
# and any manual retry depend on. The group form is kept only as a fallback for
# a host where the account cannot be resolved - it still collects on a timer,
# it just cannot be kicked on demand.
$taskState = "skipped"
if (-not $NoUserContextTask) {{
Write-Host " registering user-context probe task ..."
$taskAgentExe = Join-Path $InstallDir "shopdbagent.exe"
$taskRegistered = $false
$taskDetail = "no principal accepted"
try {{
$taskAction = New-ScheduledTaskAction -Execute $taskAgentExe -Argument "--collect-usercontext"
# AtLogOn covers the normal case. The repeating trigger matters just as
# much: AtLogOn does NOT fire for a session that is already signed in,
# which is every install onto a machine someone is currently using.
$taskTriggers = @(New-ScheduledTaskTrigger -AtLogOn)
if ($UserContextTaskMinutes -gt 0) {{
$taskTriggers += New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes $UserContextTaskMinutes)
}}
$taskSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
# Try the machine-qualified account first, then the bare name. Interactive
# LogonType so the task joins the existing interactive session instead of
# creating a separate one with its own (empty) drive table.
$taskCandidates = @(
@{{ Kind = "user"; Id = "$env:COMPUTERNAME\\$UserContextTaskUser" }},
@{{ Kind = "user"; Id = $UserContextTaskUser }},
@{{ Kind = "group"; Id = "S-1-5-32-545" }},
@{{ Kind = "group"; Id = "BUILTIN\\Users" }}
)
foreach ($candidate in $taskCandidates) {{
try {{
if ($candidate.Kind -eq "user") {{
$taskPrincipal = New-ScheduledTaskPrincipal -UserId $candidate.Id -LogonType Interactive -RunLevel Limited -ErrorAction Stop
}}
else {{
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $candidate.Id -RunLevel Limited -ErrorAction Stop
}}
Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null
$taskRegistered = $true
$taskDetail = "$($candidate.Kind):$($candidate.Id)"
break
}}
catch {{ $taskDetail = $_.Exception.Message }}
}}
}}
catch {{ $taskDetail = "could not build the task: $($_.Exception.Message)" }}
if ($taskRegistered) {{
# Read the task back rather than trusting Register-ScheduledTask to have
# returned cleanly: policy or a management agent can rewrite or drop a
# task after registration, and a task that exists but runs elevated
# looks healthy in the scheduler UI while producing wrong data.
$verified = Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue
if (-not $verified) {{
$taskState = "UNVERIFIED:task not found after registration"
Write-Warning " user-context task registered but cannot be read back."
}}
elseif ([string] $verified.Principal.RunLevel -eq "Highest") {{
$taskState = "UNVERIFIED:elevated"
Write-Warning " user-context task runs ELEVATED; mapped drives would be collected from the wrong token."
}}
elseif ($verified.Principal.GroupId) {{
# Registered, will collect on its triggers, but cannot be started on
# demand by the operator - so "Refresh Status" will fall back to the
# timer. Surfaced rather than reported as ok, because the symptom
# (stale session data that never responds to a refresh) is otherwise
# indistinguishable from a broken probe.
$taskState = "DEGRADED:group principal $($verified.Principal.GroupId)"
Write-Warning " user-context task fell back to a GROUP principal; on-demand refresh will not work, only the $UserContextTaskMinutes-minute interval."
}}
else {{
$taskState = "ok:$($verified.Principal.UserId)/$($verified.Principal.RunLevel)"
Write-Host " user-context task registered and verified: $taskState"
}}
# AtLogOn will not fire for an already-signed-in session, so kick it once
# rather than leaving a fresh install blind until the repeat interval.
try {{
Start-ScheduledTask -TaskName $UserContextTaskName -ErrorAction Stop
Write-Host " user-context task started once for the current session."
}}
catch {{ Write-Warning " could not start the task now: $($_.Exception.Message); it runs at next logon or interval." }}
}}
else {{
# Not fatal: the service still checks in, only the session-scoped fields
# are missing, and the ledger records why.
$taskState = "FAILED:$taskDetail"
Write-Warning " could not register the user-context task: $taskDetail"
Write-Warning " mapped drives and idle time will not be collected."
}}
}}
# 2c. Start the tray in the operator's session.
#
# The Run key registered above only fires at LOGON, so installing onto a machine
# someone is already signed in to leaves no tray until they sign out and back in.
# The installer cannot start it directly - it is elevated, and the tray must run
# unelevated in the user's own session - so we borrow the same mechanism that
# solves this for the probe: a transient scheduled task with a Limited group
# principal, run once and then deleted.
#
# Registered without a trigger; it exists only to be invoked immediately. The
# tray is single-instance, so if one is somehow already running this is a no-op.
if (-not $NoTray -and (Test-Path (Join-Path $InstallDir $TrayExeName))) {{
$launchTaskName = "ShopDB tray first launch"
try {{
$launchExe = Join-Path $InstallDir $TrayExeName
$launchAction = New-ScheduledTaskAction -Execute $launchExe -WorkingDirectory $InstallDir
$launchSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$launched = $false
foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{
try {{
$launchPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop
Register-ScheduledTask -TaskName $launchTaskName -Action $launchAction -Principal $launchPrincipal -Settings $launchSettings -Force -ErrorAction Stop | Out-Null
Start-ScheduledTask -TaskName $launchTaskName -ErrorAction Stop
$launched = $true
break
}}
catch {{ }}
}}
# Give the task a moment to spawn the tray before removing it: deleting a
# task that is still starting can kill the process it just launched.
if ($launched) {{
Start-Sleep -Seconds 3
Write-Host " tray started in the operator's session."
}}
else {{
Write-Warning " could not start the tray now; it will start at next logon via the Run key."
}}
# Transient by design: this is a one-shot launcher, not an autostart.
# The Run key owns autostart, and leaving this behind would mean two
# things racing to start the same single-instance tray at every logon.
try {{ Unregister-ScheduledTask -TaskName $launchTaskName -Confirm:$false -ErrorAction Stop }}
catch {{ Write-Warning " could not remove the transient launch task '$launchTaskName'; remove it manually." }}
}}
catch {{
Write-Warning " could not start the tray now: $($_.Exception.Message); it will start at next logon."
}}
}}
@@ -397,15 +580,22 @@ 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 {{ }}
# Report only; never relaunch from here. This block used to Start-Process the
# tray when it was not found running, which would hand it this installer's
# ADMIN token and reintroduce the wrong-drive-table bug. The launch task
# above is the only place the tray is started during install, and it starts
# it correctly - unelevated, in the operator's session.
$trayFound = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {{
if (Get-Process -Name $trayBase2 -ErrorAction SilentlyContinue) {{ $trayFound = $true; break }}
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." }}
if ($trayFound) {{
Write-Host " tray is running." -ForegroundColor Green
}}
else {{
Write-Warning "tray did not confirm running; it will start at next logon via the Run key."
}}
}}
# 5. Preserve the install artifacts (minus the secret-bearing install.ps1) under
@@ -432,6 +622,29 @@ if (Test-Path $installSelf) {{
}}
Write-Host "[6/6] Done." -ForegroundColor Green
# Append one line per install, including reinstalls and repeat installs of the
# same version. The user-context task state is recorded because it is the single
# point of failure for session-scoped collection: if mapped drives go missing
# later, this answers "was the task ever registered on this host, and as what"
# without needing the endpoint back. The ledger is a record, not a gate - a
# failure to append must never fail an otherwise-good install.
try {{
$ledgerDir = Join-Path $env:ProgramData "ShopDB\\Logs"
New-Item -ItemType Directory -Force -Path $ledgerDir | Out-Null
$ledgerPath = Join-Path $ledgerDir "shopdbagent-installs.log"
$ledgerAgentVersion = "unknown"
if (Test-Path $installedAgent) {{ $ledgerAgentVersion = (Get-Item $installedAgent).VersionInfo.FileVersion }}
$ledgerTrayVersion = "not-installed"
$ledgerTrayPath = Join-Path $InstallDir $TrayExeName
if (Test-Path $ledgerTrayPath) {{ $ledgerTrayVersion = (Get-Item $ledgerTrayPath).VersionInfo.FileVersion }}
$ledgerLine = "{{0}} host={{1}} installer={{2}} service={{3}} tray={{4}} dir={{5}} usercontexttask={{6}}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"), $env:COMPUTERNAME, $env:USERNAME, $ledgerAgentVersion, $ledgerTrayVersion, $InstallDir, $taskState
Add-Content -Path $ledgerPath -Value $ledgerLine -Encoding UTF8
Write-Host "Install ledger: $ledgerPath"
Write-Host " $ledgerLine" -ForegroundColor DarkGray
}}
catch {{ Write-Warning "could not append to the install ledger: $($_.Exception.Message)" }}
if (Test-Path $uninstaller) {{
Write-Host "Uninstaller: $uninstaller" -ForegroundColor Cyan
Write-Host " To remove the agent, run that script from an elevated PowerShell."

View File

@@ -77,12 +77,36 @@ COMMAND_ALLOWLIST = {
'label': 'Collect now',
'description': 'Force the endpoint agent to report fresh telemetry off-cadence.',
},
'network_connections': {
'label': 'Show network connections',
'description': 'Return the full TCP connection table with owning processes. '
'Read-only diagnostic; the standard report carries only '
'listening ports because established connections churn.',
},
'scheduledtask_run': {
'label': 'Run scheduled task',
'description': 'Start an already-registered Windows scheduled task on '
'the endpoint by its task path (for example '
'\\ReportIP). The agent does not create, modify, or '
'define tasks - it only starts one that already exists, '
'and only when the path clears the agent-side approved '
'task allowlist. The task runs in whatever context its '
'own registered definition specifies, not the agent\'s.',
'targetkind': 'scheduledtask',
},
'run_script': {
'label': 'Run script',
'description': 'Run a ShopDB-hosted script on the endpoint. The agent '
'fetches the script body by digest using its collector '
'credential and runs it in the script\'s configured '
'context (system or the logged-on user).',
'context (system or the logged-on user). CAUTION: a '
'script sent this way runs as SYSTEM unless its '
'descriptor pins the user context, so this is arbitrary '
'code execution on the endpoint by design. Grant '
'endpoints.deploy only to the most trusted operators, '
'and gate the UI behind an explicit confirm the way '
'system_restart is gated (see confirmRestart in '
'PCDetail.vue).',
'targetkind': 'script',
},
'update_app': {
@@ -96,11 +120,76 @@ COMMAND_ALLOWLIST = {
}
# Dell Command | Update: TWO utilities, deliberately two separate actions.
# A scan reports what is available and changes nothing; an update installs and
# can reboot. Collapsing them into one parameterized action would make the
# destructive one reachable by editing a field, so they stay distinct and the
# UI can gate them differently.
#
# Both are driven through scheduledtask_run against ShopDB-owned task
# definitions (\ShopDB-DellCommandScan and \ShopDB-DellCommandUpdate, already in
# ScheduledTaskPolicy's shipped defaults) rather than a bespoke verb. The task
# definition - not the agent - pins the dcu-cli.exe path, its arguments, and its
# output path, which is why no new agent code is needed to run them.
#
# OUTPUT PATH CONVENTION. A ShopDB-initiated scan MUST write its report under
# %ProgramData%\ShopDB\EndpointAgent\ (the same SYSTEM-owned root as the agent
# logs), NOT to Dell's default location. Corporate-managed DCU tasks write to
# the default; keeping ShopDB's output separate is the only thing that makes
# "did ShopDB ask for this scan, or did corporate" answerable when the results
# are collected. The path lives in the task definition and is generated
# server-side, so it can change without touching the agent.
#
# STILL TO DO - the results collector. Firing DCU is the easy half. schtasks
# /run returns as soon as the task STARTS, so a successful scheduledtask_run
# means "scan started", never "scan finished" - the command result cannot carry
# findings. Reporting available updates back into ShopDB needs a collector that
# reads the report from the path above and folds it into the telemetry payload.
# That is a separate change from this one; do not infer from a green command
# result that the data path exists.
# Reserved seam - one-click mapped-drive remediation. NOT implemented; this is a
# placement note so the next person adds it here rather than inventing a new
# transport. When it lands it will re-establish a Disconnected mapping surfaced
# by the user-context card:
#
# 'remap_drive': {
# 'label': 'Reconnect mapped drive',
# 'description': 'Re-establish a disconnected network drive mapping.',
# 'targetkind': 'drive',
# },
#
# It MUST execute in the interactive USER session. A SYSTEM-context remap creates
# a mapping in SYSTEM's own drive table that the operator cannot see, so the card
# would keep reporting the drive as broken while the "fix" silently succeeded
# somewhere nobody is looking. An ELEVATED remap fails the same way, because UAC
# token separation gives the elevated context a different drive table - the same
# reason the probe itself runs unelevated from the tray.
#
# CommandExecutor.RunScriptInUserSession is the existing path for this.
# Actions that demand a permission stricter than the base endpoints.command.
# The enqueue route requires the caller to ALSO hold the mapped permission, so a
# destructive action (a remote reboot) is a heavier grant than a routine toggle.
# NOTE FOR THE ROLES/PERMISSIONS WORK: scheduledtask_run is deliberately mapped
# onto the EXISTING endpoints.deploy grant rather than introducing a new one, so
# the wire is pre-built and needs no migration. Be aware this WIDENS what
# endpoints.deploy means: it previously covered run_script + update_app, and now
# also covers starting approved scheduled tasks. If that bundle is too coarse,
# the clean split is a separate endpoints.corptools permission mapped here - no
# other code needs to change, because the enqueue route reads this table.
#
# Suggested operator-facing wording for the combined grant:
# "Caution: Operator can run scheduled tasks, update applications, and affect
# system-level changes to the endpoint."
# That wording is accurate for scheduledtask_run and update_app. It UNDERSTATES
# run_script, which is arbitrary SYSTEM code execution - if run_script stays in
# this bundle, say so plainly in the role description.
ELEVATED_ACTION_PERMISSIONS = {
'system_restart': 'endpoints.restart',
'scheduledtask_run': 'endpoints.deploy',
'run_script': 'endpoints.deploy',
'update_app': 'endpoints.deploy',
}
@@ -215,6 +304,24 @@ def monitor_id_from_action(action):
return None
# Actions gated by ROLE ONLY, never by per-endpoint Configuration Policy.
#
# These are the Corporate Tools actions. They start already-registered,
# agent-allowlisted tasks and are not a per-machine configuration concern, so a
# policy that never mentions them must not remove them. Policy exists to shape
# what a given PC TYPE exposes (which monitors, which service primitives); it is
# not the authority on whether an admin may run a corporate tool.
#
# Without this exemption the intersection at the end of ALLOWED_ACTIONS_FOR
# silently drops these actions on any endpoint whose effective policy is
# non-empty, producing "Action not permitted by policy for this endpoint" even
# for a caller holding endpoints.deploy. The real gate for these stays
# ELEVATED_ACTION_PERMISSIONS (role), enforced in the enqueue route.
POLICY_EXEMPT_ACTIONS = frozenset({
'scheduledtask_run',
})
def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
"""The action names an endpoint may run.
@@ -223,8 +330,9 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
result is narrowed to the actions the endpoint's effective policy permits -
this is the allowlist-gating half of the policy design. With the feature off
(or no assetid), it returns the full base allowlist, so nothing changes until
a site opts in. Policy resolution is imported lazily to keep this module free
of a hard dependency on the computers plugin.
a site opts in. Actions in POLICY_EXEMPT_ACTIONS are never narrowed away;
they are gated by role alone. Policy resolution is imported lazily to keep
this module free of a hard dependency on the computers plugin.
"""
base = set(COMMAND_ALLOWLIST.keys())
if assetid is None:
@@ -247,6 +355,9 @@ def ALLOWED_ACTIONS_FOR(pctype=None, assetid=None):
# "not permitted by policy". Expand each monitor:<id> to its kind's control
# actions before intersecting.
allowed = allowed | _monitor_control_actions(allowed)
# Role-gated actions are added back unconditionally: policy narrows the
# per-endpoint surface, but it does not own the corporate tools.
allowed = allowed | POLICY_EXEMPT_ACTIONS
# Never widen past the code-level allowlist: policy can only gate within it.
return base & allowed

View File

@@ -24,7 +24,14 @@ EVENT_CATALOG = {
55: ('File system corruption', 'critical', 'System'),
7011: ('Service timeout', 'critical', 'System'),
10016: ('DCOM permission error', 'warning', 'System'),
# Windows Error Reporting trio on the Application channel. 1000 is the
# faulting-application record (app name, version, faulting module, exception
# code) and is the one that says what actually broke; 1001 is the WER bucket
# record Windows writes alongside it; 1002 is a hang rather than a crash, so
# it is a warning - the app is unresponsive, not gone.
1000: ('Application error', 'critical', 'Application'),
1001: ('Application crash', 'critical', 'Application'),
1002: ('Application hang', 'warning', 'Application'),
}
# The IDs a tech should see surfaced fleet-wide on the dashboard stability card.

View File

@@ -319,9 +319,9 @@
<div
v-for="binary in binaries"
:key="binary.key"
class="binary-block"
class="card binary-block"
>
<h4>{{ binary.label }}</h4>
<h4 class="binary-title">{{ binary.label }}</h4>
<p class="field-hint">
Upload {{ binary.name }} updated EXE. Uploading here will update the
binaries available for your "Build Site Agent" install package.
@@ -355,7 +355,7 @@
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.version || !binary.file"
:disabled="binary.busy || binary.detecting || !binary.file"
@click="uploadBinary(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload current release' }}
@@ -370,8 +370,9 @@
Upload legacy build for rollback
</label>
<p class="field-warning">
WARNING: confirm agent version before uploading from the EXE
properties and enter the version number.
The version is read from the EXE itself. Leave this blank to use the
binary's own file version, or enter a dotted-numeric override only
when the build carries no readable version.
</p>
<div class="binary-upload">
<input
@@ -389,7 +390,7 @@
<button
class="btn btn-secondary"
type="button"
:disabled="binary.busy || !binary.legacyversion || !binary.legacyfile"
:disabled="binary.busy || !binary.legacyfile"
@click="uploadLegacy(binary.key)"
>
{{ binary.busy ? 'Uploading...' : 'Upload legacy build' }}
@@ -756,7 +757,8 @@ async function detectVersion(binkey, file) {
const form = new FormData()
form.append('file', file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}/detect`, form)
`/endpoints/siteagent/binaries/${binkey}/detect`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return response.data.data?.version || ''
}
@@ -789,7 +791,7 @@ function onLegacyFileChange(binkey, event) {
async function uploadBinary(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.file || !binary.version) return
if (!binary || !binary.file) return
binary.busy = true
binary.message = ''
binary.error = ''
@@ -798,7 +800,8 @@ async function uploadBinary(binkey) {
form.append('version', binary.version)
form.append('file', binary.file)
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
`/endpoints/siteagent/binaries/${binkey}`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.version = ''
@@ -817,7 +820,7 @@ async function uploadBinary(binkey) {
async function uploadLegacy(binkey) {
const binary = binaryFor(binkey)
if (!binary || !binary.legacyfile || !binary.legacyversion) return
if (!binary || !binary.legacyfile) return
binary.busy = true
binary.message = ''
binary.error = ''
@@ -827,7 +830,8 @@ async function uploadLegacy(binkey) {
form.append('file', binary.legacyfile)
form.append('makecurrent', 'false')
const response = await api.post(
`/endpoints/siteagent/binaries/${binkey}`, form)
`/endpoints/siteagent/binaries/${binkey}`, form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
const data = response.data.data || {}
applyBinaryStatus(binkey, data.current, data.versions)
binary.legacyversion = ''
@@ -906,10 +910,18 @@ function editConfiguration() {
.settings-section > h3 { margin-bottom: 0.25rem; }
.info-list { margin: 0.25rem 0 0; padding-left: 1.25rem; }
.info-list li { margin-bottom: 0.5rem; }
/* Each agent is its own nested card inside the collapsible, so the two
binaries read as distinct units instead of one continuous wall of
controls separated by a hairline rule. */
.binary-block {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #333);
margin-top: 1rem;
padding: 1rem;
}
.binary-block:first-of-type { margin-top: 0.75rem; }
.binary-title {
margin: 0 0 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #333);
}
.binary-upload {
display: flex;

View File

@@ -91,6 +91,7 @@
<th>Machine</th>
<th>PC Type</th>
<th>Health</th>
<th>Agent Version</th>
<th>Logged-in User</th>
<th>Uptime</th>
<th>Last Reported</th>
@@ -123,12 +124,13 @@
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
<span v-else>-</span>
</td>
<td>{{ item.agentversion || '-' }}</td>
<td>{{ item.loggedinuser || '-' }}</td>
<td>{{ item.uptimedisplay || '-' }}</td>
<td>{{ formatRelative(item.lastreportedat) }}</td>
</tr>
<tr v-if="endpoints.length === 0">
<td :colspan="cancommand ? 9 : 8" style="text-align: center; color: var(--text-light);">
<td :colspan="cancommand ? 10 : 9" style="text-align: center; color: var(--text-light);">
No endpoints found
</td>
</tr>

View File

@@ -0,0 +1,80 @@
"""Dedup curated Windows events on the Windows event record id.
Adds endpointevents.eventrecordid and a unique index over
(assetid, eventchannel, eventrecordid).
Why: the agent used to re-read the newest window of every curated channel on
each cadence tick, and this ingest appended those rows without a dedup check, so
one real event was stored once per report for as long as it stayed in that
window - up to 96 identical rows a day. The agent now carries a per-channel
watermark keyed on the Windows record id, which is monotonic and unique within a
channel on a machine; this index is the server-side guarantee that survives an
agent reinstall, a restored image, or two overlapping reports, none of which
preserve the agent's watermark.
Nullable on purpose: an agent older than this field reports no record id, and
MySQL permits repeated NULLs inside a unique index, so those rows keep the
previous append-only behavior instead of failing ingest.
Existing rows are left alone. They all predate the column and so carry NULL,
which cannot collide with the new index. Purging the historical duplicates is a
separate, deliberate data cleanup - not something a schema migration should do
irreversibly.
Idempotent: both the column-add and the index-create check the live schema
first, so a db.create_all() site 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 = 'endpoints0012eventrecordid'
down_revision = 'endpoints0011trayversion'
branch_labels = None
depends_on = None
INDEX_NAME = 'idx_endpointevents_assetrecord'
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 _has_index(table_name, index_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(index['name'] == index_name
for index in inspector.get_indexes(table_name))
def upgrade():
if not _has_column('endpointevents', 'eventrecordid'):
op.add_column(
'endpointevents',
sa.Column('eventrecordid', sa.BigInteger(), nullable=True))
if not _has_index('endpointevents', INDEX_NAME):
op.create_index(
INDEX_NAME,
'endpointevents',
['assetid', 'eventchannel', 'eventrecordid'],
unique=True)
def downgrade():
if _has_index('endpointevents', INDEX_NAME):
op.drop_index(INDEX_NAME, table_name='endpointevents')
if _has_column('endpointevents', 'eventrecordid'):
op.drop_column('endpointevents', 'eventrecordid')

View File

@@ -0,0 +1,93 @@
"""Persist the hardware, OS, and session inventory the agent already reports.
Adds to endpointstates: disksjson, biosjson, memoryjson, processorjson,
osinfojson, usercontextjson, domain, and pendingreboot.
Why now: the agent has been sending disks, bios, memory, processor, osinfo and
domain on every report for several versions. The ingest logged them as unknown
payload keys and threw them away, which was the intended staging step - the
shapes are now proven against real endpoint traffic, so they get columns.
usercontext and pendingreboot are new in this same change.
Shape follows the existing servicesjson/portsjson convention: newest snapshot
per endpoint as MEDIUMTEXT, not one row per sample. The agent resends the full
structure every cycle and operators ask "what is in this PC now", not "what was
in it in March". History would be pure write amplification on a fleet-sized
table.
domain and pendingreboot are real columns rather than JSON because they are
single values the fleet view filters, groups, and alerts on; burying a scalar in
a blob makes it unqueryable.
All columns are nullable. Every existing row predates them, and there is no
correct backfill value - a machine whose reboot state has never been reported is
NOT the same as one confirmed to have no reboot pending, and inventing False
would silently create a fleet of "healthy" endpoints.
Idempotent: each column-add checks the live schema first, so a db.create_all()
site 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 = 'endpoints0013inventorycolumns'
down_revision = 'endpoints0012eventrecordid'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
# MEDIUMTEXT. An installed-application or disk list on a busy engineering
# workstation comfortably exceeds the 64KB TEXT ceiling, and a silent truncation
# there would corrupt the JSON rather than merely shorten it.
_SNAPSHOT_COLUMNS = (
'disksjson',
'biosjson',
'memoryjson',
'processorjson',
'osinfojson',
'usercontextjson',
)
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():
for column_name in _SNAPSHOT_COLUMNS:
if not _has_column(TABLE_NAME, column_name):
op.add_column(
TABLE_NAME,
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
if not _has_column(TABLE_NAME, 'domain'):
op.add_column(
TABLE_NAME,
sa.Column('domain', sa.String(length=255), nullable=True))
if not _has_column(TABLE_NAME, 'pendingreboot'):
op.add_column(
TABLE_NAME,
sa.Column('pendingreboot', sa.Boolean(), nullable=True))
def downgrade():
if _has_column(TABLE_NAME, 'pendingreboot'):
op.drop_column(TABLE_NAME, 'pendingreboot')
if _has_column(TABLE_NAME, 'domain'):
op.drop_column(TABLE_NAME, 'domain')
for column_name in reversed(_SNAPSHOT_COLUMNS):
if _has_column(TABLE_NAME, column_name):
op.drop_column(TABLE_NAME, column_name)

View File

@@ -0,0 +1,56 @@
"""Add diskhealthjson to endpointstates.
Per-drive health, kept separate from disksjson. That column answers "how full
is this drive"; this one answers "is this drive dying". Merging them would mean
a capacity collection failure and a health collection failure could not be told
apart, and the two come from different WMI sources with different failure modes.
JSON rather than scalar columns because a machine has a variable number of
drives and each carries its own verdict, and because the agent reports the same
drive from two sources - MSFT_PhysicalDisk for NVMe and Win32_DiskDrive for
ATA/SATA - which the server keeps side by side rather than collapsing.
MEDIUMTEXT for consistency with the other snapshot columns on this table.
Nullable with no backfill. A drive whose health has never been reported is not
a healthy drive, and defaulting to an empty list would read as "no disks have
problems" on every endpoint that predates this column.
Idempotent: the column-add checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0014diskhealth'
down_revision = 'endpoints0013inventorycolumns'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
COLUMN_NAME = 'diskhealthjson'
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column(TABLE_NAME, COLUMN_NAME):
op.add_column(
TABLE_NAME,
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
def downgrade():
if _has_column(TABLE_NAME, COLUMN_NAME):
op.drop_column(TABLE_NAME, COLUMN_NAME)

View File

@@ -0,0 +1,78 @@
"""Add rebootreasonsjson, bootinfojson and defenderjson to endpointstates.
Phase 2 system-side telemetry. All three are collect-and-persist; none is
surfaced in the UI by this change.
rebootreasonsjson is NOT redundant with the existing pendingreboot column. That
column is a single boolean OR-ed from three registry signals, so it cannot say
which one fired, and the difference is operationally significant: a
PendingFileRenameOperations entry means files are staged for replacement on the
next boot, so the machine is mid-servicing, whereas a lone Component Based
Servicing or Windows Update key can sit set for weeks on a box nobody has
rebooted. Same boolean, very different urgency.
bootinfojson exists because uptime lies under fast startup - a machine reports
days of uptime after what the operator experienced as a shutdown, and a "reboot"
that was really a hybrid resume does not clear the driver or kernel state a
support call is usually trying to clear.
defenderjson is stored deliberately unsurfaced. Defender is known to produce
false positives in this environment, so wiring it to a card now would train
operators to ignore that card; persisting it starts the history accumulating for
whenever someone is ready to interpret it.
JSON rather than scalars: reboot reasons is a variable-length list, and the boot
and Defender payloads are small related groups that are read together and would
otherwise be six sparse columns.
MEDIUMTEXT for consistency with the other snapshot columns on this table.
Nullable with no backfill. Null means never reported, which is deliberately
distinct from an empty list (checked, nothing pending) - defaulting the reboot
reasons to [] would assert every pre-existing endpoint was confirmed clean.
Idempotent: each column-add checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0015systemtelemetry'
down_revision = 'endpoints0014diskhealth'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
_COLUMNS = (
'rebootreasonsjson',
'bootinfojson',
'defenderjson',
)
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():
for column_name in _COLUMNS:
if not _has_column(TABLE_NAME, column_name):
op.add_column(
TABLE_NAME,
sa.Column(column_name, sa.Text(length=16777215), nullable=True))
def downgrade():
for column_name in reversed(_COLUMNS):
if _has_column(TABLE_NAME, column_name):
op.drop_column(TABLE_NAME, column_name)

View File

@@ -0,0 +1,51 @@
"""Add dellupdatesjson to endpointstates.
Stores the pending Dell Command | Update summary reported by the
shopdb-dellupdates agent utility. MEDIUMTEXT for consistency with the other
snapshot columns on this table.
Nullable with no backfill. Null means the endpoint has never reported a Dell
scan result, which is deliberately distinct from a stored payload whose status
is 'ok' with an empty list (scanned, nothing pending). The status card renders
those two states differently, so defaulting this to an empty summary would
assert every pre-existing endpoint had been scanned and found clean.
Idempotent: the column-add checks the live schema first, so a re-run and a
db.create_all() site both stay clean on strict MySQL 8.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'endpoints0016dellupdates'
down_revision = 'endpoints0015systemtelemetry'
branch_labels = None
depends_on = None
TABLE_NAME = 'endpointstates'
COLUMN_NAME = 'dellupdatesjson'
def _has_column(table_name, column_name):
from sqlalchemy import inspect
inspector = inspect(op.get_bind())
if not inspector.has_table(table_name):
return False
return any(col['name'] == column_name
for col in inspector.get_columns(table_name))
def upgrade():
if not _has_column(TABLE_NAME, COLUMN_NAME):
op.add_column(
TABLE_NAME,
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
def downgrade():
if _has_column(TABLE_NAME, COLUMN_NAME):
op.drop_column(TABLE_NAME, COLUMN_NAME)

View File

@@ -80,6 +80,65 @@ class EndpointState(db.Model):
interfacesjson = db.Column(db.Text(length=16777215), nullable=True)
scheduledtasksjson = db.Column(db.Text(length=16777215), nullable=True)
# Hardware and OS inventory, same newest-snapshot rule as above. These
# arrived on the wire well before this and were logged as unknown keys, so
# the shapes are already proven against real endpoint traffic.
disksjson = db.Column(db.Text(length=16777215), nullable=True)
biosjson = db.Column(db.Text(length=16777215), nullable=True)
memoryjson = db.Column(db.Text(length=16777215), nullable=True)
processorjson = db.Column(db.Text(length=16777215), nullable=True)
osinfojson = db.Column(db.Text(length=16777215), nullable=True)
# Session-scoped data collected by a probe running as the signed-in user
# (mapped drives, user-owned listeners). Null when nobody is signed in,
# which is a normal unattended state rather than a fault.
usercontextjson = db.Column(db.Text(length=16777215), nullable=True)
# Windows domain or workgroup membership. A scalar, not JSON: it is a single
# value the fleet view filters and groups on.
domain = db.Column(db.String(255), nullable=True)
# True when the endpoint is holding a reboot. Null = never reported, which
# is deliberately distinct from a confident False.
pendingreboot = db.Column(db.Boolean, nullable=True)
# Per-drive health from the agent's two disk sources. JSON because a machine
# has a variable number of drives and each carries its own verdict. Kept
# separate from disksjson: that is capacity, this is whether the hardware is
# dying. Null = never reported.
diskhealthjson = db.Column(db.Text(length=16777215), nullable=True)
# Which signal(s) drove pendingreboot. That column is a single bool OR-ed
# from three sources and so cannot say WHY, and the difference matters: a
# pendingfilerename means files are staged for replacement on next boot, so
# the box is mid-servicing, while a lone servicing key can sit set for weeks
# on a machine nobody rebooted. Empty list = checked, nothing pending.
# Null = never reported or the check failed.
rebootreasonsjson = db.Column(db.Text(length=16777215), nullable=True)
# Fast startup configuration and what the last boot actually did. Kept
# because uptime is not trustworthy under fast startup: a box can report
# days of uptime after what the operator experienced as a shutdown, and a
# "reboot" that was a hybrid resume does not clear the state a support call
# is usually trying to clear.
bootinfojson = db.Column(db.Text(length=16777215), nullable=True)
# Defender health. Persisted but deliberately NOT surfaced anywhere yet:
# this environment produces known false positives, and wiring it to a card
# now would train operators to ignore that card. Stored so the history is
# already accumulating when someone is ready to interpret it.
defenderjson = db.Column(db.Text(length=16777215), nullable=True)
# Pending Dell Command | Update items, produced by the shopdb-dellupdates
# agent utility from the log the \ShopDB-DellCommandScan task writes. Only
# the count drives the status card; the short list is stored so the panel
# can show what is actually pending without a second round trip.
#
# A null column and a stored payload with status 'unknown' both mean "never
# scanned", which stays distinct from a scan that found nothing pending. The
# status card relies on that distinction - see _dell_updates_field.
dellupdatesjson = 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
@@ -116,6 +175,8 @@ class EndpointState(db.Model):
'idleseconds': self.idleseconds,
'cpupercent': self.cpupercent,
'memorypercent': self.memorypercent,
'domain': self.domain,
'pendingreboot': self.pendingreboot,
'mtconnectrunning': self.mtconnectrunning,
'mtconnectcheckedat': _utciso(self.mtconnectcheckedat),
'lastreportedat': _utciso(self.lastreportedat),
@@ -255,6 +316,16 @@ class EndpointEvent(db.Model):
eventchannel = db.Column(db.String(50), nullable=True)
eventsource = db.Column(db.String(255), nullable=True)
# The Windows event record id: monotonic and unique within its channel on a
# given machine. This is the dedup key. The agent also uses it as its
# harvest watermark, so in normal operation a record is sent once; the
# unique index below is what guarantees it even when an agent is reinstalled
# (losing its watermark) or two reports overlap. Nullable because an agent
# older than this field still reports, and MySQL permits repeated NULLs in a
# unique index, so those degrade to the previous append-only behavior rather
# than failing.
eventrecordid = db.Column(db.BigInteger, nullable=True)
# When the event happened on the endpoint (from the record), not when it
# reached ShopDB.
eventtime = db.Column(db.DateTime, nullable=True, index=True)
@@ -269,6 +340,8 @@ class EndpointEvent(db.Model):
__table_args__ = (
db.Index('idx_endpointevents_assetevent', 'assetid', 'eventtime'),
db.Index('idx_endpointevents_assetrecord',
'assetid', 'eventchannel', 'eventrecordid', unique=True),
)
def __repr__(self):
@@ -279,6 +352,7 @@ class EndpointEvent(db.Model):
'endpointeventid': self.endpointeventid,
'assetid': self.assetid,
'eventid': self.eventid,
'eventrecordid': self.eventrecordid,
'eventchannel': self.eventchannel,
'eventsource': self.eventsource,
'eventtime': _utciso(self.eventtime),

View File

@@ -52,6 +52,9 @@ KNOWN_PAYLOAD_KEYS = frozenset({
'agentversion', 'trayversion', 'lastboottime', 'uptimeseconds', 'idleseconds',
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
'scheduledtasks', 'applications', 'events',
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'pendingreboot', 'usercontext', 'diskhealth',
'rebootreasons', 'bootinfo', 'defender', 'dellupdates',
})
@@ -179,12 +182,32 @@ class EndpointsPlugin(BasePlugin):
# remote reboot is a strictly heavier grant than a routine toggle.
('endpoints.restart',
'Restart endpoint computers (destructive)', 'endpoints'),
# Elevated grant for software delivery (run_script, update_app).
# Elevated grant for software delivery (run_script, update_app) and
# for starting approved scheduled tasks (scheduledtask_run).
# Required IN ADDITION to endpoints.command by the enqueue route, so
# running a staged script or pushing an installer is a strictly
# heavier grant than a routine service/process toggle.
#
# NOTE FOR THE ROLES WORK - this wire is pre-built for you.
# scheduledtask_run was deliberately mapped onto this EXISTING grant
# rather than minting a new permission, so nothing needs migrating.
# Be aware that WIDENS what endpoints.deploy means: it used to cover
# run_script + update_app, and now also covers starting any task on
# the agent's approved-task allowlist. Splitting it out is a one-line
# change (add endpoints.corptools here and remap it in
# commands.ELEVATED_ACTION_PERMISSIONS) - the enqueue route reads
# that table, so no other code changes.
#
# If you do split it, note the three are NOT equal risk:
# scheduledtask_run only starts something already registered on the
# endpoint and is allowlisted agent-side; update_app runs a pinned
# installer; run_script is arbitrary code that runs as SYSTEM unless
# its descriptor pins the user context. run_script is the reason this
# grant's description has to stay blunt.
('endpoints.deploy',
'Run staged scripts and push application installers to endpoints', 'endpoints'),
'Run staged scripts, start approved scheduled tasks, and push '
'application installers to endpoints (scripts run as SYSTEM; '
'grant only to fully trusted operators)', 'endpoints'),
# Packaging the agent is a heavier grant than deploying to one, and
# it is not the same job either: the generated ZIP carries a live
# collector key stamped into its install script, so whoever holds
@@ -391,7 +414,9 @@ class EndpointsPlugin(BasePlugin):
'position': 12,
# Service state is what is being watched; the Start/Stop
# controls belong beside it, not in a tab of their own.
'group': 'monitors',
# Lives in serviceprocess rather than monitors: that tab is the
# box's own health, this is what runs on it.
'group': 'serviceprocess',
},
{
'id': 'endpoints-tracked-processes',
@@ -431,7 +456,7 @@ class EndpointsPlugin(BasePlugin):
'empty': 'No monitored processes reported.',
'position': 13,
# observed process state, not something you act on here
'group': 'monitors',
'group': 'serviceprocess',
},
{
# One feed instead of two cards. Events and commands answer the
@@ -472,6 +497,11 @@ class EndpointsPlugin(BasePlugin):
{'key': 'detail'},
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
],
# Expands in place on click. The message body is several
# hundred words of Windows boilerplate around the one or
# two lines that matter, so it stays collapsed until asked
# for rather than pushing every other row off the card.
'body': 'body',
},
'empty': 'Nothing recorded for this endpoint yet.',
'position': 20,
@@ -493,10 +523,46 @@ class EndpointsPlugin(BasePlugin):
{'key': 'subnet', 'label': 'Subnet'},
{'key': 'dhcp', 'label': 'Assignment'},
{'key': 'role', 'label': 'Role'},
{'key': 'link', 'label': 'Link'},
],
'empty': 'No network interfaces reported.',
'position': 16,
# adapter state is watched, not something you act on
# adapter state is watched, not something you act on.
# Declared in monitors but RENDERED at the top of the Details
# tab (PCDetail.vue lifts it by id and excludes it here): which
# adapter is up and what address it holds is asked at the same
# moment as "what is this box".
'group': 'monitors',
},
{
# What this box has been on the network, carved out of the
# hand-written Network card that used to sit on Details and
# show only current addresses with a Primary badge.
#
# The history is ACCIDENTAL: _record_interfaces matches on MAC
# then IP and never deletes, so old addresses accumulate. That
# turned out to be worth reading rather than worth cleaning
# away, so it is presented as what it already is.
'id': 'endpoints-iphistory',
'title': 'IP History',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/iphistory',
'render': 'table',
'collapsible': True,
'columns': [
{'key': 'ipaddress', 'label': 'IP address', 'mono': True},
{'key': 'macaddress', 'label': 'MAC', 'mono': True},
{'key': 'status', 'label': 'Status'},
{'key': 'firstseen', 'label': 'First seen',
'format': 'date'},
# NOT "last seen": modifieddate only moves when a field on
# the row changes, so an address still held and reported
# unchanged keeps an old stamp here.
{'key': 'lastchanged', 'label': 'Last changed',
'format': 'date'},
],
'empty': 'No addresses on record for this endpoint.',
'position': 16.5,
'group': 'monitors',
},
{
@@ -524,10 +590,123 @@ class EndpointsPlugin(BasePlugin):
# the installed-software inventory
'group': 'software',
},
{
'id': 'endpoints-hardware',
'title': 'Hardware',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/hardware',
'render': 'keyvalue',
'collapsible': True,
'empty': 'No hardware inventory reported.',
'position': 14,
'group': 'monitors',
},
{
'id': 'endpoints-osinfo',
'title': 'Operating system',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/osinfo',
'render': 'keyvalue',
'collapsible': True,
'empty': 'No OS inventory reported.',
'position': 15,
'group': 'monitors',
},
{
# Session-scoped, so it sits with the other things watched
# rather than acted on. The user context card still carries a
# one-line summary; this is the same data with room to show a
# remote path and a per-drive badge, and both are built from
# _mapped_drive_rows so they cannot disagree.
#
# Room reserved here for the future one-click reconnect: the
# list render already supports a row action, so adding it is a
# map/commandendpoint change rather than a new card.
'id': 'endpoints-mappeddrives',
'title': 'Mapped drives',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/mappeddrives',
'render': 'list',
'collapsible': True,
'status': {
'key': 'statecolor',
'levels': {
'green': 'ok',
'yellow': 'warning',
'red': 'critical',
},
},
'map': {
'title': 'drive',
'meta': [{'key': 'remote', 'mono': True}],
'badge': {'label': 'statuslabel', 'color': 'statecolor'},
},
'empty': 'No mapped drives reported.',
'position': 21,
'group': 'monitors',
},
{
# Volumes and physical drive health in one card, as two
# sections. VISUAL GROUPING ONLY: the two tables share no key
# (storage is per volume, health is per physical device) and
# nothing is reconciled across them. They are one card because
# the operator reading a full disk and the one reading a dying
# disk are usually the same person in the same minute.
'id': 'endpoints-storage',
'title': 'Storage',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/storage',
'render': 'tabs',
'collapsible': True,
'empty': 'No disks reported.',
'position': 17,
'group': 'monitors',
},
{
# The listening set is the box's exposed network surface. The
# full connection table is deliberately not here: it is an
# on-demand command, because a per-cycle capture of every
# connection is a lot of rows nobody reads.
'id': 'endpoints-listeners',
'title': 'Listening ports',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/listeners',
'render': 'table',
'collapsible': True,
'columns': [
{'key': 'localport', 'label': 'Port'},
{'key': 'protocol', 'label': 'Protocol'},
{'key': 'localaddress', 'label': 'Address'},
{'key': 'processname', 'label': 'Process'},
{'key': 'processid', 'label': 'PID'},
],
'empty': 'No listening ports reported.',
'position': 19,
'group': 'monitors',
},
{
# Session-scoped data collected by a probe in the interactive
# user's session, so it is absent whenever nobody is signed in.
# Last on the page: it is the least reliably present card, and
# an empty one at the bottom reads as "not reported" rather
# than pushing the populated cards down.
'id': 'endpoints-usercontext',
'title': 'User context',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/usercontext',
'render': 'keyvalue',
'collapsible': True,
'empty': 'No user session data reported.',
'position': 40,
'group': 'monitors',
},
{
# 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.
# optional screenshot the operator captured from the tray. Has
# a tab of its own rather than sitting in System Monitors: that
# tab is machine-collected health, this is a human telling you
# something, and the thumbnail grid is far bulkier than the
# fact cards it used to sit between.
# A custom render carries the thumbnail grid and the expanding
# lightbox with left/right cycling the generic modes cannot.
'id': 'endpoints-operator-reports',
@@ -539,7 +718,7 @@ class EndpointsPlugin(BasePlugin):
'collapsible': True,
'empty': 'No operator reports for this endpoint.',
'position': 18,
'group': 'monitors',
'group': 'reports',
},
]
@@ -636,6 +815,80 @@ class EndpointsPlugin(BasePlugin):
'type': 'array',
'description': 'Scheduled task inventory + last run.',
},
'disks': {
'type': 'array',
'description': 'Fixed local disks: {drive, label, '
'filesystem, sizebytes, freebytes, '
'usedbytes, freepercent}. Newest '
'snapshot, replaced on every report.',
},
'bios': {
'type': 'object',
'description': 'Firmware identity: vendor, version, '
'release date.',
},
'memory': {
'type': 'object',
'description': 'Physical memory totals in bytes.',
},
'processor': {
'type': 'object',
'description': 'CPU identity, core and logical '
'processor counts, max clock.',
},
'osinfo': {
'type': 'object',
'description': 'OS build, version, and install date.',
},
'domain': {
'type': 'string',
'description': 'Windows domain or workgroup membership.',
},
'pendingreboot': {
'type': 'boolean',
'description': 'True when the endpoint is holding a '
'reboot. Absent means never reported, '
'which is not the same as false.',
},
'diskhealth': {
'type': 'array',
'description': 'Per-drive health from two sources: '
'physicaldisk (the only one reporting '
'NVMe) and diskdrive (ATA/SATA and '
'fallback). Each entry carries a '
'precomputed predictfailure; null there '
'means the drive did not answer.',
},
'rebootreasons': {
'type': 'array',
'description': 'Which signal(s) drove pendingreboot: '
'componentbasedservicing, windowsupdate, '
'pendingfilerename. Empty array means '
'checked and nothing pending; omit when '
'the check failed.',
},
'bootinfo': {
'type': 'object',
'description': 'hiberbootenabled (fast startup is '
'configured) and lastboottype (what the '
'last boot actually did). Uptime is not '
'trustworthy when fast startup is in '
'play.',
},
'defender': {
'type': 'object',
'description': 'Defender service/realtime state and '
'signature and scan ages in days. '
'Persisted but not surfaced.',
},
'usercontext': {
'type': 'object',
'description': 'Session-scoped data collected by a probe '
'running as the signed-in user: mapped '
'drives (netuse), user-owned listeners, '
'and idle seconds. Absent when nobody is '
'signed in.',
},
'applications': {
'type': 'array',
'description': 'Installed-software SBOM from both '
@@ -663,6 +916,12 @@ class EndpointsPlugin(BasePlugin):
'required': ['eventid'],
'properties': {
'eventid': {'type': 'integer'},
'eventrecordid': {
'type': 'integer',
'description': 'Windows event record id; '
'unique per channel per '
'machine, used to dedup.',
},
'eventchannel': {'type': 'string'},
'eventsource': {'type': 'string'},
'eventtime': {'type': 'string',
@@ -846,6 +1105,51 @@ class EndpointsPlugin(BasePlugin):
if 'scheduledtasks' in payload:
state.scheduledtasksjson = _as_json(payload.get('scheduledtasks'))
# Hardware/OS inventory. Same presence guard as above: a report that
# omits a key must not wipe the last-known value. These change rarely
# (a RAM upgrade, a BIOS flash), so a transient collection failure on
# one cycle should leave the previous snapshot standing rather than
# blank the asset record.
if 'disks' in payload:
state.disksjson = _as_json(payload.get('disks'))
if 'bios' in payload:
state.biosjson = _as_json(payload.get('bios'))
if 'memory' in payload:
state.memoryjson = _as_json(payload.get('memory'))
if 'processor' in payload:
state.processorjson = _as_json(payload.get('processor'))
if 'osinfo' in payload:
state.osinfojson = _as_json(payload.get('osinfo'))
if 'domain' in payload:
# Clamped like every other String column here: an over-long value
# aborts the whole collector transaction under STRICT_TRANS_TABLES,
# so one bad agent report would drop a machine's entire telemetry.
state.domain = _clamp(payload.get('domain'), 255)
if 'pendingreboot' in payload:
state.pendingreboot = payload.get('pendingreboot')
if 'diskhealth' in payload:
state.diskhealthjson = _as_json(payload.get('diskhealth'))
if 'rebootreasons' in payload:
state.rebootreasonsjson = _as_json(payload.get('rebootreasons'))
if 'bootinfo' in payload:
state.bootinfojson = _as_json(payload.get('bootinfo'))
if 'defender' in payload:
state.defenderjson = _as_json(payload.get('defender'))
# Presence-guarded like the probe output below: the agent omits the key
# entirely when the collector utility is not installed, and overwriting
# to null there would discard a still-valid scan result from before the
# utility was removed.
if 'dellupdates' in payload:
state.dellupdatesjson = _as_json(payload.get('dellupdates'))
# Session-scoped probe output. The agent repeats its last result between
# probe runs and omits the key entirely when nobody is signed in, so the
# presence guard preserves the last known mapped drives across an
# unattended period instead of flapping the field to null.
if 'usercontext' in payload:
state.usercontextjson = _as_json(payload.get('usercontext'))
# Installed-software SBOM: stored as a per-asset SNAPSHOT in its own
# table (endpointapplications), not a JSON blob on the state, so it can
# later join the applications catalog ("Managed Applications"). Only
@@ -897,6 +1201,19 @@ class EndpointsPlugin(BasePlugin):
hostname, len(unknown_keys), sorted(unknown_keys), preview_text)
stored_events = 0
skipped_events = 0
# Records the agent already sent must not be appended a second time.
# The agent carries a per-channel watermark so the steady state is
# "only what is new", but a reinstalled agent, a restored image, or two
# overlapping reports can all replay records. Resolve that here rather
# than relying on the unique index to raise, because an IntegrityError
# would abort the whole collector transaction and lose the state upsert
# along with it.
#
# One query for the whole batch, not one per record: a seeding agent
# sends up to the per-channel cap at once.
candidates = []
for record in (payload.get('events') or []):
try:
eventid = int(record.get('eventid'))
@@ -906,10 +1223,45 @@ class EndpointsPlugin(BasePlugin):
if eventid not in CURATED_EVENT_IDS:
# Outside the catalog: ignored on purpose, not an error.
continue
try:
eventrecordid = record.get('eventrecordid')
eventrecordid = (int(eventrecordid)
if eventrecordid is not None else None)
except (TypeError, ValueError):
eventrecordid = None
candidates.append((eventid, eventrecordid, record))
keyed = [(record.get('eventchannel'), eventrecordid)
for _e, eventrecordid, record in candidates
if eventrecordid is not None]
already_stored = set()
if keyed:
rows = (db.session.query(EndpointEvent.eventchannel,
EndpointEvent.eventrecordid)
.filter(EndpointEvent.assetid == assetid,
EndpointEvent.eventrecordid.in_(
[recordid for _c, recordid in keyed]))
.all())
already_stored = {(channel, recordid) for channel, recordid in rows}
for eventid, eventrecordid, record in candidates:
eventchannel = record.get('eventchannel')
if eventrecordid is not None:
key = (eventchannel, eventrecordid)
if key in already_stored:
skipped_events += 1
continue
# Guard against the same record appearing twice inside one
# payload as well as against rows already in the table.
already_stored.add(key)
db.session.add(EndpointEvent(
assetid=assetid,
eventid=eventid,
eventchannel=record.get('eventchannel'),
eventrecordid=eventrecordid,
eventchannel=eventchannel,
eventsource=record.get('eventsource'),
eventtime=_parse_dt(record.get('eventtime')),
eventmessage=record.get('eventmessage'),
@@ -917,6 +1269,11 @@ class EndpointsPlugin(BasePlugin):
))
stored_events += 1
if skipped_events:
logger.debug(
'endpoint %s replayed %d already-stored event record(s)',
hostname, skipped_events)
# flush, not commit: the collector dispatcher owns the transaction.
db.session.flush()

View File

@@ -591,14 +591,14 @@ onMounted(async () => {
machineTypes.value = typesRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
locations.value = locRes || []
models.value = allModels
businessunits.value = buRes.data.data || []
// listAll resolves to the ARRAY, not a response: fetchAllPages already
// unwrapped every page. Reading .data.data off it threw, and the whole
// parallel load went to the catch - so every dropdown on this form came up
// empty and the machine's own values never loaded.
vendors.value = vendorRes || []
locations.value = locRes || []
models.value = allModels
businessunits.value = buRes || []
pcs.value = pcsRes || []
// Load relationship types separately

View File

@@ -187,8 +187,11 @@ onMounted(async () => {
])
types.value = typesResponse.data.data || []
statuses.value = statusResponse.data.data || []
// listAll resolves to the ARRAY, not a response: fetchAllPages already
// unwrapped every page. Reading .data.data off it throws and takes the
// whole parallel load into the catch.
locations.value = locResponse || []
businessunits.value = buResponse.data.data || []
businessunits.value = buResponse || []
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
assettypeid.value = assetType ? assetType.assettypeid : null

4
scripts/backup/.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
# Keep shell scripts LF-only so they run inside Linux containers regardless of
# the host checkout. A CRLF shebang makes the kernel look for "bash\r" and the
# container dies with: env: 'bash\r': No such file or directory.
*.sh text eol=lf

View File

@@ -0,0 +1,53 @@
# Backup sidecar image: mysql:8.4 (for a version-matched mysqldump) plus restic
# (for deduped, encrypted, destination-agnostic file snapshots) and a small
# Python for the manifest writer.
#
# WHY mysql:8.4 as the base: the dump tool must match the server major/minor so
# the SQL it emits restores cleanly onto the same engine. Reusing the exact
# image the db service runs removes version drift as a failure mode.
FROM mysql:8.4
# Track 1 local build unblock: trust the corporate proxy (Zscaler/GE) root CA so
# the build-time fetches below (Oracle yum, github.com, PyPI) succeed behind TLS
# interception. This is a local convenience only; the shipping build vendors
# these deps instead of fetching them (pinned restic + SHA256, vendored
# bzip2/python3/alembic or a base image that carries them) so the release image
# needs no outbound calls and no embedded cert. The cert is gitignored (*.pem)
# and must be staged at scripts/backup/zscaler-root-ca.pem before building.
#
# Copied via the directory rather than by name, because the cert is OPTIONAL: it
# is gitignored, so a plain `COPY scripts/backup/zscaler-root-ca.pem` fails the
# build outright and nobody outside a TLS-intercepting network - another site,
# or our own CI - could build this image at all. A trailing glob does not fix
# that either: BuildKit fails a COPY whose pattern matches nothing, which is
# precisely the case being handled. Copying a directory that always exists and
# testing for the file inside it is the form that works both ways.
COPY scripts/backup/ /tmp/buildctx/
RUN if [ -f /tmp/buildctx/zscaler-root-ca.pem ]; then \
echo "staging corporate root CA"; \
cp /tmp/buildctx/zscaler-root-ca.pem /etc/pki/ca-trust/source/anchors/; \
update-ca-trust; \
else \
echo "no corporate root CA staged - using the public trust store"; \
fi \
&& rm -rf /tmp/buildctx
# restic from the distro is often stale; pull the pinned release binary so the
# snapshot format and retention flags are predictable across sites.
ARG RESTIC_VERSION=0.17.3
RUN microdnf install -y bzip2 python3 python3-pip ca-certificates \
&& curl -fsSL -o /tmp/restic.bz2 \
"https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2" \
&& bunzip2 /tmp/restic.bz2 \
&& install -m 0755 /tmp/restic /usr/local/bin/restic \
&& rm -f /tmp/restic \
&& microdnf clean all
# alembic so the manifest writer can read the current schema head. pip does not
# read the system trust store, so point it at the extracted system CA bundle
# (now including the proxy root) to get through TLS interception.
RUN python3 -m pip install --no-cache-dir \
--cert /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem \
alembic
ENTRYPOINT ["/opt/backup/backup.sh"]

View File

@@ -0,0 +1,44 @@
# Restore Simulation (FOR DEVELOPER USE ONLY)
`simulate-restore.sh` is a developer diagnostic that proves the backup/restore
pair round-trips the live database. It is NOT an operator tool and NOT part of
the shipped feature. It mutates a live database on purpose - never run it
against production or anything you cannot disturb.
## Testing cadence
- Before merging any change to `scripts/backup/*.sh`, `docker-compose.backup.yml`,
or `shopdb/core/api/shopdbbackups.py`.
- Before cutting a release that touches the backup/restore surface.
- After a schema migration that adds or drops tables (new table archetype).
- Ad hoc when investigating a restore-fidelity concern.
## What it does
1. Requests a backup through the real `.backup-requested` sentinel.
2. Fingerprints every base table with `CHECKSUM TABLE` (the restore target).
3. Quiesces the `api` service, mutates the table archetypes:
- core standalone (vendor rename, setting flip)
- consumable ledger (`modelsupplies.quantityonhand` bump)
4. Requests a restore through the real `.restore-requested` sentinel.
5. Re-fingerprints and asserts every table returned to backup-time state.
6. Writes `simulate-restore-results.txt` next to the script.
## Testing parameters
- Stack must be up with the backup profile:
`docker compose -f docker-compose.yml -f docker-compose.backup.yml --profile backup up -d`
- Run from git bash on the host: `bash scripts/backup/simulate-restore.sh`
- Coverage: all `BASE TABLE` rows in `shopdb_flask` (110+ tables), one pass.
## Expected drift (NOT failures)
- `apitokens`: the app rewrites token last-used continuously; restore is
correct at backup time.
- Endpoint-agent tables (`computers`, `endpointstates`, `endpointevents`,
`endpointapplications`): external fleet agents keep writing after restore and
cannot be quiesced from here.
- Asset ROW deletion is not permitted in a live/CMMC environment (RESTRICT +
compliance). The cascade edge is reported informationally only. A missing or
corrupted asset is still fully restored because the dump drops and recreates
the table - restore fidelity does not depend on a live delete.

197
scripts/backup/backup.sh Executable file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env bash
# ShopDB backup one-shot.
#
# Produces one recoverable set per run and stores it two ways: a plain
# db.sql.gz + manifest.json on the local /backups volume for a fast, repo-free
# restore, and a restic snapshot (db dump + instance tree + manifest) at
# RESTIC_REPOSITORY for a deduped, encrypted, offsite copy.
#
# Sequence:
# 1. mysqldump --single-transaction (InnoDB-consistent, non-blocking)
# 2. manifest.py stamps core+plugin Alembic revisions + image tag
# 3. restic backup the dump + /app/instance + manifest as ONE snapshot
# 4. restic forget --keep-daily 5 --keep-weekly 4 --keep-monthly 6 --prune
# 5. restic check - fail loudly if the repo is unhealthy, so a bad repo is
# caught on backup night, not restore night.
#
# ASCII only, no emojis - matches the repo style gate.
set -euo pipefail
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
SITE="${SHOPDB_SITE:-shopdb}"
STAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
SETNAME="${SITE}-${STAMP}"
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
# shellcheck source=scripts/backup/status.sh
. /opt/backup/status.sh
log() { echo "[backup] $*"; }
# Record the run outcome on any exit so a failed backup is visible in the UI
# history, not just a silent gap.
BACKUP_DONE=false
_on_exit() {
code=$?
if [ "${BACKUP_DONE}" = true ]; then
status_write backup done "${SETNAME}" "Backup complete"
history_append backup success "${SETNAME}" "Backup complete"
else
status_write backup failed "${SETNAME}" "Backup failed (exit ${code})"
history_append backup failed "${SETNAME}" "Backup failed (exit ${code})"
fi
}
trap _on_exit EXIT
mkdir -p "${SETDIR}"
status_write backup database "${SETNAME}" "Dumping database"
# 1. Database: whole-DB logical dump, consistent and non-blocking on InnoDB.
# Dumped WITHOUT --databases on purpose. That flag bakes CREATE DATABASE and USE
# <name> into the file, which pins the dump to the schema name it was taken
# from: restoring onto a site whose MYSQL_DATABASE differs loaded the rows into
# the dump's schema, left the live one untouched, and reported success.
# --no-tablespaces because dumping tablespace metadata needs the PROCESS
# privilege, which the app user does not have on MySQL 8.
log "dumping database ${MYSQL_DATABASE} from ${MYSQL_HOST}"
mysqldump \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--no-tablespaces \
"${MYSQL_DATABASE}" \
| gzip -c > "${SETDIR}/db.sql.gz"
log "database dump written ($(du -h "${SETDIR}/db.sql.gz" | cut -f1))"
# 2. Manifest: the schema stamp that makes a restore a checked operation.
# Capture the revision actually applied to the DATABASE (its alembic_version),
# not just the code head. A restore must gate on the DATA revision; stamping
# only the code head hides an image that has outrun its own database and lets a
# behind-schema dump restore under ahead-schema code (1054 Unknown column on
# every affected table). Query it straight from the dumped DB.
log "reading applied database revision (alembic_version)"
DATAREV="$(mysql \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--skip-column-names --batch \
-e "SELECT version_num FROM alembic_version LIMIT 1;" \
"${MYSQL_DATABASE}" 2>/dev/null | tr -d '\r')"
if [ -z "${DATAREV}" ]; then
# Recorded loudly rather than shrugged off: a set with no data revision
# cannot be gated on restore, and the gate treats an empty stamp as "no
# evidence" and refuses it. Better a visible warning now than a set that
# looks restorable and is not.
log "WARN could not read alembic_version - this set will NOT be restorable"
history_append backup warning "${SETNAME}" \
"Could not read alembic_version; set has no data revision and cannot be restored"
fi
# The same question for every PLUGIN chain. Each plugin owns an
# alembic_version_<plugin> table, and those move independently of the core one:
# code rolled back to an older plugin revision can sit at an identical core
# head while the dump carries plugin tables it has never heard of. Stamping only
# the core revision let that combination pass the restore gate, load, and then
# fail during the plugin upgrade with the data already replaced. Read straight
# from the dumped DB, same as the core revision above.
log "reading applied plugin revisions (alembic_version_*)"
DATAPLUGINS="$(mysql \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--skip-column-names --batch \
-e "SELECT table_name FROM information_schema.tables
WHERE table_schema = '${MYSQL_DATABASE}'
AND table_name LIKE 'alembic\\_version\\_%';" 2>/dev/null \
| tr -d '\r' \
| while read -r table; do
[ -n "${table}" ] || continue
plugin="${table#alembic_version_}"
rev="$(mysql \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--skip-column-names --batch \
-e "SELECT version_num FROM \`${table}\` LIMIT 1;" \
"${MYSQL_DATABASE}" 2>/dev/null | tr -d '\r')"
[ -n "${rev}" ] && printf '%s\t%s\n' "${plugin}" "${rev}"
done \
| python3 -c 'import json,sys; print(json.dumps(dict(l.rstrip("\n").split("\t",1) for l in sys.stdin if "\t" in l)))')"
[ -n "${DATAPLUGINS}" ] || DATAPLUGINS='{}'
log "plugin revisions: ${DATAPLUGINS}"
log "writing manifest"
python3 /opt/backup/manifest.py \
--out "${SETDIR}/manifest.json" \
--takenat "${STAMP}" \
--site "${SITE}" \
--imagetag "${SHOPDB_IMAGE_TAG:-source}" \
--datarev "${DATAREV}" \
--dataplugins "${DATAPLUGINS}" \
--migrations /app/migrations \
--plugins /app/plugins
# Consistency guard: if the DATA revision does not match the CODE head, this
# backup captures a database the running image has outrun. It is still stored
# (losing it would be worse) but flagged loudly so a restore of it is a
# deliberate choice, not a surprise.
CODEREV="$(python3 -c \
'import json,sys;print(",".join(sorted(json.load(open(sys.argv[1]))["schemarev"]["core"])))' \
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
if [ -n "${DATAREV}" ] && [ "${DATAREV}" != "${CODEREV}" ]; then
log "WARN schema drift: database is at '${DATAREV}' but code head is '${CODEREV}'"
log "WARN this DB has not run 'flask db upgrade' to the running image; restore will upgrade it"
history_append backup warning "${SETNAME}" "Schema drift: data ${DATAREV} vs code ${CODEREV}"
fi
# 3. restic snapshot: db dump + instance tree + manifest, one point in time.
# Skips gracefully when restic/repo are not configured (local-only sites).
IS_CHECK_PASSED=false
status_write backup snapshot "${SETNAME}" "Snapshotting to backup store"
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
if ! restic snapshots >/dev/null 2>&1; then
log "initializing restic repository at ${RESTIC_REPOSITORY}"
restic init
fi
log "restic backup (db dump + instance tree + manifest)"
restic backup \
--tag "${SITE}" \
--tag "${SETNAME}" \
"${SETDIR}/db.sql.gz" \
"${SETDIR}/manifest.json" \
/app/instance
# 4. Retention: any day in the last 5, plus weekly/monthly tail.
log "pruning per retention policy"
restic forget \
--tag "${SITE}" \
--keep-daily 5 \
--keep-weekly 4 \
--keep-monthly 6 \
--prune
# 5. Integrity: learn about corruption now, not at restore time.
log "verifying repository integrity"
if restic check; then
IS_CHECK_PASSED=true
log "restic check passed"
else
log "restic check FAILED - repository may be corrupt"
fi
else
log "restic not configured - local db.sql.gz + manifest only"
fi
# Record the verification result into the manifest so the UI can badge the set.
python3 /opt/backup/manifest.py \
--amend "${SETDIR}/manifest.json" \
--ischeckpassed "${IS_CHECK_PASSED}"
# Clear the request sentinel the UI drops when an operator clicks "Back up now".
rm -f "${BACKUPS_ROOT}/.backup-requested" 2>/dev/null || true
BACKUP_DONE=true
log "backup set ${SETNAME} complete"

163
scripts/backup/manifest.py Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Backup manifest writer.
The manifest is the stamp that turns "we have dumps" into "we can actually
restore." It records, at capture time:
- takenat : ISO-8601 UTC timestamp of the run
- site : the site tag
- imagetag : the app image tag that produced the data
- schemarev : the core Alembic head revision(s) the running CODE is at, plus
each plugin chain's heads - the versions a restore target must
be at least, or restored rows reference columns the live schema
has not applied yet.
- datarev : the CORE Alembic revision actually applied to the DATABASE that
was dumped (its alembic_version), which is the number a restore
must gate on. schemarev stamps the code on disk; datarev stamps
the data in the dump. They diverge when an image is ahead of a DB
that has not run 'flask db upgrade' yet, and that divergence is
exactly what a code-to-code check cannot see.
- dataplugins: the same thing for every PLUGIN chain - the applied revision of
each alembic_version_<plugin> table. The core chain alone cannot
answer compatibility: plugin schemas move independently, so code
rolled back to an older plugin revision can sit at an identical
core head while the dump carries plugin tables it has never heard
of. Gating on core only let that pass, load, and then fail during
the plugin upgrade with the data already replaced.
Two modes:
--out ... write a fresh manifest for a new set
--amend ... patch an existing manifest (used to record ischeckpassed after
restic check runs, or to backfill datarev)
ASCII only, no emojis - matches the repo style gate.
"""
import argparse
import json
import os
import sys
def _heads_for(chain_dir):
"""Return the Alembic head revision id(s) under a versions directory.
We read the revision graph the cheap way: a head is a revision no other
revision names as its down_revision. Done by text scan so this stays
dependency-light and works for both the core chain and each plugin chain.
"""
versions = os.path.join(chain_dir, 'versions')
if not os.path.isdir(versions):
return []
revisions = {}
down_refs = set()
for name in os.listdir(versions):
if not name.endswith('.py'):
continue
path = os.path.join(versions, name)
revid = None
downid = None
try:
with open(path, 'r', encoding='utf-8') as handle:
for line in handle:
stripped = line.strip()
if stripped.startswith('revision =') and revid is None:
revid = stripped.split('=', 1)[1].strip().strip('"\'')
elif stripped.startswith('down_revision ='):
value = stripped.split('=', 1)[1].strip()
downid = None if value in ('None', 'null') else value.strip('"\'')
except OSError:
continue
if revid:
revisions[revid] = downid
if downid:
down_refs.add(downid)
return sorted(rev for rev in revisions if rev not in down_refs)
def _parse_dataplugins(raw):
"""The applied per-plugin revisions, as a {plugin: revision} map.
Bad or absent JSON becomes an empty map rather than an error: a manifest
that cannot say what the plugin chains were at is exactly the legacy case
the restore gate already knows how to treat conservatively, and failing the
backup here would lose the set over a stamp.
"""
if not raw:
return {}
try:
value = json.loads(raw)
except ValueError:
return {}
if not isinstance(value, dict):
return {}
return {str(k): str(v) for k, v in value.items() if k and v}
def _collect_schema(migrations_dir, plugins_dir):
"""Core heads plus each plugin's chain heads, keyed for compatibility checks."""
schema = {'core': _heads_for(migrations_dir), 'plugins': {}}
if plugins_dir and os.path.isdir(plugins_dir):
for name in sorted(os.listdir(plugins_dir)):
chain = os.path.join(plugins_dir, name, 'migrations')
heads = _heads_for(chain)
if heads:
schema['plugins'][name] = heads
return schema
def write_manifest(args):
manifest = {
'takenat': args.takenat,
'site': args.site,
'imagetag': args.imagetag,
'schemarev': _collect_schema(args.migrations, args.plugins),
'datarev': args.datarev or '',
'dataplugins': _parse_dataplugins(args.dataplugins),
'ischeckpassed': False,
}
with open(args.out, 'w', encoding='utf-8') as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
return 0
def amend_manifest(args):
try:
with open(args.amend, 'r', encoding='utf-8') as handle:
manifest = json.load(handle)
except (OSError, ValueError):
manifest = {}
if args.ischeckpassed is not None:
manifest['ischeckpassed'] = args.ischeckpassed.lower() == 'true'
if args.datarev is not None:
manifest['datarev'] = args.datarev
if args.dataplugins is not None:
manifest['dataplugins'] = _parse_dataplugins(args.dataplugins)
with open(args.amend, 'w', encoding='utf-8') as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description='ShopDB backup manifest writer')
parser.add_argument('--out')
parser.add_argument('--amend')
parser.add_argument('--takenat', default='')
parser.add_argument('--site', default='shopdb')
parser.add_argument('--imagetag', default='source')
parser.add_argument('--migrations', default='/app/migrations')
parser.add_argument('--plugins', default='/app/plugins')
parser.add_argument('--datarev')
parser.add_argument('--dataplugins',
help='JSON object of {plugin: applied revision}')
parser.add_argument('--ischeckpassed')
args = parser.parse_args(argv)
if args.amend:
return amend_manifest(args)
if args.out:
return write_manifest(args)
parser.error('one of --out or --amend is required')
if __name__ == '__main__':
sys.exit(main())

290
scripts/backup/restore.sh Executable file
View File

@@ -0,0 +1,290 @@
#!/usr/bin/env bash
# ShopDB restore.
#
# The DANGEROUS half of the pair, so it is deliberately guarded. A restore is
# only safe onto code whose schema is at least the set's - otherwise restored
# rows reference columns the live schema has not applied yet and every query on
# those tables 500s. This script refuses an incompatible restore rather than
# leaving the site half-broken.
#
# Usage (from the backup container):
# docker compose -f docker-compose.yml -f docker-compose.backup.yml \
# run --rm --entrypoint /opt/backup/restore.sh backup <snapshot-set-name>
#
# It does NOT roll back code - that is git's job (git checkout <tag> && build).
# Restore code FIRST, bring the schema up (flask db upgrade + plugin
# upgrade-all), THEN run this to load the data.
#
# ASCII only, no emojis - matches the repo style gate.
set -euo pipefail
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
SETNAME="${1:-}"
# Overridable so the gate can be exercised outside a container against throwaway
# migration trees. In the image these are exactly the mounted paths, so the
# shipped behaviour is unchanged; test-restore-datarev.sh drives THIS script
# rather than a copy of its logic, which is the only way the test can notice
# this script drifting.
BACKUP_LIB_DIR="${BACKUP_LIB_DIR:-/opt/backup}"
APP_MIGRATIONS_DIR="${APP_MIGRATIONS_DIR:-/app/migrations}"
APP_PLUGINS_DIR="${APP_PLUGINS_DIR:-/app/plugins}"
APP_INSTANCE_DIR="${APP_INSTANCE_DIR:-/app/instance}"
# When set, stop after the compatibility gate and report the verdict as the exit
# code, touching nothing. This is what makes the gate testable.
RESTORE_CHECK_ONLY="${RESTORE_CHECK_ONLY:-}"
# shellcheck source=scripts/backup/status.sh
. "${BACKUP_LIB_DIR}/status.sh"
log() { echo "[restore] $*"; }
# On any exit, stamp the terminal phase and append a history record so the UI
# reflects success or failure even when a step aborts early (set -e). A restore
# is only "done" once the DATA has been upgraded to the running CODE, and that
# upgrade runs in the Flask-capable api container, not here (this sidecar has no
# app). So the happy path ends by handing off to the api: this script stamps an
# 'upgrading' phase and drops an .upgrade-requested sentinel, and the api runs
# 'flask db upgrade' + 'flask plugin upgrade-all' and writes the final 'done'.
RESTORE_LOADED=false
_on_exit() {
code=$?
if [ "${RESTORE_LOADED}" = true ]; then
# Data is loaded; the api takes it from 'upgrading' to 'done'. Do not
# stamp success here or the UI would call the restore complete before
# the schema is actually brought up to code.
status_write restore upgrading "${SETNAME}" "Upgrading restored schema to code"
else
status_write restore failed "${SETNAME}" "Restore failed (exit ${code})"
history_append restore failed "${SETNAME}" "Restore failed (exit ${code})"
fi
}
trap _on_exit EXIT
die() { echo "[restore] ERROR: $*" >&2; exit 1; }
[ -n "${SETNAME}" ] || die "usage: restore.sh <snapshot-set-name>"
status_write restore checking "${SETNAME}" "Checking schema compatibility"
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
DBDUMP="${SETDIR}/db.sql.gz"
# Prefer the local fast path; fall back to pulling the set out of restic.
if [ ! -f "${DBDUMP}" ]; then
log "local dump not found; restoring set from restic"
command -v restic >/dev/null 2>&1 || die "restic not available and no local dump"
restic restore latest --tag "${SETNAME}" --target / --include "${SETDIR}" \
|| die "restic restore of ${SETNAME} failed"
fi
[ -f "${DBDUMP}" ] || die "no db.sql.gz for set ${SETNAME}"
# Compatibility gate: the restore must gate on the revision the DUMP'S DATA is
# at (datarev), not on a code-to-code comparison. Loading data that sits at an
# OLDER revision than the running code is fine - we upgrade it afterwards - but
# loading data NEWER than the code (a revision the code has never heard of)
# would leave rows referencing columns the code cannot migrate to, so that is
# refused.
log "checking schema compatibility"
# The set's data revision: prefer the manifest field; for older manifests that
# predate datarev, parse the real alembic_version straight out of the dump so a
# legacy set is still gated on its true data, not on a code-derived stamp.
SET_DATAREV="$(python3 -c \
'import json,sys;print(json.load(open(sys.argv[1])).get("datarev",""))' \
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
if [ -z "${SET_DATAREV}" ]; then
log "manifest has no datarev (legacy set) - reading it from the dump"
SET_DATAREV="$(gunzip -c "${DBDUMP}" \
| grep -A2 -i 'INSERT INTO `alembic_version`' \
| grep -oE "'[0-9a-zA-Z_]+'" | tr -d "'" | head -1)"
fi
if [ -z "${SET_DATAREV}" ]; then
die "cannot determine the set's data revision - refusing an unverified restore"
fi
# Does the running code know this revision at all? If a migration file with that
# revision id exists in the core chain, the code is at-or-past it (the head is
# the tip of the same chain), so a post-load upgrade can carry the data forward.
# If it is absent, the dump is NEWER than the code - refuse.
if ! grep -rqE "^revision = ['\"]${SET_DATAREV}['\"]" "${APP_MIGRATIONS_DIR}/versions" 2>/dev/null; then
log "set datarev : ${SET_DATAREV}"
die "the backup's data is at a revision this code does not have - deploy code at or past '${SET_DATAREV}' before restoring"
fi
log "data revision ${SET_DATAREV} is known to this code - restore may proceed"
# The same gate for every PLUGIN chain, because the core chain alone cannot
# answer this. Plugin schemas move independently of core: roll the code back to
# an older plugin revision and the core head can be identical while the dump
# carries plugin tables the code has never heard of. Gating on core only let
# that pass, load the dump, and then fail in 'flask plugin upgrade-all' with
# "Can't locate revision" and the data already replaced - a failed restore that
# has destroyed what it was restoring over.
#
# A manifest with no dataplugins is a legacy set taken before this was stamped.
# Its plugin revisions are unknowable, so it is allowed through on the core gate
# alone (the behaviour before this check existed) rather than being made
# unrestorable retroactively.
SET_DATAPLUGINS="$(python3 -c \
'import json,sys
try:
data = json.load(open(sys.argv[1])).get("dataplugins") or {}
except Exception:
data = {}
for name, rev in sorted(data.items()):
print(name + " " + rev)' \
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
if [ -z "${SET_DATAPLUGINS}" ]; then
log "manifest stamps no plugin revisions (legacy set) - core gate only"
else
while read -r plugin rev; do
[ -n "${plugin}" ] || continue
chain="${APP_PLUGINS_DIR}/${plugin}/migrations/versions"
if [ ! -d "${chain}" ]; then
die "the backup carries data for plugin '${plugin}', which this build does not have - deploy a build that includes it before restoring"
fi
if ! grep -rqE "^revision = ['\"]${rev}['\"]" "${chain}" 2>/dev/null; then
log "plugin ${plugin} data revision: ${rev}"
die "plugin '${plugin}' data is at a revision this code does not have - deploy code at or past '${rev}' before restoring"
fi
log "plugin ${plugin} at ${rev} - known to this code"
done <<PLUGINREVS
${SET_DATAPLUGINS}
PLUGINREVS
fi
# Last gate: which schema does this dump target? A dump taken with --databases
# carries CREATE DATABASE and USE <name>, pinning it to the schema it came from.
# Loaded into a site whose MYSQL_DATABASE differs, the rows land in the dump's
# schema, the live database is untouched, and the restore reports success. New
# sets are dumped without it and load into whatever schema is named here; older
# sets are still bound, so refuse rather than restore beside the live data.
# Checked HERE, with the other gates, so it costs nothing and stops before the
# safety copy rather than after it.
DUMP_SCHEMA="$(gunzip -c "${DBDUMP}" \
| grep -m1 -oE '^USE `[^`]+`' \
| sed -E 's/^USE `(.*)`/\1/' || true)"
if [ -n "${DUMP_SCHEMA}" ]; then
if [ "${DUMP_SCHEMA}" != "${MYSQL_DATABASE}" ]; then
die "this set is bound to schema '${DUMP_SCHEMA}' but this site's database is '${MYSQL_DATABASE}' - restoring it would load the data beside the live database and report success"
fi
log "legacy schema-bound dump for '${DUMP_SCHEMA}' - matches this site"
fi
if [ -n "${RESTORE_CHECK_ONLY}" ]; then
log "check-only: every gate passed, stopping before anything destructive"
RESTORE_LOADED=false
trap - EXIT
exit 0
fi
# Safety set FIRST. Everything below this point is destructive and none of it
# is transactional: the dump carries DROP TABLE per table, and the instance tree
# is replaced wholesale. Without a copy taken here, a restore of the wrong set -
# or a failure part-way through the load - leaves nothing to go back to. This is
# a local copy on the same volume, so it is cheap and it is NOT a substitute for
# a real backup; it exists to make THIS operation reversible.
SAFETY_STAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
SAFETY_DIR="${BACKUPS_ROOT}/pre-restore-${SAFETY_STAMP}"
status_write restore safety "${SETNAME}" "Copying the current state aside"
log "taking a pre-restore safety copy into ${SAFETY_DIR}"
mkdir -p "${SAFETY_DIR}" || die "could not create ${SAFETY_DIR}"
mysqldump \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--no-tablespaces \
"${MYSQL_DATABASE}" \
| gzip -c > "${SAFETY_DIR}/db.sql.gz" \
|| die "pre-restore dump failed - refusing to touch the live database"
if [ -d /app/instance ]; then
tar -czf "${SAFETY_DIR}/instance.tar.gz" -C /app instance \
|| die "pre-restore instance copy failed - refusing to touch the live files"
fi
# Named so an operator finding this directory months later knows what it is and
# how to use it, without reading this script.
cat > "${SAFETY_DIR}/README.txt" <<SAFETYNOTE
Automatic pre-restore copy of the state this site was in immediately before
restoring set '${SETNAME}' at ${SAFETY_STAMP}.
db.sql.gz mysqldump of ${MYSQL_DATABASE} as it was
instance.tar.gz /app/instance as it was (absent if there was no such tree)
To go back to this state, load the dump the same way a restore does:
gunzip -c db.sql.gz | mysql --host=... --user=... --password=... \
--database=${MYSQL_DATABASE}
tar -xzf instance.tar.gz -C /app
This is a local copy on the backups volume, taken for reversibility of one
restore. It is not a backup set: it has no manifest and no restic snapshot, and
nothing prunes it.
SAFETYNOTE
log "safety copy written ($(du -sh "${SAFETY_DIR}" | cut -f1))"
# Files first, then DB, so the two land together.
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
status_write restore files "${SETNAME}" "Restoring instance files"
# Scoped to THIS set's snapshot. A bare 'latest' restores whatever was
# snapshotted most recently, so restoring anything other than the newest set
# paired that set's DATABASE with the NEWEST snapshot's uploads and
# plugins.json - a mismatch nothing downstream could detect.
log "restoring instance files from the ${SETNAME} snapshot"
STAGE="$(mktemp -d)"
restic restore latest --tag "${SETNAME}" --target "${STAGE}" \
--include /app/instance \
|| die "restic restore of instance files for ${SETNAME} failed"
# Replace the CONTENTS of the instance tree rather than restoring over it,
# so files created after the snapshot are gone afterwards - a restore has to
# put the site back to the snapshot, not merge into it. Done as an explicit
# staged swap rather than 'restic restore --delete --target /', because that
# aims a delete pass at the container root and the blast radius of getting
# its filter wrong is the whole filesystem. /app/instance is a mount, so the
# directory itself has to survive; only what is inside it is replaced.
if [ -d "${STAGE}/app/instance" ]; then
find /app/instance -mindepth 1 -delete \
|| die "could not clear the instance tree before restoring it"
cp -a "${STAGE}/app/instance/." /app/instance/ \
|| die "could not lay down the restored instance tree"
else
log "WARN snapshot for ${SETNAME} carried no /app/instance - leaving files alone"
fi
rm -rf "${STAGE}"
fi
status_write restore database "${SETNAME}" "Loading database dump"
log "loading database dump into ${MYSQL_DATABASE}"
gunzip -c "${DBDUMP}" | mysql \
--host="${MYSQL_HOST}" \
--user="${MYSQL_USER}" \
--password="${MYSQL_PASSWORD}" \
--database="${MYSQL_DATABASE}" \
|| die "database load failed"
# Hand off to the api container for the schema upgrade. The dump may sit at an
# older revision than this image (allowed and expected); without the upgrade the
# data stays behind the code and every query on a table the newer migrations
# changed 500s with 1054 Unknown column. This sidecar has no Flask, so it cannot
# run 'flask db upgrade' itself - it records the intent and lets the api (which
# owns the app context and already runs these exact idempotent commands on every
# deploy) finalize. The api clears .upgrade-requested and stamps the final
# 'done' once the upgrade succeeds.
status_write restore upgrading "${SETNAME}" "Upgrading restored schema to code"
printf '%s' "${SETNAME}" > "${BACKUPS_ROOT}/.upgrade-requested"
log "database loaded at revision ${SET_DATAREV}; handed off to api for schema upgrade"
rm -f "${BACKUPS_ROOT}/.restore-requested" 2>/dev/null || true
RESTORE_LOADED=true

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# ShopDB restore simulation harness. ***FOR DEVELOPER USE ONLY***
#
# This is a developer diagnostic, NOT an operator tool and NOT part of the
# shipped backup feature. It mutates a live database on purpose. Do not run it
# against production or any environment you are not prepared to disturb.
#
# WHY: the restore path reloads the WHOLE database in one pass (mysqldump
# --databases emits DROP TABLE + CREATE TABLE + full INSERTs, so restore is a
# full replace, not a row-level merge). The strongest real-world check is a
# full-fidelity round trip: fingerprint every table, take a backup, mutate
# across the table archetypes (core standalone, asset + plugin subtype, and
# consumable ledgers), restore, then prove every table's fingerprint returns to
# the backup-time state.
#
# It drives the exact sentinel path the UI uses (.backup-requested /
# .restore-requested on the shared volume), not a private code path, so a pass
# here is a pass for the operator-facing feature.
#
# KNOWN, EXPECTED, NOT-A-FAILURE behaviors (see LiveDatabaseTest notes below):
# - apitokens does not round-trip bit-for-bit: the app rewrites token
# last-used timestamps continuously, so restore reproduces backup-time
# state, which differs from any later fingerprint. This is correct.
# - endpoint-agent tables (computers heartbeat, endpointstates/events/
# applications) churn from the EXTERNAL fleet agents, which cannot be
# quiesced. Post-restore writes from them are expected drift, not a
# restore defect.
# - Asset ROWS cannot be deleted in a live/CMMC environment (RESTRICT and
# compliance controls). The cascade probe is therefore informational only;
# restore fidelity does not depend on it. A missing/corrupted asset is
# still fully restored because the dump drops and recreates the table.
#
# Usage (from a host with the stack up, git bash):
# bash scripts/backup/simulate-restore.sh
#
# ASCII only, no emojis - matches the repo style gate.
set -uo pipefail
# Silence the docker CLI "What's next" hints - they bleed into command
# substitution and corrupt captured SQL output.
export DOCKER_CLI_HINTS=false
COMPOSE="docker compose -f docker-compose.yml -f docker-compose.backup.yml --profile backup"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Tables that legitimately differ after restore and MUST NOT be counted as
# failures:
# - App-churn: token last-used, sessions, audit trail.
# - Endpoint-agent churn: the external fleet agents keep writing to these
# after restore (heartbeats, state, events, inventory) and cannot be
# quiesced from here.
# Restore reproduces their state at dump time; later fingerprints differ by
# design, so they are reported informationally.
VOLATILE="apitokens sessions auditlogs computers endpointstates endpointevents endpointapplications"
WORKDIR="$(mktemp -d)"
BASELINE="${WORKDIR}/baseline.txt"
MUTATED="${WORKDIR}/mutated.txt"
RESTORED="${WORKDIR}/restored.txt"
MARKER="simrestore-$(date -u +%Y%m%dT%H%M%SZ)"
log() { echo "[sim] $*"; }
die() { echo "[sim] ERROR: $*" >&2; exit 1; }
# Run a SQL statement, returning tab-separated rows with no column headers.
sql() {
printf '%s\n' "$1" | ${COMPOSE} exec -T db sh -c \
'mysql -ushopdb -p"$MYSQL_PASSWORD" shopdb_flask -N 2>/dev/null'
}
# Fingerprint every base table with CHECKSUM TABLE into the given file.
fingerprint() {
out="$1"
tables="$(sql "SELECT table_name FROM information_schema.tables \
WHERE table_schema='shopdb_flask' AND table_type='BASE TABLE' \
ORDER BY table_name;" | tr -d '\r')"
: > "${out}"
for table in ${tables}; do
# CHECKSUM TABLE returns "dbname.table<TAB>checksum"; keep table+checksum.
line="$(sql "CHECKSUM TABLE \`${table}\`;" | tr -d '\r')"
checksum="$(printf '%s' "${line}" | awk '{print $2}')"
printf '%s\t%s\n' "${table}" "${checksum}" >> "${out}"
done
}
wait_for_phase() {
kind="$1"; want="$2"; timeout="${3:-120}"
waited=0
while [ "${waited}" -lt "${timeout}" ]; do
phase="$(${COMPOSE} exec -T backup-watcher sh -c \
"cat /backups/.${kind}-status.json 2>/dev/null" \
| sed -n 's/.*\"phase\":\"\([^\"]*\)\".*/\1/p')"
if [ "${phase}" = "${want}" ]; then return 0; fi
if [ "${phase}" = "failed" ]; then
die "${kind} reported failed status"
fi
sleep 3
waited=$((waited + 3))
done
die "${kind} did not reach '${want}' within ${timeout}s (last: ${phase:-none})"
}
# --- 0. Preconditions ------------------------------------------------------
${COMPOSE} exec -T db sh -c 'true' >/dev/null 2>&1 \
|| die "db container not reachable - is the stack up?"
# --- 0b. Datarev-gate preflight (external tester) --------------------------
# This live round trip runs one codebase against itself, so it can never
# reproduce the "data revision behind the running code" case the restore gate
# now handles. Run the standalone datarev tester first so a pass here also
# covers that offline contract (manifest datarev, known-vs-unknown revision
# refusal, legacy fallback, and the api upgrade handoff).
log "running datarev gate preflight (test-restore-datarev.sh)"
bash "${SCRIPT_DIR}/test-restore-datarev.sh" \
|| die "datarev gate preflight failed - see output above"
log "datarev gate preflight passed"
# --- 1. Backup the current state, then fingerprint the RESTORE TARGET ------
# The restore point is the database as it existed WHEN THE DUMP RAN, not some
# earlier moment. Live-churn tables (apitokens last-used, sessions, auditlogs)
# move under the running app, so the baseline must be captured immediately
# after the backup completes - that is the exact state restore must reproduce.
log "requesting a backup (sentinel .backup-requested)"
${COMPOSE} exec -T backup-watcher sh -c 'echo sim > /backups/.backup-requested'
wait_for_phase backup done 300
SETNAME="$(${COMPOSE} exec -T backup-watcher sh -c \
'cat /backups/.backup-status.json 2>/dev/null' \
| sed -n 's/.*\"setname\":\"\([^\"]*\)\".*/\1/p')"
[ -n "${SETNAME}" ] || die "could not read the completed backup set name"
log "backup complete: ${SETNAME}"
log "baseline fingerprint of all tables (restore target)"
fingerprint "${BASELINE}"
baseline_count="$(wc -l < "${BASELINE}")"
log "fingerprinted ${baseline_count} tables"
# Quiesce the app for the mutate/restore/verify window. A real DR runbook stops
# serving traffic before a restore; here it also stops the endpoint-agent write
# path (computers heartbeat, endpointstates/events/applications) that would
# otherwise churn the DB concurrently and defeat a bit-for-bit comparison.
log "stopping api to quiesce writers"
${COMPOSE} stop api >/dev/null 2>&1
resume_api() { log "resuming api"; ${COMPOSE} start api >/dev/null 2>&1; }
trap resume_api EXIT
# --- 2. Mutate across the table archetypes ---------------------------------
# Core standalone: rename a vendor and flip a setting.
# Ledger/consumable: bump a modelsupplies quantityonhand.
# NOTE: asset ROW deletion is intentionally NOT performed. In a live/CMMC
# environment assets cannot be removed (RESTRICT + compliance controls), which
# is the app behaving as designed. Restore fidelity does not depend on it: the
# dump drops and recreates every table, so a missing or corrupted asset is
# fully reconstructed regardless. The cascade edge is reported informationally.
log "mutating rows (marker ${MARKER})"
sql "UPDATE vendors SET vendorname=CONCAT('${MARKER}-', vendorname) \
ORDER BY vendorid LIMIT 1;"
sql "UPDATE settings SET settingvalue='${MARKER}' \
WHERE settingkey=(SELECT settingkey FROM (SELECT settingkey FROM settings \
ORDER BY settingkey LIMIT 1) AS s);"
# Cascade edge (informational only - no destructive delete): report the FK
# delete rule that would remove computer subtype rows if an asset were deleted.
CASCADE_RULE="$(sql "SELECT delete_rule FROM information_schema.referential_constraints \
WHERE constraint_schema='shopdb_flask' AND referenced_table_name='assets' \
AND table_name='computers' LIMIT 1;" | tr -d '\r')"
log "cascade edge computers->assets delete_rule=${CASCADE_RULE:-unknown} (live asset delete not permitted; not exercised)"
# Ledger: move a cached quantityonhand so the restore must revert it.
sql "UPDATE modelsupplies SET quantityonhand=quantityonhand+9999 \
ORDER BY id LIMIT 1;" 2>/dev/null \
|| sql "UPDATE modelsupplies SET quantityonhand=quantityonhand+9999 \
ORDER BY modelsupplyid LIMIT 1;"
log "post-mutation fingerprint"
fingerprint "${MUTATED}"
changed="$(diff <(sort "${BASELINE}") <(sort "${MUTATED}") | grep -c '^>')"
log "mutation changed ${changed} table fingerprint(s)"
# --- 3. Restore the backup we took -----------------------------------------
log "requesting restore of ${SETNAME}"
${COMPOSE} exec -T backup-watcher sh -c "printf '%s' '${SETNAME}' > /backups/.restore-requested"
wait_for_phase restore done 600
log "restore complete"
# --- 4. Verify every table returned to baseline ----------------------------
log "post-restore fingerprint"
fingerprint "${RESTORED}"
fails=0
volatile_diffs=0
while IFS="$(printf '\t')" read -r table base; do
restored="$(grep -P "^${table}\t" "${RESTORED}" | awk '{print $2}')"
if [ "${base}" != "${restored}" ]; then
case " ${VOLATILE} " in
*" ${table} "*)
echo "[sim] (volatile) ${table} differs as expected: baseline=${base} restored=${restored}"
volatile_diffs=$((volatile_diffs + 1))
;;
*)
echo "[sim] MISMATCH ${table}: baseline=${base} restored=${restored}"
fails=$((fails + 1))
;;
esac
fi
done < "${BASELINE}"
echo "=================================================="
RESULTS_FILE="${SCRIPT_DIR}/simulate-restore-results.txt"
{
echo "ShopDB restore simulation results (FOR DEVELOPER USE ONLY)"
echo "run (UTC) : $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "marker : ${MARKER}"
echo "backup set : ${SETNAME}"
echo "tables checked : ${baseline_count}"
echo "cascade edge : computers->assets delete_rule=${CASCADE_RULE:-unknown} (not exercised - live asset delete not permitted)"
echo ""
echo "Expected, not-a-failure drift (excluded from strict equality):"
echo " - apitokens: app rewrites token last-used; restore is correct at backup time."
echo " - endpoint-agent tables (computers, endpointstates/events/applications):"
echo " external fleet agents keep writing post-restore and cannot be quiesced."
echo " - asset row deletion is not permitted live (RESTRICT + CMMC); the table is"
echo " still fully restored because the dump drops and recreates it."
echo ""
if [ "${fails}" -eq 0 ]; then
echo "RESULT: PASS - all ${baseline_count} tables restored to backup-time state"
echo " (${volatile_diffs} volatile table(s) excluded from strict equality)"
else
echo "RESULT: FAIL - ${fails} table(s) did not match after restore"
fi
} | tee "${RESULTS_FILE}"
echo "=================================================="
echo "[sim] results written to ${RESULTS_FILE}"
echo "[sim] work dir: ${WORKDIR}"
echo "=================================================="
[ "${fails}" -eq 0 ]

100
scripts/backup/status.sh Executable file
View File

@@ -0,0 +1,100 @@
# ShopDB backup/restore status + history helper.
#
# WHY this exists: the restore and backup runs happen in the watcher container,
# but the operator watches the UI, which only talks to the api. This helper is
# the bridge: it writes two kinds of state onto the shared /backups volume that
# the api can read.
#
# 1. A LIVE status file (.restore-status.json / .backup-status.json) rewritten
# at each phase so the UI can show an indeterminate spinner with a phase
# label (queued, restoring files, loading database, done, failed).
# 2. An append-only HISTORY file (history/restores.json / history/backups.json)
# that keeps the record of past runs so the UI can list them in a card.
#
# Both writes are atomic (write temp, rename) so the api never reads a half
# written file. History is bounded to the most recent HISTORY_MAX entries.
#
# This file is SOURCED, not executed. It exposes:
# status_write <kind> <phase> <setname> [message]
# history_append <kind> <outcome> <setname> [message]
#
# kind is "restore" or "backup". ASCII only, no emojis - matches the style gate.
STATUS_BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
HISTORY_MAX="${BACKUP_HISTORY_MAX:-50}"
_status_now() { date -u +%Y-%m-%dT%H%M%SZ; }
_status_json_escape() {
# Escape backslashes and double quotes for embedding in a JSON string.
printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
# status_write <kind> <phase> <setname> [message]
# Rewrites the live status file for this run kind.
status_write() {
kind="$1"
phase="$2"
setname="${3:-}"
message="${4:-}"
statusfile="${STATUS_BACKUPS_ROOT}/.${kind}-status.json"
tmpfile="${statusfile}.tmp.$$"
{
printf '{'
printf '"kind":"%s",' "$(_status_json_escape "${kind}")"
printf '"phase":"%s",' "$(_status_json_escape "${phase}")"
printf '"setname":"%s",' "$(_status_json_escape "${setname}")"
printf '"message":"%s",' "$(_status_json_escape "${message}")"
printf '"updatedat":"%s"' "$(_status_now)"
printf '}\n'
} > "${tmpfile}" 2>/dev/null || return 0
mv -f "${tmpfile}" "${statusfile}" 2>/dev/null || rm -f "${tmpfile}" 2>/dev/null
return 0
}
# history_append <kind> <outcome> <setname> [message]
# Appends a completed run record and trims to HISTORY_MAX newest entries.
history_append() {
kind="$1"
outcome="$2"
setname="${3:-}"
message="${4:-}"
historydir="${STATUS_BACKUPS_ROOT}/history"
historyfile="${historydir}/${kind}s.json"
tmpfile="${historyfile}.tmp.$$"
mkdir -p "${historydir}" 2>/dev/null || return 0
record="$(printf '{"setname":"%s","outcome":"%s","message":"%s","finishedat":"%s"}' \
"$(_status_json_escape "${setname}")" \
"$(_status_json_escape "${outcome}")" \
"$(_status_json_escape "${message}")" \
"$(_status_now)")"
if command -v python3 >/dev/null 2>&1; then
HISTORY_FILE="${historyfile}" HISTORY_RECORD="${record}" \
HISTORY_MAX="${HISTORY_MAX}" python3 - "${tmpfile}" <<'PY' 2>/dev/null || return 0
import json, os, sys
path = os.environ["HISTORY_FILE"]
record = json.loads(os.environ["HISTORY_RECORD"])
limit = int(os.environ.get("HISTORY_MAX", "50"))
tmp = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as handle:
entries = json.load(handle)
if not isinstance(entries, list):
entries = []
except (OSError, ValueError):
entries = []
entries.append(record)
entries = entries[-limit:]
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(entries, handle)
os.replace(tmp, path)
PY
fi
return 0
}

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# ShopDB restore datarev-gate tester. ***FOR DEVELOPER USE ONLY***
#
# WHY THIS EXISTS SEPARATELY FROM simulate-restore.sh:
# simulate-restore.sh is a full-fidelity ROUND TRIP against a live stack: it
# backs up, mutates, restores, and proves every table returns to backup-time
# state. By construction it runs ONE codebase against ITSELF, so the database it
# dumps is always at the SAME revision as the code that restores it. It can
# therefore never exercise the case the FBJXBDB4 outage was about: a dump whose
# DATA revision is BEHIND (or ahead of) the running CODE.
#
# This tester fills that gap offline. It builds throwaway migration trees and
# fake gzipped dumps and drives the exact decision logic restore.sh uses:
# 1. manifest.py records datarev (the DB's real alembic_version), not just the
# code head (schemarev).
# 2. A dump whose datarev the code KNOWS (an ancestor in the chain) is allowed
# - the post-load upgrade rolls it forward.
# 3. A dump whose datarev the code does NOT know (newer than this build) is
# REFUSED - restoring it would 500 on 1054 Unknown column.
# 4. A legacy manifest with no datarev falls back to parsing alembic_version
# straight out of the dump.
# 5. restore.sh hands the loaded restore off to the api via the
# .upgrade-requested sentinel instead of marking it done in the sidecar.
#
# No Docker, no MySQL, no network: it is a pure logic gate test, safe to run in
# CI and from inside simulate-restore.sh as a preflight.
#
# ASCII only, no emojis - matches the repo style gate.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MANIFEST_PY="${SCRIPT_DIR}/manifest.py"
RESTORE_SH="${SCRIPT_DIR}/restore.sh"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "${WORKDIR}"' EXIT
# The backup image ships python3; a dev host may only have `python` (and on
# Windows `python3` can be a non-functional Store alias that resolves on PATH
# but errors when run). Pick the first interpreter that actually EXECUTES, so
# this tester runs anywhere without changing the shipped scripts.
PY=""
for candidate in python3 python py; do
if "${candidate}" -c 'pass' >/dev/null 2>&1; then
PY="${candidate}"
break
fi
done
[ -n "${PY}" ] || { echo "[test] ERROR: no working python interpreter found"; exit 1; }
passes=0
fails=0
pass() { echo "[test] PASS: $*"; passes=$((passes + 1)); }
fail() { echo "[test] FAIL: $*"; fails=$((fails + 1)); }
# Build a throwaway core migration chain: baseline -> mid -> head. The gate in
# restore.sh only needs each version file to declare `revision = '<id>'`, so
# that is all we write.
build_chain() {
root="$1"
versions="${root}/versions"
mkdir -p "${versions}"
printf "revision = '%s'\ndown_revision = None\n" \
'0001_baseline' > "${versions}/0001_baseline.py"
printf "revision = '%s'\ndown_revision = '%s'\n" \
'0002_mid' '0001_baseline' > "${versions}/0002_mid.py"
printf "revision = '%s'\ndown_revision = '%s'\n" \
'0003_head' '0002_mid' > "${versions}/0003_head.py"
}
# Fake a gzipped mysqldump that stamps a given alembic_version, matching the
# INSERT shape restore.sh greps for when a legacy manifest lacks datarev.
build_dump() {
out="$1"; datarev="$2"
{
echo 'DROP TABLE IF EXISTS `alembic_version`;'
echo 'CREATE TABLE `alembic_version` (`version_num` varchar(32));'
echo 'INSERT INTO `alembic_version` VALUES'
echo "('${datarev}');"
} | gzip -c > "${out}"
}
# Build a throwaway plugin chain, shaped like plugins/<name>/migrations/versions.
build_plugin_chain() {
root="$1"; name="$2"; rev="$3"
versions="${root}/${name}/migrations/versions"
mkdir -p "${versions}"
printf "revision = '%s'\ndown_revision = None\n" \
"${rev}" > "${versions}/${rev}.py"
}
# Drive the REAL restore.sh gate. This is the point of the rewrite: the test
# used to carry its own copy of the predicate, so it stayed green no matter how
# far restore.sh drifted from it. RESTORE_CHECK_ONLY stops the script right
# after the gate, so the verdict here IS the shipped verdict.
run_gate() {
backups_root="$1"; setname="$2"; chain="$3"; plugins="$4"
RESTORE_CHECK_ONLY=1 \
SHOPDB_BACKUPS_ROOT="${backups_root}" \
BACKUP_LIB_DIR="${SCRIPT_DIR}" \
APP_MIGRATIONS_DIR="${chain}" \
APP_PLUGINS_DIR="${plugins}" \
bash "${RESTORE_SH}" "${setname}" >"${WORKDIR}/gate.log" 2>&1
}
CHAIN="${WORKDIR}/migrations"
build_chain "${CHAIN}"
PLUGINS="${WORKDIR}/plugins"
build_plugin_chain "${PLUGINS}" 'endpoints' 'endpoints0002known'
ROOT="${WORKDIR}/backups"
mkdir -p "${ROOT}"
# Lay out one set the way a real backup does, under the backups root.
make_set() {
setname="$1"; datarev="$2"; dumprev="$3"; dataplugins="$4"
setdir="${ROOT}/${setname}"
mkdir -p "${setdir}"
if [ "${datarev}" = "__legacy__" ]; then
printf '{"takenat":"2023-06-01T00:00:00Z","schemarev":{"core":["0003_head"]}}' \
> "${setdir}/manifest.json"
else
${PY} "${MANIFEST_PY}" --out "${setdir}/manifest.json" \
--migrations "${CHAIN}" --plugins "${PLUGINS}" \
--datarev "${datarev}" --dataplugins "${dataplugins}" \
--takenat '2024-01-01T00:00:00Z' >/dev/null 2>&1
fi
build_dump "${setdir}/db.sql.gz" "${dumprev}"
printf '%s' "${setdir}"
}
# --- 1. manifest.py records datarev separately from the code head ----------
make_set 'set-behind' '0002_mid' '0002_mid' '{}' >/dev/null
recorded="$(${PY} -c \
'import json,sys;print(json.load(open(sys.argv[1])).get("datarev",""))' \
"${ROOT}/set-behind/manifest.json" 2>/dev/null)"
head_stamp="$(${PY} -c \
'import json,sys;print(json.load(open(sys.argv[1])).get("schemarev",{}).get("core",[]))' \
"${ROOT}/set-behind/manifest.json" 2>/dev/null)"
if [ "${recorded}" = '0002_mid' ]; then
pass "manifest.py records datarev (${recorded}) separate from code head ${head_stamp}"
else
fail "manifest.py did not record datarev (got '${recorded}')"
fi
# --- 2. datarev the code KNOWS (behind head) is allowed --------------------
if run_gate "${ROOT}" 'set-behind' "${CHAIN}" "${PLUGINS}"; then
pass "data behind code (0002_mid) is known and allowed to restore"
else
fail "data behind code was refused: $(tail -1 "${WORKDIR}/gate.log")"
fi
# --- 3. datarev the code does NOT know (newer than build) is refused -------
make_set 'set-ahead' '0004_future' '0004_future' '{}' >/dev/null
if run_gate "${ROOT}" 'set-ahead' "${CHAIN}" "${PLUGINS}"; then
fail "data ahead of code (0004_future) was NOT refused"
else
pass "data ahead of code (0004_future) is unknown and correctly refused"
fi
# --- 4. legacy manifest (no datarev) falls back to the dump ----------------
make_set 'set-legacy' '__legacy__' '0001_baseline' '{}' >/dev/null
if run_gate "${ROOT}" 'set-legacy' "${CHAIN}" "${PLUGINS}"; then
pass "legacy set with no datarev falls back to the dump revision"
else
fail "legacy set fallback failed: $(tail -1 "${WORKDIR}/gate.log")"
fi
# --- 5. restore.sh hands off to the api instead of self-completing ---------
if grep -q '\.upgrade-requested' "${RESTORE_SH}" \
&& grep -q "status_write restore upgrading" "${RESTORE_SH}" \
&& ! grep -qE 'status_write restore done' "${RESTORE_SH}"; then
pass "restore.sh parks in 'upgrading' and hands off via .upgrade-requested"
else
fail "restore.sh does not hand off the upgrade to the api"
fi
# --- 6. a PLUGIN chain the code does not know is refused -------------------
# The core head is identical in both of these; only the plugin revision differs.
# That is the whole failure: a core-only gate cannot tell them apart.
make_set 'set-plugin-known' '0002_mid' '0002_mid' '{"endpoints":"endpoints0002known"}' >/dev/null
if run_gate "${ROOT}" 'set-plugin-known' "${CHAIN}" "${PLUGINS}"; then
pass "a set whose plugin revision the code knows is allowed"
else
fail "known plugin revision was refused: $(tail -1 "${WORKDIR}/gate.log")"
fi
make_set 'set-plugin-future' '0002_mid' '0002_mid' '{"endpoints":"endpoints0099future"}' >/dev/null
if run_gate "${ROOT}" 'set-plugin-future' "${CHAIN}" "${PLUGINS}"; then
fail "plugin data ahead of code was NOT refused (core-only gate regression)"
else
pass "plugin data ahead of code is refused even though the core head matches"
fi
# --- 7. a plugin the build does not have at all is refused -----------------
make_set 'set-plugin-missing' '0002_mid' '0002_mid' '{"nosuchplugin":"whatever0001"}' >/dev/null
if run_gate "${ROOT}" 'set-plugin-missing' "${CHAIN}" "${PLUGINS}"; then
fail "a set carrying data for an absent plugin was NOT refused"
else
pass "a set carrying data for a plugin this build lacks is refused"
fi
# --- 8. a dump bound to another schema is refused --------------------------
# --databases bakes 'USE <name>' in, which silently loaded the rows beside the
# live database and reported success.
setdir="${ROOT}/set-otherschema"
mkdir -p "${setdir}"
${PY} "${MANIFEST_PY}" --out "${setdir}/manifest.json" \
--migrations "${CHAIN}" --plugins "${PLUGINS}" \
--datarev '0002_mid' --dataplugins '{}' \
--takenat '2024-01-01T00:00:00Z' >/dev/null 2>&1
{
echo 'CREATE DATABASE `someothersite`;'
echo 'USE `someothersite`;'
echo 'INSERT INTO `alembic_version` VALUES'
echo "('0002_mid');"
} | gzip -c > "${setdir}/db.sql.gz"
# Now a gate, so check-only reaches it without needing a database.
out="$(RESTORE_CHECK_ONLY=1 SHOPDB_BACKUPS_ROOT="${ROOT}" \
BACKUP_LIB_DIR="${SCRIPT_DIR}" \
APP_MIGRATIONS_DIR="${CHAIN}" APP_PLUGINS_DIR="${PLUGINS}" \
MYSQL_DATABASE='shopdb_flask' \
bash "${RESTORE_SH}" 'set-otherschema' 2>&1)" || true
if printf '%s' "${out}" | grep -q "bound to schema 'someothersite'"; then
pass "a dump bound to another site's schema is refused"
else
fail "a schema-bound dump was not caught (got: $(printf '%s' "${out}" | tail -1))"
fi
echo "=================================================="
echo "[test] datarev gate: ${passes} passed, ${fails} failed"
echo "=================================================="
[ "${fails}" -eq 0 ]

61
scripts/backup/watch.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# ShopDB backup WATCHER - the resident half of the in-app backup utility.
#
# WHY this exists: the api container cannot run docker, so when an operator
# clicks "Back up now" the api only drops a sentinel file (.backup-requested)
# on the shared backups volume. This watcher is the thing that actuates that
# request: it polls for the sentinel and runs the one-shot backup.sh, which
# ships a restic snapshot to the backup-store container and clears the sentinel
# on success. That turns the UI button into a real, self-serve backup without
# giving the api container any docker or host privileges.
#
# ASCII only, no emojis - matches the repo style gate.
set -uo pipefail
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
SENTINEL="${BACKUPS_ROOT}/.backup-requested"
RESTORE_SENTINEL="${BACKUPS_ROOT}/.restore-requested"
INTERVAL="${BACKUP_POLL_SECONDS:-10}"
log() { echo "[backup-watch] $*"; }
# shellcheck source=scripts/backup/status.sh
. /opt/backup/status.sh
mkdir -p "${BACKUPS_ROOT}"
# The api container runs unprivileged and only drops sentinel files here; it
# cannot write into a root-owned 0755 directory. Make the backups root
# writable so "Back up now" and "Restore" can queue their sentinels.
chmod 0777 "${BACKUPS_ROOT}" 2>/dev/null || true
log "watcher up; polling ${SENTINEL} and ${RESTORE_SENTINEL} every ${INTERVAL}s"
while true; do
if [ -f "${SENTINEL}" ]; then
log "backup requested - running one-shot"
status_write backup queued "" "Backup queued"
if /opt/backup/backup.sh; then
log "backup completed"
else
# backup.sh only clears the sentinel on success, so a failed run
# would otherwise loop forever. Clear it here and wait for the next
# explicit request rather than hammering a broken backup.
log "backup FAILED - clearing request, awaiting next"
rm -f "${SENTINEL}" 2>/dev/null || true
fi
fi
if [ -f "${RESTORE_SENTINEL}" ]; then
# The api drops the target set name into the restore sentinel. Read it,
# hand it to restore.sh, and clear the request whether it succeeds or
# fails so a bad set cannot pin the watcher in a tight retry loop.
setname="$(head -n1 "${RESTORE_SENTINEL}" 2>/dev/null | tr -d '\r\n')"
log "restore requested - running restore for ${setname}"
status_write restore queued "${setname}" "Restore queued"
if /opt/backup/restore.sh "${setname}"; then
log "restore completed"
else
log "restore FAILED - clearing request, awaiting next"
fi
rm -f "${RESTORE_SENTINEL}" 2>/dev/null || true
fi
sleep "${INTERVAL}"
done

View File

@@ -35,6 +35,10 @@ EXCLUDES=(
--exclude-dir=.git
--exclude-dir=versions
--exclude-dir=staticdocs
# Local, gitignored scratch notes (see .gitignore devnotes/). Not product
# source, never committed, and free to hold pasted logs full of em-dashes
# and arrows - grep does not consult .gitignore, so exclude it explicitly.
--exclude-dir=devnotes
)
# Root-level gitignored scratch: review writeups and chat handoffs, free to hold

View File

@@ -1,10 +1,11 @@
# uninstall-shopdbagent.ps1
# ShopDB Endpoint Agent uninstaller. Run from an ELEVATED (admin) PowerShell on
# the endpoint. Reverses everything install-shopdbagent.ps1 and the site agent
# generator's install.ps1 create:
# the endpoint. Reverses everything the site agent generator's install.ps1
# creates:
# - stops and deletes the Windows service
# - kills any running agent/tray processes
# - removes the tray logon Run key (64-bit and 32-bit views)
# - unregisters the ShopDB user-context probe scheduled task
# - removes the credential hive HKLM\SOFTWARE\GE\ShopDB (both views)
# - removes the Defender path exclusion for the install directory
# - deletes the install directory and the ProgramData data/log tree
@@ -20,6 +21,10 @@ param(
[string] $ServiceName = "ShopDbEndpointAgent",
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
# Scheduled task that runs the user-context probe in the operator's session.
# Must match the installer's -UserContextTaskName.
[string] $UserContextTaskName = "ShopDB user-context probe",
# Also remove any server TLS certificate the site-agent installer imported
# into the machine Trusted Root store. Off by default because a root cert is
# shared trust material: only pull it when you are sure no other service on
@@ -113,6 +118,34 @@ Start-Sleep -Seconds 1
# 3. Remove the tray logon Run key entry (both views) and the credential hive.
Write-Host "[3/6] Removing registry entries ..."
Remove-HklmBothViews -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "ShopDbEndpointTray"
# Unregister the probe task. Left behind it would keep firing at every logon
# against a deleted exe, which shows up as a recurring task-failure history on a
# machine nobody believes has the agent on it any more.
try {
if (Get-ScheduledTask -TaskName $UserContextTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $UserContextTaskName -Confirm:$false -ErrorAction Stop
Write-Host " removed scheduled task '$UserContextTaskName'"
}
else {
Write-Host " scheduled task '$UserContextTaskName' not present."
}
}
catch {
Write-Warning " could not remove scheduled task '$UserContextTaskName': $($_.Exception.Message)"
}
# The install's one-shot tray launcher is transient and normally removes itself,
# but an install that failed partway can leave it behind.
try {
if (Get-ScheduledTask -TaskName "ShopDB tray first launch" -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName "ShopDB tray first launch" -Confirm:$false -ErrorAction Stop
Write-Host " removed leftover scheduled task 'ShopDB tray first launch'"
}
}
catch {
Write-Warning " could not remove scheduled task 'ShopDB tray first launch': $($_.Exception.Message)"
}
Remove-HklmBothViews -SubKey "SOFTWARE\GE\ShopDB"
# Remove the All-Users Start Menu shortcut the site-agent installer created.

View File

@@ -110,7 +110,12 @@ from .plugins import plugin_manager
# internals. The journal records the primitive mutations a bulk import makes
# (update a cell, insert a row) tied to a batch id, and a generic undo replays
# them in reverse. Additive names on the surface, minor bump.
__contract_version__ = '0.30.0'
#
# 0.31.0 widens the asset-panel vocabulary a plugin may declare: two more
# tab groups (serviceprocess, reports), a datetime column format, a map body
# key, and a tabs section that renders as a table. All optional, so a plugin
# that declares none of them is unaffected. Additive, minor bump.
__contract_version__ = '0.31.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
@@ -204,6 +209,22 @@ def create_app(config_name: str = None) -> Flask:
# Register core blueprints
register_blueprints(app)
# Finish any restore that is waiting on its schema upgrade. The backup
# sidecar has no Flask, so it loads the dump and leaves a sentinel; this
# container owns the app context that can run the migrations. Started here
# rather than left to a browser poll, because a restore must complete
# whether or not anyone is still watching it.
#
# Skipped unless a backups store actually exists, which keeps it off every
# deployment that does not run the backup overlay - and out of the test
# suite, which builds this app thousands of times.
if not app.config.get('TESTING'):
from .core.api.shopdbbackups import (
_backups_configured, start_restore_finalizer)
with app.app_context():
if _backups_configured():
start_restore_finalizer(app)
# Personal API token auth shim: recognize `Bearer shopdb_pat_...` before
# any JWT decode and mint a request-scoped JWT for the token's owner.
from .utils.apitoken_auth import install_apitoken_auth
@@ -298,6 +319,7 @@ CORE_BLUEPRINT_NAMES = (
'pluginui',
'apitokens',
'docs',
'shopdbbackups',
)

View File

@@ -163,11 +163,14 @@ class Config:
# three different routes, which makes it a property of the app rather than
# of any handler.
#
# 128 MB clears the largest thing anyone legitimately sends - the agent
# binary upload caps itself at 64 MB - with room for the multipart envelope.
# 256 MB clears the largest thing anyone legitimately sends - the agent
# binary upload caps itself at 128 MB - with room for the multipart
# envelope. This must stay STRICTLY ABOVE the agent cap: it bounds the
# whole request (file + boundaries + form fields), so setting the two
# equal would reject a file sitting exactly at the agent limit.
# Over this, Werkzeug answers 413 without reading the body.
MAX_CONTENT_LENGTH = int(
os.environ.get('MAX_CONTENT_LENGTH', 128 * 1024 * 1024))
os.environ.get('MAX_CONTENT_LENGTH', 256 * 1024 * 1024))
class DevelopmentConfig(Config):

View File

@@ -27,6 +27,7 @@ from .setup import setup_bp
from .pluginui import pluginui_bp
from .apitokens import apitokens_bp
from .docs import docs_bp
from .shopdbbackups import shopdbbackups_bp
__all__ = [
'auth_bp',
@@ -56,4 +57,5 @@ __all__ = [
'pluginui_bp',
'apitokens_bp',
'docs_bp',
'shopdbbackups_bp',
]

View File

@@ -25,7 +25,14 @@ pluginui_bp = Blueprint('pluginui', __name__)
#
# 'details' is first and is the fallback: it holds what an asset IS, which is
# what a panel declaring nothing almost always contributes.
ASSET_PANEL_GROUPS = ('details', 'monitors', 'software', 'activity')
#
# This tuple is a THIRD copy of the tab vocabulary - RENDERED_GROUPS in
# PluginAssetPanels.vue and the tab list in PCDetail.vue are the other two.
# Adding a group means touching all three: a value missing here is rewritten to
# 'details' before the client ever sees it, so the panels quietly render in the
# wrong tab and the intended tab never appears. No error, no log line.
ASSET_PANEL_GROUPS = ('details', 'monitors', 'serviceprocess', 'reports',
'software', 'activity')
def _collect(hookname):

View File

@@ -1320,6 +1320,21 @@ def build_default_settings():
'DeviceSettingsMenuBlade/~/overview/aadDeviceId/{deviceid}'
)
},
{
# ADR-015-OK: the DEFAULT is empty, so no cloud is assumed. Same
# reasoning as entra_device_url - the LAPS blade lives in the same
# tenant-specific portal, so the host cannot be a product fact.
'key': 'laps_device_url',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': (
'Azure LAPS (local admin password) URL template. {objectid} is '
'the device object id. Example (US Gov): https://portal.azure.us/'
'?feature.msaljs=true#view/Microsoft_AAD_Devices/'
'DeviceDetailsMenuBlade/~/LocalAdminPassword/objectId/{objectid}'
)
},
{
'key': 'servicenow_ticket_prefixes',
'value': 'GEINC,GECHG,GERIT,GESCT',
@@ -1488,6 +1503,99 @@ def build_default_settings():
'Incoming Webhook), adaptivecard (Teams Workflow), '
'or json (generic {title,text})'
},
# Backup destination settings
#
# These are the ground wiring for where the one-shot backup container
# ships each set. 'local' keeps sets on the mounted backups volume and
# needs no credentials. 'smb' and 'aws' are declared so the UI and the
# backup runner can be configured now; the client libraries (an SMB
# stack, boto3/awscli) are deliberately NOT pulled in yet - those land
# behind the security review that vets third-party deps against
# external exploit. Until then a non-local destination is accepted and
# persisted but the runner treats it as not-yet-armed.
#
# Secrets follow the existing convention: any key containing
# 'password'/'secret'/'token' is masked by _is_secret on the way out
# and preserved when the client submits the mask back unchanged.
{
'key': 'backup_destination_kind',
'value': 'local',
'valuetype': 'string',
'category': 'backup',
'description': 'Where backup sets are shipped: local, smb, or aws. '
'Non-local destinations are wiring-only until the '
'security review clears their client libraries.'
},
{
'key': 'backup_smb_host',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'SMB/CIFS server hostname or IP for the backup share'
},
{
'key': 'backup_smb_share',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'SMB share name (the top-level export, no path)'
},
{
'key': 'backup_smb_path',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'Subpath within the SMB share to write sets into'
},
{
'key': 'backup_smb_username',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'SMB authentication username (DOMAIN\\user allowed)'
},
{
'key': 'backup_smb_password',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'SMB authentication password'
},
{
'key': 'backup_aws_region',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'AWS region for the S3 backup bucket (e.g. us-east-1)'
},
{
'key': 'backup_aws_bucket',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'S3 bucket name that holds backup sets'
},
{
'key': 'backup_aws_prefix',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'Optional key prefix within the S3 bucket'
},
{
'key': 'backup_aws_accesskeyid',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'AWS access key id for the backup bucket'
},
{
'key': 'backup_aws_secretaccesskey',
'value': '',
'valuetype': 'string',
'category': 'backup',
'description': 'AWS secret access key for the backup bucket'
},
# Audit log retention lives at 'audit_retentiondays' (category site),
# defined above and read by shopdb/core/services/auditretention.py. A
# second key 'audit_retention_days' was seeded here with a different

View File

@@ -0,0 +1,799 @@
"""Backups API routes.
Admin-only surface over the site's backup sets. The heavy lifting (mysqldump,
restic snapshot of the instance tree, retention prune, integrity check) runs in
the one-shot backup container defined in docker-compose.backup.yml; this
blueprint is the read-mostly control plane the UI talks to.
Two recovery products stay firewalled, per the design notes:
- code rollback lives in git (tags/releases), NOT here
- data restore lives here, gated on schema compatibility via each set's manifest
Every backup set carries a manifest.json stamped with the core + plugin Alembic
revisions and the running image tag at capture time. A restore is a CHECKED
operation: current code must be at least the set's schema revision, so we never
load rows that reference a column the live schema has not applied yet.
"""
import json
import os
import threading
import time
import subprocess
from datetime import datetime, timezone
from flask import Blueprint, current_app, request
from shopdb.core.models import AuditLog
from shopdb.core.models.setting import Setting
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
shopdbbackups_bp = Blueprint('shopdbbackups', __name__)
# Supported backup destination kinds. 'local' needs no credentials; 'smb' and
# 'aws' are ground-wired here so operators can configure them now, but their
# client libraries are held back until the security review clears them, so the
# runner treats a non-local destination as configured-but-not-yet-armed.
DESTINATION_KINDS = ('local', 'smb', 'aws')
# The setting keys that make up each destination's config, grouped by kind so
# the GET can surface only the relevant fields and the PUT can validate what it
# is allowed to write. Secret keys (password/secret/token in the name) are
# masked by the shared settings serializer, same convention as SMTP/SAML.
DESTINATION_FIELDS = {
'local': [],
'smb': [
'backup_smb_host',
'backup_smb_share',
'backup_smb_path',
'backup_smb_username',
'backup_smb_password',
],
'aws': [
'backup_aws_region',
'backup_aws_bucket',
'backup_aws_prefix',
'backup_aws_accesskeyid',
'backup_aws_secretaccesskey',
],
}
# Keys whose value must never leave the API in the clear. Mirrors the shared
# settings convention (_is_secret) so the two surfaces agree on what is secret.
_SECRET_TOKENS = ('password', 'secret', 'token')
SECRET_MASK = '********'
def _is_secret_key(key):
return any(token in key for token in _SECRET_TOKENS)
# Where the backup container drops each set and its manifest. Mounted read-only
# for the api container so the UI can enumerate sets without being able to
# mutate them; writes happen only from the backup one-shot.
BACKUPS_ROOT_ENV = 'SHOPDB_BACKUPS_ROOT'
DEFAULT_BACKUPS_ROOT = '/backups'
# Retention the backup container enforces; surfaced read-only so the UI can show
# "day 1 of 5 retained" without re-deriving the policy.
RETENTION = {
'keepdaily': 5,
'keepweekly': 4,
'keepmonthly': 6,
}
def _backups_root():
return os.environ.get(BACKUPS_ROOT_ENV, DEFAULT_BACKUPS_ROOT)
def _backups_configured():
"""Whether this deployment has a backup store behind the feature.
The overlay is a compose profile: a third container writes the sets onto a
shared volume, and the api container only ever reads them and drops a
sentinel. A Windows/IIS install has no such container, and the default root
resolves there to a drive-letter path nothing creates - so every button
either 500s on PermissionError or reports 'queued' for a run that will
never happen. Answer the caller honestly instead.
"""
return os.path.isdir(_backups_root())
def _unavailable_response():
# 404 + NOT_FOUND is the established shape for "this feature is not
# enabled here" (the configuration-policy routes answer the same way), and
# the UI already tells a 404 apart from a real failure.
return error_response(
ErrorCodes.NOT_FOUND,
'Site Backups is not configured on this deployment. It needs the '
'backup overlay and a readable backups root (%s).' % BACKUPS_ROOT_ENV,
http_code=404)
def _read_json(path):
"""Load a JSON file, or None when it is absent or unreadable."""
try:
with open(path, 'r', encoding='utf-8') as handle:
return json.load(handle)
except (OSError, ValueError):
return None
def _read_status(kind):
"""Return the live status dict for a run kind, or None when idle."""
root = _backups_root()
return _read_json(os.path.join(root, '.' + kind + '-status.json'))
def _read_history(kind):
"""Return the recorded run history for a kind, newest first."""
root = _backups_root()
entries = _read_json(os.path.join(root, 'history', kind + 's.json'))
if not isinstance(entries, list):
return []
return list(reversed(entries))
def _write_status(kind, phase, setname, message):
"""Seed a live status file so the UI shows movement the instant a run is
queued, before the watcher has picked up the sentinel."""
root = _backups_root()
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
payload = {
'kind': kind,
'phase': phase,
'setname': setname,
'message': message,
'updatedat': stamp,
}
try:
path = os.path.join(root, '.' + kind + '-status.json')
with open(path, 'w', encoding='utf-8') as handle:
json.dump(payload, handle)
except OSError:
pass
def _append_history(kind, outcome, setname, message):
"""Append one run record to the shared history the UI reads, newest last.
Mirrors status.sh's history_append so a finalize that completes in the api
(not the sidecar) still lands in the same history file the restore page
shows. Best-effort: history is a convenience log, never a gate.
"""
root = _backups_root()
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
entry = {
'kind': kind,
'outcome': outcome,
'setname': setname,
'message': message,
'at': stamp,
}
path = os.path.join(root, 'history', kind + 's.json')
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
entries = _read_json(path)
if not isinstance(entries, list):
entries = []
entries.append(entry)
with open(path, 'w', encoding='utf-8') as handle:
json.dump(entries, handle)
except OSError:
pass
# The post-restore upgrade is claimed by renaming the sentinel. rename(2) is
# atomic, so exactly one claimant wins even with several gunicorn workers and a
# browser poll all racing - which matters because the loser would otherwise run
# Alembic concurrently against the same database.
UPGRADE_SENTINEL = '.upgrade-requested'
UPGRADE_CLAIM = '.upgrade-inprogress'
UPGRADE_FAILED = '.upgrade-failed'
def _claim_upgrade():
"""Take exclusive ownership of a pending upgrade, or return None.
Returns the set name on success. Losing the race is not an error: it means
another worker is already finalizing the same restore.
"""
root = _backups_root()
sentinel = os.path.join(root, UPGRADE_SENTINEL)
claim = os.path.join(root, UPGRADE_CLAIM)
try:
os.rename(sentinel, claim)
except OSError:
return None
try:
with open(claim, 'r', encoding='utf-8') as handle:
return handle.read().strip() or 'unknown'
except OSError:
return 'unknown'
def _finalize_claimed(setname):
"""Run the upgrade for a claimed restore and record how it ended.
A failure parks the claim as .upgrade-failed rather than putting it back:
re-arming it here would retry a migration that just failed, every poll,
forever. The operator re-arms it explicitly through the finalize endpoint.
"""
root = _backups_root()
claim = os.path.join(root, UPGRADE_CLAIM)
_write_status('restore', 'upgrading', setname,
'Upgrading restored schema to code')
try:
_run_schema_upgrade()
except Exception as error: # noqa: BLE001 - report any upgrade failure
message = 'Schema upgrade failed: ' + str(error)
try:
os.replace(claim, os.path.join(root, UPGRADE_FAILED))
except OSError:
pass
_write_status('restore', 'failed', setname, message)
_append_history('restore', 'failed', setname, message)
current_app.logger.error('Post-restore schema upgrade failed: %s', error)
return False
try:
os.remove(claim)
except OSError:
pass
_write_status('restore', 'done', setname, 'Restore complete')
_append_history('restore', 'success', setname, 'Restore complete')
current_app.logger.info('Restore finalized: %s', setname)
return True
def start_restore_finalizer(app):
"""Watch for a restore awaiting its schema upgrade, and run it.
WHY THIS EXISTS: the restore itself runs in the Flask-less backup sidecar,
which loads the dump and drops a sentinel because it cannot run
'flask db upgrade'. Until this watcher, the ONLY thing that acted on that
sentinel was the browser poll loop of the modal that queued the restore -
so closing the tab, or letting a token expire, during the minutes a load
takes left the data at the old revision under new code, with every query on
a newer column failing and nothing retrying.
Runs in the api container because that is where the app context and the
engine live, and out of band rather than in a request because Alembic can
outrun the gunicorn worker timeout and get killed mid-chain.
"""
interval = max(1, int(os.environ.get('SHOPDB_FINALIZE_POLL_SECONDS', '10')))
def _loop():
while True:
time.sleep(interval)
try:
with app.app_context():
if not _backups_configured():
continue
setname = _claim_upgrade()
if setname:
_finalize_claimed(setname)
except Exception: # noqa: BLE001 - a watcher must not die
try:
app.logger.exception('Restore finalizer pass failed')
except Exception: # noqa: BLE001
pass
thread = threading.Thread(target=_loop, name='restore-finalizer', daemon=True)
thread.start()
return thread
def _run_schema_upgrade():
"""Bring the just-restored database up to the running code, in-process.
Runs the same two idempotent steps every deploy runs: the core Alembic
upgrade to head, then each plugin chain via the plugin manager. Both are
no-ops when the data already matches the code head, so this is safe to call
even when the restored dump was already current. Uses the live app context
and its configured engine, so no subprocess or extra Flask CLI is needed.
"""
from flask_migrate import upgrade as alembic_upgrade
alembic_upgrade()
manager = current_app.extensions.get('plugin_manager')
if manager is None:
raise RuntimeError('Plugin manager not initialized; cannot upgrade plugins')
results = manager.upgrade_all_plugins()
failed = {name: status for name, status in (results or {}).items()
if status not in ('ok', 'no-migrations')}
if failed:
raise RuntimeError('Plugin upgrade failed: ' + json.dumps(failed))
def _read_manifest(setdir):
"""Load a set's manifest.json, or None when absent/unreadable.
A set without a readable manifest is not restorable (we cannot check its
schema), so callers treat None as "unverified" rather than trusting it.
"""
path = os.path.join(setdir, 'manifest.json')
try:
with open(path, 'r', encoding='utf-8') as handle:
return json.load(handle)
except (OSError, ValueError):
return None
def _current_schema_revisions():
"""Return the live Alembic head revisions (core + plugins) for this build.
Compared against a set's stamped revisions to decide restore compatibility.
Imported lazily so a missing alembic at request time degrades to an empty
map (everything reads as "unknown") instead of 500ing the list endpoint.
"""
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
except ImportError:
return {}
root = current_app.root_path
migrations = os.path.join(os.path.dirname(root), 'migrations')
ini = os.path.join(migrations, 'alembic.ini')
if not os.path.isfile(ini):
return {}
try:
config = Config(ini)
# This is a Flask-Migrate project: alembic.ini carries no
# script_location (it is set at runtime), so ScriptDirectory.from_config
# would raise and leave every set reading as incompatible. Point it at
# the migrations directory - the same versions tree manifest.py scans.
config.set_main_option('script_location', migrations)
script = ScriptDirectory.from_config(config)
return {'core': list(script.get_heads())}
except Exception:
return {}
def _known_core_revisions():
"""Return every core Alembic revision this build knows, heads and ancestors.
Restore compatibility is now data-driven: a set restores when the running
code KNOWS the dump's data revision (so `flask db upgrade` can roll it
forward to head). Heads alone cannot answer that - a dump stamped at an
older-but-valid revision is perfectly restorable - so we walk the whole
script history. Lazy import + broad except keeps a missing/broken alembic
from 500ing the list endpoint (returns empty = everything reads unknown).
"""
try:
from alembic.config import Config
from alembic.script import ScriptDirectory
except ImportError:
return set()
root = current_app.root_path
migrations = os.path.join(os.path.dirname(root), 'migrations')
ini = os.path.join(migrations, 'alembic.ini')
if not os.path.isfile(ini):
return set()
try:
config = Config(ini)
config.set_main_option('script_location', migrations)
script = ScriptDirectory.from_config(config)
return {rev.revision for rev in script.walk_revisions()}
except Exception:
return set()
def _known_plugin_revisions():
"""Every revision each plugin chain knows, as {plugin: {revisions}}.
Read by text scan rather than through Alembic. Each plugin chain has its own
env and version table, so loading them properly would mean building 17
ScriptDirectories on a request that only needs to answer "is this id in
there" - and manifest.py already reads the same trees the same way, so the
two agree by construction.
"""
root = os.path.dirname(current_app.root_path)
plugins_dir = os.path.join(root, 'plugins')
known = {}
if not os.path.isdir(plugins_dir):
return known
for name in sorted(os.listdir(plugins_dir)):
versions = os.path.join(plugins_dir, name, 'migrations', 'versions')
if not os.path.isdir(versions):
continue
revisions = set()
for filename in os.listdir(versions):
if not filename.endswith('.py'):
continue
try:
with open(os.path.join(versions, filename), 'r',
encoding='utf-8') as handle:
for line in handle:
stripped = line.strip()
if stripped.startswith('revision ='):
revisions.add(
stripped.split('=', 1)[1].strip().strip('"\''))
break
except OSError:
continue
if revisions:
known[name] = revisions
return known
def _plugin_data_is_known(manifest):
"""Whether this build knows every plugin revision the set's data sits at.
A set with no `dataplugins` predates the stamp; its plugin revisions are
unknowable, so it keeps the old core-only verdict rather than being marked
unrestorable retroactively.
"""
stamped = manifest.get('dataplugins') or {}
if not isinstance(stamped, dict) or not stamped:
return True
known = _known_plugin_revisions()
for name, revision in stamped.items():
if not revision:
continue
if revision not in known.get(name, ()):
return False
return True
def _is_compatible(manifest, current):
"""Decide whether a set restores onto THIS build.
Data-driven gate (the fix for the FBJXBDB4 outage): the authoritative
signal is the set's `datarev` - the actual `alembic_version` of the dumped
DATABASE, not the code head that captured it. A restore is safe when the
running code KNOWS that revision (it is somewhere in this build's migration
history), because then the post-restore finalize can run `flask db upgrade`
to roll the loaded data forward to head. If the code does not know the
revision, the dump is from a NEWER schema than this build and would 500 on
restored rows, so we block until the code is upgraded.
The same question is asked of every PLUGIN chain, because plugin schemas
move independently: code rolled back to an older plugin revision can sit at
an identical core head while the dump carries plugin tables it cannot
migrate.
Legacy sets predate `datarev`; fall back to the old head-subset check on
`schemarev` so they stay listable and restorable as before.
"""
if not manifest:
return False
# A manifest that CARRIES the key but left it empty is not a legacy set: it
# is a set whose data revision could not be read when it was taken. Falling
# through to the code-to-code check would badge it restorable on no
# evidence, using exactly the comparison the datarev work replaced. Only a
# manifest with no datarev key at all predates the stamp.
if 'datarev' in manifest and not manifest.get('datarev'):
return False
datarev = manifest.get('datarev')
if datarev:
known = _known_core_revisions()
if not known:
return False
# Core AND every plugin chain. Core alone said yes to a set whose plugin
# data the code had never heard of, which loads and then dies during the
# plugin upgrade with the old data already gone.
return datarev in known and _plugin_data_is_known(manifest)
setrevs = set(manifest.get('schemarev', {}).get('core', []))
currevs = set(current.get('core', []))
if not setrevs or not currevs:
return False
# Compatible when the current heads cover every head the set was taken at.
return setrevs.issubset(currevs)
# Reserved entries under the backups root that are not backup sets: the run
# history store and any dotfile sentinels/status files the watcher drops.
_NON_SET_ENTRIES = ('history',)
def _list_sets():
"""Enumerate backup sets newest-first, each summarized for the list view."""
root = _backups_root()
if not os.path.isdir(root):
return []
current = _current_schema_revisions()
sets = []
for name in sorted(os.listdir(root), reverse=True):
if name in _NON_SET_ENTRIES or name.startswith('.'):
continue
setdir = os.path.join(root, name)
if not os.path.isdir(setdir):
continue
manifest = _read_manifest(setdir)
dbdump = os.path.join(setdir, 'db.sql.gz')
sizebytes = 0
for base, _dirs, files in os.walk(setdir):
for filename in files:
try:
sizebytes += os.path.getsize(os.path.join(base, filename))
except OSError:
pass
sets.append({
'snapshotid': name,
'takenat': (manifest or {}).get('takenat'),
'sizebytes': sizebytes,
'schemarev': (manifest or {}).get('schemarev', {}),
'imagetag': (manifest or {}).get('imagetag'),
'ischeckpassed': bool((manifest or {}).get('ischeckpassed')),
'hasmanifest': manifest is not None,
'hasdbdump': os.path.isfile(dbdump),
'iscompatible': _is_compatible(manifest, current),
})
return sets
@shopdbbackups_bp.route('', methods=['GET'])
@require_role('admin')
def list_backups():
"""List every retained backup set, newest first, with a compatibility flag."""
sets = _list_sets()
laststgood = next((s for s in sets if s['ischeckpassed']), None)
meta = {
'retention': RETENTION,
'lastgoodat': (laststgood or {}).get('takenat'),
'count': len(sets),
'configured': _backups_configured(),
}
return success_response(data=sets, meta=meta)
@shopdbbackups_bp.route('/<snapshotid>', methods=['GET'])
@require_role('admin')
def get_backup(snapshotid):
"""Return one set's full manifest plus a resolved compatibility verdict."""
root = _backups_root()
setdir = os.path.join(root, os.path.basename(snapshotid))
if not os.path.isdir(setdir):
return error_response(ErrorCodes.NOT_FOUND, 'Backup set not found',
http_code=404)
manifest = _read_manifest(setdir)
current = _current_schema_revisions()
return success_response(data={
'snapshotid': os.path.basename(snapshotid),
'manifest': manifest,
'currentschemarev': current,
'iscompatible': _is_compatible(manifest, current),
'hasmanifest': manifest is not None,
})
@shopdbbackups_bp.route('', methods=['POST'])
@require_role('admin')
def trigger_backup():
"""Kick off an on-demand backup run.
The api container cannot run docker; it writes a sentinel the host-side
scheduler (or an operator running `docker compose run --rm backup`) picks
up. Returning isrunning=false with a clear message keeps the UI honest
rather than pretending the run started inside this process.
"""
if not _backups_configured():
return _unavailable_response()
root = _backups_root()
try:
os.makedirs(root, exist_ok=True)
sentinel = os.path.join(root, '.backup-requested')
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
with open(sentinel, 'w', encoding='utf-8') as handle:
handle.write(stamp)
except OSError as error:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not queue a backup: ' + str(error),
http_code=500)
_write_status('backup', 'queued', '', 'Backup queued')
AuditLog.log('requested', 'Backup', entityname=stamp)
return success_response(
data={'isrunning': False, 'isqueued': True, 'requestedat': stamp},
message='Backup queued. The backup service runs it on its next pass.')
@shopdbbackups_bp.route('/<snapshotid>/restore', methods=['POST'])
@require_role('admin')
def restore_backup(snapshotid):
"""Queue a restore of a set, gated on schema compatibility.
We refuse outright when the set is incompatible (current code older than the
set's schema) so an operator cannot foot-gun a schema mismatch from the UI.
The actual restore is performed by scripts/backup/restore.sh in the backup
container; here we validate and record intent.
"""
if not _backups_configured():
return _unavailable_response()
root = _backups_root()
setdir = os.path.join(root, os.path.basename(snapshotid))
if not os.path.isdir(setdir):
return error_response(ErrorCodes.NOT_FOUND, 'Backup set not found',
http_code=404)
manifest = _read_manifest(setdir)
current = _current_schema_revisions()
if not _is_compatible(manifest, current):
return error_response(
ErrorCodes.CONFLICT,
'This backup was taken on a newer schema than the running code. '
'Upgrade the code to at least the set\'s revision, run '
'`flask db upgrade` and `flask plugin upgrade-all`, then retry.',
details={'currentschemarev': current,
'setschemarev': (manifest or {}).get('schemarev', {})},
http_code=409)
try:
sentinel = os.path.join(root, '.restore-requested')
stamp = datetime.now(timezone.utc).replace(tzinfo=None).isoformat() + 'Z'
with open(sentinel, 'w', encoding='utf-8') as handle:
handle.write(os.path.basename(snapshotid))
except OSError as error:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not queue the restore: ' + str(error),
http_code=500)
_write_status('restore', 'queued', os.path.basename(snapshotid),
'Restore queued')
AuditLog.log('restore-requested', 'Backup',
entityname=os.path.basename(snapshotid))
return success_response(
data={'isqueued': True, 'snapshotid': os.path.basename(snapshotid)},
message='Restore queued and validated against the live schema.')
@shopdbbackups_bp.route('/finalize-restore', methods=['POST'])
@require_role('admin')
def finalize_restore():
"""Make sure a restore awaiting its schema upgrade is queued to be finalized.
This used to RUN the upgrade inline, which made two things true that should
not have been: a restore only completed while a browser tab stayed open on
it, and the upgrade ran inside a request, where Alembic can outrun the
gunicorn worker timeout and be killed part-way through the chain, leaving a
half-applied revision the next attempt trips over.
The work now belongs to the finalizer watcher in this container
(start_restore_finalizer). This endpoint only ensures something is pending
for it to claim, so it is safe to call repeatedly, and it is how an operator
retries an upgrade that failed.
"""
if not _backups_configured():
return _unavailable_response()
root = _backups_root()
sentinel = os.path.join(root, UPGRADE_SENTINEL)
claim = os.path.join(root, UPGRADE_CLAIM)
failed = os.path.join(root, UPGRADE_FAILED)
if os.path.exists(claim):
return success_response(
data={'queued': True, 'inprogress': True},
message='The schema upgrade is already running.')
if os.path.exists(sentinel):
return success_response(
data={'queued': True, 'inprogress': False},
message='The schema upgrade is queued and will run shortly.')
# A previous attempt failed and parked itself. Re-arming is the retry.
if os.path.exists(failed):
try:
os.replace(failed, sentinel)
except OSError as error:
return error_response(ErrorCodes.INTERNAL_ERROR,
'Could not re-queue the schema upgrade: ' + str(error),
http_code=500)
AuditLog.log('restore-finalize-retried', 'Backup')
return success_response(
data={'queued': True, 'inprogress': False, 'retried': True},
message='The failed schema upgrade has been queued to run again.')
return success_response(
data={'queued': False},
message='No restore is awaiting schema upgrade.')
@shopdbbackups_bp.route('/status', methods=['GET'])
@require_role('admin')
def get_status():
"""Return the live status of the most recent backup and restore runs.
The watcher writes these files as it moves through each phase; the UI polls
this endpoint to drive the restore progress spinner and phase label. Either
value is None when that kind of run has never been requested.
"""
return success_response(data={
'configured': _backups_configured(),
'backup': _read_status('backup'),
'restore': _read_status('restore'),
})
@shopdbbackups_bp.route('/history', methods=['GET'])
@require_role('admin')
def get_history():
"""Return the recorded history of backup and restore runs, newest first.
These are operational run logs written by the backup container onto the
shared volume, not the audit trail (AuditLog records the requested intent).
"""
return success_response(data={
'backups': _read_history('backup'),
'restores': _read_history('restore'),
})
@shopdbbackups_bp.route('/settings', methods=['GET'])
@require_role('admin')
def get_backup_settings():
"""Return destination + retention config for the settings pane.
The destination kind and its (non-secret) fields come from the settings
store so an operator can configure where backups ship without editing env
or redeploying. Secret fields (SMB password, AWS secret key) are never
returned in the clear: we send a mask when a value is set and an empty
string when it is not, matching the shared settings API convention.
'smb' and 'aws' are configurable now but their client libraries are held
back pending the security review, so we flag those destinations as armed:
false to tell the UI the config is stored but not yet acting.
"""
kind = Setting.get('backup_destination_kind', 'local')
if kind not in DESTINATION_KINDS:
kind = 'local'
fields = {}
for key in DESTINATION_FIELDS.get(kind, []):
raw = Setting.get(key, '')
if _is_secret_key(key):
fields[key] = SECRET_MASK if raw else ''
else:
fields[key] = raw or ''
return success_response(data={
'destinationkind': kind,
'destinationkinds': list(DESTINATION_KINDS),
'fields': fields,
'isarmed': kind == 'local',
'retention': RETENTION,
'backupsroot': _backups_root(),
})
@shopdbbackups_bp.route('/settings', methods=['PUT'])
@require_role('admin')
def update_backup_settings():
"""Persist the backup destination kind and its credential/config fields.
Only keys that belong to the chosen destination are accepted, so a stray
payload cannot write arbitrary settings rows through this surface. A secret
field submitted as the mask placeholder means "leave unchanged" - the
client only ever received the mask, so we never overwrite a real secret
with it.
"""
data = request.get_json(silent=True) or {}
kind = data.get('destinationkind', Setting.get('backup_destination_kind', 'local'))
if kind not in DESTINATION_KINDS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'destinationkind must be one of: ' + ', '.join(DESTINATION_KINDS))
Setting.set('backup_destination_kind', kind, valuetype='string',
category='backup')
incoming = data.get('fields', {}) or {}
allowed = set(DESTINATION_FIELDS.get(kind, []))
for key, value in incoming.items():
if key not in allowed:
continue
if _is_secret_key(key) and value == SECRET_MASK:
continue
Setting.set(key, value if value is not None else '',
valuetype='string', category='backup')
AuditLog.log('updated', 'BackupSettings', entityname=kind, changes={
'destinationkind': kind,
})
return get_backup_settings()

View File

@@ -0,0 +1,44 @@
"""The server-side panel group vocabulary must admit every group a plugin uses.
`asset_panels()` resolves a panel's group BEFORE the client sees it, coercing
any unrecognised value to 'details'. That makes ASSET_PANEL_GROUPS a third copy
of the tab vocabulary, alongside RENDERED_GROUPS in PluginAssetPanels.vue and
the tab list in PCDetail.vue. Phase 3 added 'serviceprocess' to the two
frontend copies and missed this one, so both Service/Process panels were
silently relabelled 'details' on the wire and the tab never appeared.
The failure is invisible: no error, no log line, and the panels still render -
just in the wrong tab, underneath the Details cards.
"""
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
from plugins.endpoints.plugin import EndpointsPlugin
def test_every_group_the_endpoints_plugin_declares_survives_resolution():
declared = {
str(panel.get('group') or '').strip().lower()
for panel in EndpointsPlugin().get_asset_panels()
}
declared.discard('')
unknown = declared - set(ASSET_PANEL_GROUPS)
assert not unknown, (
f'panels declare {sorted(unknown)}, which the server rewrites to '
"'details' - the tab will not appear"
)
def test_serviceprocess_is_admitted():
# Named explicitly: the generic test above would also pass if the
# serviceprocess panels were deleted outright.
assert 'serviceprocess' in ASSET_PANEL_GROUPS
def test_reports_is_admitted():
# Same reasoning as serviceprocess above. Operator Reports was moved out of
# System Monitors into a tab of its own, which is the same three-copy
# change that Phase 3 got wrong.
assert 'reports' in ASSET_PANEL_GROUPS
def test_details_remains_the_fallback():
assert ASSET_PANEL_GROUPS[0] == 'details'

View File

@@ -0,0 +1,242 @@
"""The post-restore schema upgrade: who runs it, and what happens when it fails.
A restore loads the dump in the Flask-less backup sidecar and leaves a sentinel,
because only the api container can run the migrations. These tests pin the two
properties that failure taught us to care about: the upgrade must not depend on
a browser staying open, and it must never run twice at once.
"""
import os
import pytest
from shopdb.core.api import shopdbbackups
@pytest.fixture
def backups_root(tmp_path, monkeypatch):
"""Point the backup routes at a real, empty directory."""
root = tmp_path / 'backups'
root.mkdir()
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV, str(root))
return root
def _arm(root, setname='wj-2026-09-06T120000Z'):
(root / shopdbbackups.UPGRADE_SENTINEL).write_text(setname, encoding='utf-8')
return setname
def test_only_one_claimant_wins_a_pending_upgrade(app, backups_root):
"""Two workers racing the same sentinel must not both run Alembic."""
setname = _arm(backups_root)
with app.app_context():
first = shopdbbackups._claim_upgrade()
second = shopdbbackups._claim_upgrade()
assert first == setname
assert second is None, 'a second worker claimed an upgrade already in flight'
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists()
assert (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
def test_claiming_nothing_is_not_an_error(app, backups_root):
with app.app_context():
assert shopdbbackups._claim_upgrade() is None
def test_a_successful_finalize_clears_the_claim_and_says_done(app, backups_root,
monkeypatch):
setname = _arm(backups_root)
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', lambda: None)
with app.app_context():
claimed = shopdbbackups._claim_upgrade()
assert shopdbbackups._finalize_claimed(claimed) is True
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
with app.app_context():
status = shopdbbackups._read_status('restore')
assert status and status['phase'] == 'done'
assert status['setname'] == setname
def test_a_failed_upgrade_parks_itself_instead_of_retrying_forever(app,
backups_root,
monkeypatch):
"""A failing migration must not be re-armed automatically.
Putting the sentinel back would run the same broken chain on every poll for
as long as the container lives.
"""
_arm(backups_root)
def _boom():
raise RuntimeError('Can\'t locate revision identified by abc123')
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', _boom)
with app.app_context():
claimed = shopdbbackups._claim_upgrade()
assert shopdbbackups._finalize_claimed(claimed) is False
assert (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
'a failed upgrade re-armed itself and will retry forever'
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
with app.app_context():
status = shopdbbackups._read_status('restore')
assert status and status['phase'] == 'failed'
def test_the_endpoint_does_not_run_the_upgrade_itself(app, client, auth_headers,
backups_root, monkeypatch):
"""Alembic in a request can outrun the worker timeout and be killed mid-chain."""
_arm(backups_root)
ran = []
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade',
lambda: ran.append(True))
response = client.post('/api/shopdbbackups/finalize-restore',
headers=auth_headers)
assert response.status_code == 200
assert ran == [], 'the request ran the schema upgrade inline'
assert response.get_json()['data']['queued'] is True
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
'the endpoint consumed the sentinel the watcher needs to claim'
def test_the_endpoint_re_arms_a_failed_upgrade(app, client, auth_headers,
backups_root):
"""Retrying a failed upgrade is what this endpoint is for now."""
(backups_root / shopdbbackups.UPGRADE_FAILED).write_text('wj-set',
encoding='utf-8')
response = client.post('/api/shopdbbackups/finalize-restore',
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['retried'] is True
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).read_text() == 'wj-set'
assert not (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
def test_nothing_pending_reports_nothing_pending(app, client, auth_headers,
backups_root):
response = client.post('/api/shopdbbackups/finalize-restore',
headers=auth_headers)
assert response.status_code == 200
assert response.get_json()['data']['queued'] is False
def test_an_unconfigured_deployment_refuses(app, client, auth_headers,
tmp_path, monkeypatch):
"""No store behind the feature means say so, not 500 on a missing path."""
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV,
str(tmp_path / 'nothing-here'))
response = client.post('/api/shopdbbackups/finalize-restore',
headers=auth_headers)
assert response.status_code == 404
class TestPluginChainCompatibility:
"""The restore gate has to see plugin chains, not just the core one.
The failure it exists for: roll code back to an older plugin revision and
the CORE head can be identical, so a core-only gate passes a dump whose
plugin tables the code has never heard of. It loads - destroying what was
there - and only then dies in 'flask plugin upgrade-all'.
"""
def _manifest(self, core_rev, plugins=None):
manifest = {'datarev': core_rev, 'schemarev': {'core': [core_rev]}}
if plugins is not None:
manifest['dataplugins'] = plugins
return manifest
def _real_core_rev(self, app):
with app.app_context():
known = shopdbbackups._known_core_revisions()
assert known, 'expected this build to know its own core revisions'
return sorted(known)[0]
def _real_plugin_rev(self, app):
with app.app_context():
known = shopdbbackups._known_plugin_revisions()
assert known, 'expected this build to have plugin migration chains'
name = sorted(known)[0]
return name, sorted(known[name])[0]
def test_a_set_from_a_newer_plugin_chain_is_refused(self, app):
core = self._real_core_rev(app)
name, _ = self._real_plugin_rev(app)
manifest = self._manifest(core, {name: 'thisrevisiondoesnotexist'})
with app.app_context():
assert shopdbbackups._is_compatible(manifest, {}) is False, \
'a dump carrying unknown plugin data was called restorable'
def test_a_set_whose_plugin_chains_are_known_is_allowed(self, app):
core = self._real_core_rev(app)
name, rev = self._real_plugin_rev(app)
manifest = self._manifest(core, {name: rev})
with app.app_context():
assert shopdbbackups._is_compatible(manifest, {}) is True
def test_a_plugin_this_build_does_not_have_is_refused(self, app):
core = self._real_core_rev(app)
manifest = self._manifest(core, {'nosuchplugin': 'somerevision'})
with app.app_context():
assert shopdbbackups._is_compatible(manifest, {}) is False
def test_a_legacy_set_without_the_stamp_keeps_its_old_verdict(self, app):
"""Sets taken before dataplugins existed must not become unrestorable."""
core = self._real_core_rev(app)
with app.app_context():
assert shopdbbackups._is_compatible(self._manifest(core), {}) is True
assert shopdbbackups._is_compatible(
self._manifest(core, {}), {}) is True
def test_the_core_gate_still_refuses_an_unknown_core_revision(self, app):
name, rev = self._real_plugin_rev(app)
manifest = self._manifest('unknowncorerevision', {name: rev})
with app.app_context():
assert shopdbbackups._is_compatible(manifest, {}) is False
class TestDatarevEvidence:
"""An unreadable data revision must not be badged restorable."""
def _real_core_rev(self, app):
with app.app_context():
known = shopdbbackups._known_core_revisions()
return sorted(known)[0]
def test_an_empty_datarev_is_no_evidence_not_a_legacy_set(self, app):
"""backup.sh discards the mysql error, so datarev can be ''.
Falling back to the code-to-code head check there would badge the set
compatible using the very comparison datarev was introduced to replace.
"""
core = self._real_core_rev(app)
manifest = {'datarev': '', 'schemarev': {'core': [core]}}
with app.app_context():
assert shopdbbackups._is_compatible(
manifest, {'core': [core]}) is False
def test_a_manifest_with_no_datarev_key_still_falls_back(self, app):
"""A genuine legacy set predates the field entirely and keeps working."""
core = self._real_core_rev(app)
manifest = {'schemarev': {'core': [core]}}
with app.app_context():
assert shopdbbackups._is_compatible(
manifest, {'core': [core]}) is True

View File

@@ -94,7 +94,23 @@ def documentation_files():
def test_there_are_docs_to_check():
"""A path change that silently matched nothing would make this suite pass
while checking absolutely nothing."""
while checking absolutely nothing.
FLAG FOR DOC AUTHORS: if this fails with a SMALL number (3, not 0), you have
almost certainly just committed a docs/*.md file. Don't lower the threshold
to make it pass - that is the exact failure this assertion exists to catch,
and it would silently disable the whole scrub suite.
docs/*.md prose is wiki-published and MUST stay untracked. The gates decide
'is this a published checkout?' by the ABSENCE of docs/*.md, so one tracked
prose file makes CI look internal while carrying no prose, and takes down
backend AND gates together. the DEV-BUILD-TEST wiki page and the PLUGIN-HOOKS wiki page
did exactly this and held the branch red for eight runs; both were untracked
(kept on disk) and .gitignore now ignores docs/*.md wholesale.
Authoring a doc is still fine - write it in docs/, it just stays local and
goes to the wiki. docs/notes/ and docs/openapi.json are unaffected.
"""
assert len(documentation_files()) > 20

View File

@@ -121,7 +121,14 @@ def test_a_stated_plugin_count_matches_the_tree():
def test_the_map_itself_is_current():
"""The pointer target has to be right, or every document pointing at it is
wrong at one remove."""
wrong at one remove.
FLAG FOR DOC AUTHORS: if this fails saying PROJECT-MAP.md is missing, the
likely cause is not a missing map - it is that you committed a docs/*.md
file, which stopped this module from skipping as a published checkout. The
map is generated and correctly untracked. See the note in
tests/test_docs_publishable.py::test_there_are_docs_to_check.
"""
mapfile = DOCS / 'PROJECT-MAP.md'
assert mapfile.is_file(), 'the PROJECT-MAP wiki page is missing; run scripts/gen_project_map.py'
text = mapfile.read_text()

View File

@@ -101,7 +101,12 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
# 0010 renames the shipped business policy off the originating site's
# designator on installs that already stamped 0007 (ADR-015).
# 0011 adds the trayversion cache column to endpointstates.
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0011trayversion'
# 0012 adds endpointevents.eventrecordid plus the unique index that dedups a
# replayed Windows event record.
# 0013 adds the inventory columns.
# 0014 adds endpointstates.diskhealthjson, and 0015 adds the system telemetry
# columns (boot info, reboot reasons, Defender health, NIC link state).
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0016dellupdates'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -0,0 +1,90 @@
"""Agent binary upload version gating: PE-fallback and precedence.
The upload route no longer rejects the version at raw input. A typed value is
only a hint; the dotted-numeric FILE version in the binary's PE resource is the
authority. The route falls back to it when the typed value is missing or not
dotted-numeric, and only refuses when neither yields a usable version.
Building a real PE with a RT_VERSION resource inline is impractical (see the
agentstore unit tests), so these API tests monkeypatch read_pe_file_version to
stand in for the parser. PE-magic bytes keep looks_like_pe happy so the request
reaches the version gate.
"""
import io
from plugins.endpoints.api import agentstore
from plugins.endpoints.api import agentupload
from shopdb.config import Config
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
_PE_BYTES = b'MZ' + b'\x00' * 128
def _upload(client, headers, monkeypatch, detected, typed=None):
monkeypatch.setattr(agentstore, 'read_pe_file_version',
lambda data: detected)
form = {'file': (io.BytesIO(_PE_BYTES), 'shopdbagent.exe')}
if typed is not None:
form['version'] = typed
return client.post(
UPLOAD_URL.format('agent'),
data=form,
content_type='multipart/form-data',
headers=headers)
def test_upload_falls_back_to_pe_version_when_typed_is_missing(
client, db, app, auth_headers, monkeypatch):
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0')
assert response.status_code == 200, response.get_json()
payload = response.get_json()['data']
assert payload['current'] == '1.5.0.0'
assert any(v['version'] == '1.5.0.0' for v in payload['versions'])
def test_upload_falls_back_when_typed_is_not_dotted_numeric(
client, db, app, auth_headers, monkeypatch):
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0',
typed='1.5.0+1a1bf0640f956634dada1b34840')
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['current'] == '1.5.0.0'
def test_upload_prefers_valid_typed_version_over_pe(
client, db, app, auth_headers, monkeypatch):
response = _upload(client, auth_headers, monkeypatch, '9.9.9.9',
typed='1.6.3')
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['current'] == '1.6.3'
def test_upload_rejects_when_neither_typed_nor_pe_is_usable(
client, db, app, auth_headers, monkeypatch):
response = _upload(client, auth_headers, monkeypatch, None)
assert response.status_code == 400
assert 'dotted-numeric' in response.get_json()['data']['error']['message']
def test_upload_cap_admits_a_self_contained_single_file_publish():
"""The tray publishes ~69 MB; the cap must clear a real binary.
This is the bug that presented as "upload does not detect version": the
size check returns before the PE is parsed, so an oversized file looks
like a parser failure to the operator. Pinned at 100 MB rather than the
observed 69 MB so runtime growth does not silently re-break it.
"""
assert agentupload._MAX_UPLOAD_BYTES >= 100 * 1024 * 1024
def test_request_cap_stays_above_the_agent_upload_cap():
"""MAX_CONTENT_LENGTH bounds the whole multipart envelope, not the file.
Setting the two equal rejects a file sitting exactly at the agent cap,
because the boundaries and form fields push the request over. They must
move together, with real headroom between them.
"""
envelope = Config.MAX_CONTENT_LENGTH
assert envelope > agentupload._MAX_UPLOAD_BYTES
assert envelope - agentupload._MAX_UPLOAD_BYTES >= 1024 * 1024

View File

@@ -0,0 +1,168 @@
"""Pending Dell Command | Update reporting: ingest and status-card exposure.
The agent's shopdb-dellupdates utility reads the log the \\ShopDB-DellCommandScan
scheduled task writes and reports a short summary on the normal telemetry
check-in. These tests cover the ingest write path and the three-state rendering
on the status panel, which is the part most likely to be broken by a well-meaning
simplification: "never scanned" must NOT render as "No".
"""
import json
from shopdb.core.models import Asset, AssetType
STATUS_URL = '/api/endpoints/asset/{}/status'
def _computer(db, hostname):
"""Create a computer Asset + Computer row; 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()
db.session.add(Computer(assetid=asset.assetid, hostname=hostname,
computertypeid=ctype.computertypeid))
db.session.flush()
return asset
def _checkin(db, hostname, payload):
"""Run a check-in for hostname with the extra payload keys merged in."""
from plugins.endpoints.plugin import EndpointsPlugin
body = {'hostname': hostname}
body.update(payload)
EndpointsPlugin().apply_collector_payload(body)
db.session.commit()
def _status_fields(client, asset, auth_headers):
response = client.get(STATUS_URL.format(asset.assetid), headers=auth_headers)
assert response.status_code == 200
return {f['label']: f['value']
for f in response.get_json()['data']['fields']}
def test_checkin_persists_the_dell_summary(client, db, app, auth_headers):
"""A reported dellupdates payload is stored on the endpoint state."""
from plugins.endpoints.models.endpoint import EndpointState
_computer(db, 'DELL01')
_checkin(db, 'DELL01', {'dellupdates': {
'status': 'ok', 'count': 1, 'truncated': False,
'updates': [{'name': 'Dell BIOS', 'severity': 'Urgent',
'version': '1.2.3'}],
}})
state = EndpointState.query.filter_by(
assetid=Asset.query.filter_by(assetnumber='DELL01').first().assetid
).first()
stored = json.loads(state.dellupdatesjson)
assert stored['count'] == 1
assert stored['updates'][0]['name'] == 'Dell BIOS'
def test_pending_updates_show_yes_with_a_count(client, db, app, auth_headers):
"""A scan with pending items renders Yes plus how many."""
asset = _computer(db, 'DELL02')
_checkin(db, 'DELL02', {'dellupdates': {
'status': 'ok', 'count': 3, 'truncated': False, 'updates': [],
}})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Yes (3)'
def test_a_clean_scan_shows_no(client, db, app, auth_headers):
"""A scan that found nothing renders No, not a hidden row."""
asset = _computer(db, 'DELL03')
_checkin(db, 'DELL03', {'dellupdates': {
'status': 'ok', 'count': 0, 'truncated': False, 'updates': [],
}})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'No'
def test_a_never_scanned_endpoint_says_so(client, db, app, auth_headers):
"""Never scanned renders as its own visible state, not as No and not hidden.
Two failure modes are being guarded at once: showing 'No' would claim the
machine is current when nobody ever checked, and hiding the row entirely
reads to an operator as a broken panel.
"""
asset = _computer(db, 'DELL04')
_checkin(db, 'DELL04', {})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Never scanned'
def test_an_unknown_status_says_never_scanned(client, db, app, auth_headers):
"""The utility's own 'never scanned' answer renders as never scanned."""
asset = _computer(db, 'DELL05')
_checkin(db, 'DELL05', {'dellupdates': {
'status': 'unknown', 'count': 0, 'truncated': False, 'updates': [],
}})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Never scanned'
def test_a_failed_scan_read_is_distinct_from_never_scanned(client, db, app,
auth_headers):
"""An unreadable scan log is its own state, not silence and not clean."""
asset = _computer(db, 'DELL06')
_checkin(db, 'DELL06', {'dellupdates': {
'status': 'error', 'count': 0, 'truncated': False, 'updates': [],
}})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Unknown (scan unreadable)'
def test_a_malformed_blob_does_not_break_the_panel(client, db, app, auth_headers):
"""A corrupt cached payload degrades to the unknown state, not a 500."""
from plugins.endpoints.models.endpoint import EndpointState
asset = _computer(db, 'DELL07')
_checkin(db, 'DELL07', {})
state = EndpointState.query.filter_by(assetid=asset.assetid).first()
state.dellupdatesjson = 'not json at all'
db.session.commit()
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Never scanned'
def test_an_omitted_key_preserves_the_last_result(client, db, app, auth_headers):
"""A check-in without the key does not erase a previously reported scan.
The agent omits dellupdates entirely when the collector utility is absent,
so overwriting to null would discard a still-valid result.
"""
asset = _computer(db, 'DELL08')
_checkin(db, 'DELL08', {'dellupdates': {
'status': 'ok', 'count': 2, 'truncated': False, 'updates': [],
}})
_checkin(db, 'DELL08', {})
fields = _status_fields(client, asset, auth_headers)
assert fields['Pending Dell updates'] == 'Yes (2)'

View File

@@ -0,0 +1,108 @@
"""Disk health rows: a dying drive must never render as a healthy one.
The agent reports drive health from two sources because neither covers the
fleet. MSFT_PhysicalDisk is the only one that reports NVMe - verified on a
two-NVMe box where the classic ATA SMART class returned zero instances while
both drives were present and healthy. Win32_DiskDrive covers ATA/SATA and hosts
where the Storage namespace is missing.
That means the panel can receive the same physical drive twice, from sources
that may disagree, plus rows where health could not be read at all. Every test
here pins a way this card could start showing a failing or unreadable drive as
fine, which is the only failure mode that matters: nobody investigates a disk
that looks healthy.
"""
from plugins.endpoints.api.routes import _diskhealth_rows
def _drive(deviceid, predictfailure, healthstatus='Healthy',
source='physicaldisk', model='Samsung SSD'):
return {
'deviceid': deviceid,
'model': model,
'mediatype': 'SSD',
'bustype': 'NVMe',
'healthstatus': healthstatus,
'predictfailure': predictfailure,
'source': source,
}
def test_a_failing_drive_sorts_above_healthy_ones():
"""On a multi-disk box the bad drive must not be below the fold."""
rows = _diskhealth_rows([
_drive('0', False),
_drive('1', True, healthstatus='Unhealthy'),
_drive('2', False),
])
assert [row['deviceid'] for row in rows] == ['1', '0', '2']
assert rows[0]['verdict'] == 'Failure predicted'
def test_a_drive_that_did_not_answer_reads_as_unknown_not_ok():
"""Null predictfailure is an unread drive, not a healthy one."""
rows = _diskhealth_rows([_drive('0', None, healthstatus='')])
assert rows[0]['verdict'] == 'Unknown'
assert rows[0]['healthstatus'] == 'Unknown'
def test_unknown_outranks_ok_so_silence_is_not_buried_under_health():
"""A drive that stopped answering is a weaker claim than one saying OK."""
rows = _diskhealth_rows([
_drive('0', False),
_drive('1', None),
])
assert [row['verdict'] for row in rows] == ['Unknown', 'OK']
def test_both_sources_survive_for_the_same_hardware():
"""Two disagreeing views of one drive is the signal, not noise.
Collapsing them would hide the case worth looking at: one source calling a
drive healthy while the other predicts failure.
"""
rows = _diskhealth_rows([
_drive('0', False, source='physicaldisk'),
_drive('0', True, healthstatus='Pred Fail', source='diskdrive'),
])
assert len(rows) == 2
assert {row['source'] for row in rows} == {'physicaldisk', 'diskdrive'}
assert rows[0]['verdict'] == 'Failure predicted'
def test_input_order_does_not_change_which_drive_is_shown_first():
"""The verdict ranking must decide the order, not arrival order."""
healthy = _drive('0', False)
failing = _drive('1', True)
forward = _diskhealth_rows([healthy, failing])
backward = _diskhealth_rows([failing, healthy])
assert forward == backward
assert forward[0]['deviceid'] == '1'
def test_an_nvme_drive_is_reported_rather_than_dropped():
"""The whole reason for the second source: ATA SMART returns nothing here."""
rows = _diskhealth_rows([_drive('0', False, source='physicaldisk')])
assert rows[0]['bustype'] == 'NVMe'
assert rows[0]['verdict'] == 'OK'
def test_a_malformed_payload_does_not_take_the_panel_down():
rows = _diskhealth_rows([_drive('0', False), 'not a dict', None])
assert len(rows) == 1
assert rows[0]['deviceid'] == '0'
def test_a_missing_or_wrong_shaped_payload_is_an_empty_panel():
assert _diskhealth_rows(None) == []
assert _diskhealth_rows({}) == []
assert _diskhealth_rows([]) == []

View File

@@ -0,0 +1,138 @@
"""IP History and IP Configuration, carved out of the old Details Network card.
These read core Communication rows, which accumulate as an ACCIDENTAL history:
_record_interfaces matches an incoming NIC on MAC then on IP and inserts when
neither matches, and never deletes. So an address the machine has not held for
a year sits next to the current one. These tests pin the reading of that, not
the collecting.
The date columns are the delicate part. modifieddate only moves when a field on
the row actually changes, so an address still held and reported unchanged keeps
an old stamp. It is labelled 'Last changed' for that reason and must not drift
back to 'Last seen'.
"""
from datetime import datetime
from pathlib import Path
from plugins.endpoints.api.routes import (
_IPHISTORY_COLUMNS,
_iphistory_status,
_isostamp,
)
class FakeComm:
"""Only the columns these two routes read."""
def __init__(self, ipaddress='10.0.0.1', macaddress='AA:BB',
isprimary=False, ismachinenetwork=False, isdhcp=False,
subnetmask='', gateway='', dns1='', dns2='',
createddate=None, modifieddate=None):
self.ipaddress = ipaddress
self.macaddress = macaddress
self.isprimary = isprimary
self.ismachinenetwork = ismachinenetwork
self.isdhcp = isdhcp
self.subnetmask = subnetmask
self.gateway = gateway
self.dns1 = dns1
self.dns2 = dns2
self.createddate = createddate
self.modifieddate = modifieddate
def test_an_apipa_address_is_named_as_a_dhcp_failure():
# 169.254.x.x is not an address the machine was given - it is what Windows
# assigns when DHCP failed. Calling it 'Previous' would file a past network
# fault as a past subnet.
assert _iphistory_status(FakeComm(ipaddress='169.254.122.88')) == 'APIPA (no DHCP)'
def test_apipa_wins_over_primary():
# A box currently failing DHCP can have its APIPA address flagged primary
# by the collector. The fault is the more useful thing to say.
comm = FakeComm(ipaddress='169.254.1.1', isprimary=True)
assert _iphistory_status(comm) == 'APIPA (no DHCP)'
def test_a_normal_address_is_not_mistaken_for_apipa():
# Guards a substring match: 169.254 must be a prefix, not found anywhere.
assert _iphistory_status(FakeComm(ipaddress='10.169.254.5')) == 'Previous'
def test_the_primary_address_is_marked():
assert _iphistory_status(FakeComm(isprimary=True)) == 'Primary'
def test_a_machine_network_address_is_distinguished_from_a_stale_one():
# ismachinenetwork means it talks to the machine, not that it is old.
assert _iphistory_status(FakeComm(ismachinenetwork=True)) == 'Machine network'
def test_everything_else_reads_as_previous():
assert _iphistory_status(FakeComm()) == 'Previous'
def test_a_missing_timestamp_yields_null_not_a_crash():
# createddate is nullable on legacy rows imported before BaseModel stamps.
assert _isostamp(None) is None
def test_a_timestamp_is_emitted_as_utc_iso():
# Matches every other timestamp this file emits; the client formats it to
# the viewer's locale rather than being pinned to the server's.
stamp = _isostamp(datetime(2026, 9, 5, 11, 30, 0))
assert stamp == '2026-09-05T11:30:00Z'
def test_the_second_date_is_not_labelled_last_seen():
# The whole reason for the label. modifieddate cannot support 'last seen':
# an unchanged address reported daily keeps a months-old stamp.
labels = {col['key']: col['label'] for col in _IPHISTORY_COLUMNS}
assert labels['lastchanged'] == 'Last changed'
assert 'seen' not in labels['lastchanged'].lower()
assert labels['firstseen'] == 'First seen'
def test_the_history_ordering_compiles_on_mysql():
# Regression: the first cut ordered with .nullslast(), which is valid
# ANSI/Postgres but blows up on MySQL with a 1064 syntax error. The test
# suite runs on SQLite, so it passed and the page still 500'd in the
# browser. Compile the real ordering against the MySQL dialect so the
# dialect gap is caught here instead of in a container log.
from sqlalchemy import select
from sqlalchemy.dialects import mysql
from shopdb.api import Communication
ordering = (select(Communication.ipaddress)
.where(Communication.assetid == 1)
.order_by(Communication.modifieddate.is_(None),
Communication.modifieddate.desc(),
Communication.communicationid.desc()))
compiled = str(ordering.compile(dialect=mysql.dialect()))
assert 'NULLS LAST' not in compiled.upper()
def test_every_panel_handler_still_has_a_route():
# Regression: inserting the ipconfig route directly above panel_applications
# ate that function's @endpoints_bp.route line, leaving a handler decorated
# with require_permission only. It imported fine and every test passed; the
# endpoint was simply gone. Assert each panel_* handler is actually mapped.
import re
from plugins.endpoints.api import routes as routes_module
source = Path(routes_module.__file__).read_text(encoding='utf-8')
handlers = re.findall(r'^def (panel_\w+)', source, re.MULTILINE)
assert handlers, 'expected panel handlers in routes.py'
routed = set()
for match in re.finditer(r'^@endpoints_bp\.route\(', source, re.MULTILINE):
tail = source[match.start():]
found = re.search(r'^def (\w+)', tail, re.MULTILINE)
if found:
routed.add(found.group(1))
assert set(handlers) - routed == set()

View File

@@ -0,0 +1,49 @@
"""NIC link label: an unknown link must never read as a working one.
Link speed and duplex are paired onto the interface rows from a second WMI
class, so three states are possible: read successfully, unreadable, and
disconnected. Verified on a live box that a DISCONNECTED adapter returns empty
Speed and FullDuplex - two of four NICs on the test machine were in that state.
That is the trap these tests pin. Defaulting an absent speed to 0 or an absent
duplex to False would report every unplugged NIC as half duplex, which is the
exact signature of the cable/port fault this field exists to catch. A column
that cries fault on every disconnected NIC is a column nobody reads.
"""
from plugins.endpoints.api.routes import _link_label
def test_a_gigabit_full_duplex_link_is_not_annotated():
"""The normal case should be quiet so the abnormal case stands out."""
assert _link_label(1000000000, True) == '1 Gb'
def test_half_duplex_is_called_out_because_it_is_the_actionable_case():
assert _link_label(100000000, False) == '100 Mb half duplex'
def test_a_disconnected_adapter_reads_as_unknown_not_half_duplex():
"""The live-box case: disconnected NICs return empty speed and duplex.
Reading this as half duplex would flag every unplugged NIC as a fault.
"""
assert _link_label(None, None) == '-'
def test_an_unreadable_duplex_on_a_live_link_is_not_assumed_half():
"""Speed known, duplex not. Unknown duplex must not render as a fault."""
assert _link_label(10000000000, None) == '10 Gb'
def test_zero_speed_is_unknown_rather_than_a_zero_speed_link():
assert _link_label(0, True) == '-'
def test_sub_gigabit_speeds_stay_in_megabits():
assert _link_label(10000000, True) == '10 Mb'
def test_speeds_do_not_render_trailing_zeros():
"""2.5Gb NICs are common now; the label should not read '2.5000 Gb'."""
assert _link_label(2500000000, True) == '2.5 Gb'

View File

@@ -0,0 +1,186 @@
"""Mapped-drive merge: two disagreeing Windows sources into one honest row.
Windows reports mapped drives twice - Win32_NetworkConnection (the 'net use'
table) and Get-SmbMapping - and the two do not agree. Captured on one machine at
one moment, same two mappings, Get-SmbMapping said OK for both while net use
said Disconnected and Reconnecting. Trusting the structured source alone would
have rendered a healthy Q: on a box whose Q: was down.
So the probe collects both without reconciling them and _mapped_drives() decides
what an operator is shown. Every test here pins a way that decision could
silently start rendering a broken drive as a working one, which is the only
failure mode of this card that matters: nobody reports a drive that looks fine.
"""
from plugins.endpoints.api.routes import _mapped_drives
def _netuse(localname, remotename, status):
return {'localname': localname, 'remotename': remotename, 'status': status}
def _smb(localpath, remotepath, status):
return {'localpath': localpath, 'remotepath': remotepath, 'status': status}
def test_the_worse_of_two_disagreeing_sources_is_what_gets_shown():
"""The real capture: Get-SmbMapping says OK, net use says Disconnected.
The drive was down. Showing OK because one source said so is the exact bug
this merge exists to prevent.
"""
rows = _mapped_drives({
'netuse': [_netuse('Q:', r'\\server\specdata', 'Disconnected')],
'smbmapping': [_smb('Q:', r'\\server\specdata', 'OK')],
})
assert rows == [r'Q: \\server\specdata (Disconnected)']
def test_reconnecting_is_worse_than_ok_and_survives_the_merge():
"""Reconnecting is a real status from the capture, not a synonym for OK."""
rows = _mapped_drives({
'netuse': [_netuse('S:', r'\\server\shared', 'Reconnecting')],
'smbmapping': [_smb('S:', r'\\server\shared', 'OK')],
})
assert rows == [r'S: \\server\shared (Reconnecting)']
def test_a_drive_both_sources_call_healthy_is_not_annotated():
"""Only problems are labelled; tagging every drive '(OK)' buries the bad one."""
rows = _mapped_drives({
'netuse': [_netuse('H:', r'\\server\home', 'OK')],
'smbmapping': [_smb('H:', r'\\server\home', 'OK')],
})
assert rows == [r'H: \\server\home']
def test_a_drive_only_one_source_knows_about_is_still_reported():
"""Get-SmbMapping is absent on some builds and session types.
Requiring agreement between the sources would hide every mapped drive on
those machines rather than showing what is known about them.
"""
rows = _mapped_drives({
'netuse': [_netuse('P:', r'\\server\programs', 'Disconnected')],
'smbmapping': [],
})
assert rows == [r'P: \\server\programs (Disconnected)']
def test_the_smbmapping_key_missing_entirely_degrades_to_netuse():
"""Old agents predate the second source; the panel must still work.
The server ships ahead of the agent republish, so this is the live shape
until every endpoint has the new probe.
"""
rows = _mapped_drives({
'netuse': [_netuse('Q:', r'\\server\specdata', 'OK')],
})
assert rows == [r'Q: \\server\specdata']
def test_a_drive_with_no_status_from_either_source_reads_as_unknown():
"""A blank annotation reads as healthy, so a failed check must not be blank."""
rows = _mapped_drives({
'netuse': [_netuse('Z:', r'\\server\scratch', None)],
'smbmapping': [],
})
assert rows == [r'Z: \\server\scratch (Unknown)']
def test_an_unrecognised_status_is_not_assumed_healthy():
"""Unknown vocabulary ranks below OK deliberately.
Paired against OK specifically: a status the server does not recognise must
LOSE to a healthy one, or an unseen fault renders green. Pairing it against
a known-bad status would prove nothing, since the known-bad value would win
however unknowns are ranked.
"""
rows = _mapped_drives({
'netuse': [_netuse('X:', r'\\server\thing', 'SomeNewStatus')],
'smbmapping': [_smb('X:', r'\\server\thing', 'OK')],
})
assert rows == [r'X: \\server\thing (SomeNewStatus)']
def test_a_known_bad_status_beats_an_unrecognised_one():
"""Unavailable outranks an unknown string; the worst named state wins."""
rows = _mapped_drives({
'netuse': [_netuse('X:', r'\\server\thing', 'Unavailable')],
'smbmapping': [_smb('X:', r'\\server\thing', 'SomeNewStatus')],
})
assert rows == [r'X: \\server\thing (Unavailable)']
def test_which_source_reported_a_status_does_not_change_the_verdict():
"""The same pair of statuses must resolve identically whichever side sent it.
_worse_drive_status ranks by severity and breaks ties by argument order, so
if two statuses ever rank EQUAL the answer silently depends on which source
happened to report which - and a drive would read healthy or broken based on
enumeration order. Asserting both directions is what makes an accidental tie
visible; a single-direction test passes on luck.
"""
remote = r'\\server\thing'
pairs = [
('OK', 'Disconnected'),
('OK', 'SomeNewStatus'),
('Reconnecting', 'Unavailable'),
('OK', 'Reconnecting'),
]
for first, second in pairs:
forward = _mapped_drives({
'netuse': [_netuse('X:', remote, first)],
'smbmapping': [_smb('X:', remote, second)],
})
backward = _mapped_drives({
'netuse': [_netuse('X:', remote, second)],
'smbmapping': [_smb('X:', remote, first)],
})
assert forward == backward, (
'statuses {!r}/{!r} resolve differently depending on which source '
'reported them'.format(first, second))
def test_the_two_sources_are_paired_by_drive_letter_not_by_order():
"""The sources enumerate independently and need not agree on ordering."""
rows = _mapped_drives({
'netuse': [
_netuse('Q:', r'\\server\specdata', 'OK'),
_netuse('S:', r'\\server\shared', 'OK'),
],
'smbmapping': [
_smb('S:', r'\\server\shared', 'Disconnected'),
_smb('Q:', r'\\server\specdata', 'OK'),
],
})
assert rows == [
r'Q: \\server\specdata',
r'S: \\server\shared (Disconnected)',
]
def test_no_user_context_at_all_yields_no_rows():
"""Nobody signed in is a valid state, not a fault."""
assert _mapped_drives({}) == []
def test_malformed_entries_are_skipped_rather_than_raising():
"""The payload is agent-supplied; a bad row must not blank the whole card."""
rows = _mapped_drives({
'netuse': ['not-a-dict', None, {'status': 'OK'},
_netuse('Q:', r'\\server\specdata', 'Disconnected')],
'smbmapping': 'not-a-list',
})
assert rows == [r'Q: \\server\specdata (Disconnected)']

View File

@@ -0,0 +1,118 @@
"""The mapped drives card: structured rows, badges, and row order.
The merge logic itself is pinned in test_endpoints_mapped_drives.py. These
tests cover what the CARD adds on top: a per-drive badge colour and the row
order that decides what an operator sees first.
The failure mode this guards is silent: a broken drive rendered green, or
sorted below the fold, leaves the card looking healthy and tells nobody.
"""
from plugins.endpoints.api.routes import (
_mapped_drive_rows, _mapped_drives, _mapped_drives_unhealthy_first,
)
def _context(netuse=None, smbmapping=None):
context = {}
if netuse is not None:
context['netuse'] = netuse
if smbmapping is not None:
context['smbmapping'] = smbmapping
return context
def _netuse(local, remote, status):
return {'localname': local, 'remotename': remote, 'status': status}
def test_a_healthy_drive_is_green():
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]))
assert rows[0]['statecolor'] == 'green'
assert rows[0]['statuslabel'] == 'OK'
def test_a_disconnected_drive_is_red():
rows = _mapped_drive_rows(
_context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')]))
assert rows[0]['statecolor'] == 'red'
def test_connected_counts_as_healthy_alongside_ok():
"""net use and Get-SmbMapping use different words for the same state."""
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Connected')]))
assert rows[0]['statecolor'] == 'green'
def test_a_drive_with_no_status_is_red_not_green():
"""An uncoloured row reads as working. Unknown health is not health."""
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', None)]))
assert rows[0]['statuslabel'] == 'Unknown'
assert rows[0]['statecolor'] == 'red'
def test_an_unrecognised_status_is_not_coloured_green():
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Weird')]))
assert rows[0]['statecolor'] == 'red'
def test_the_worse_of_two_sources_drives_the_badge_colour():
"""The merge already picks the worse status; the badge must follow it."""
rows = _mapped_drive_rows(_context(
netuse=[_netuse('Z:', r'\\srv\share', 'OK')],
smbmapping=[{'localpath': 'Z:', 'remotepath': r'\\srv\share',
'status': 'Disconnected'}],
))
assert len(rows) == 1
assert rows[0]['statecolor'] == 'red'
def test_the_remote_path_is_carried_as_its_own_field():
"""The card shows the path separately rather than glued into the title."""
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]))
assert rows[0]['drive'] == 'Z:'
assert rows[0]['remote'] == r'\\srv\share'
def test_the_summary_line_and_the_card_agree_about_a_broken_drive():
"""Both are built from the same rows, so they cannot drift apart."""
context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')])
assert _mapped_drive_rows(context)[0]['statecolor'] == 'red'
assert '(Disconnected)' in _mapped_drives(context)[0]
def test_a_healthy_drive_is_not_annotated_in_the_summary_line():
context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')])
assert _mapped_drives(context) == [r'Z: \\srv\share']
def test_no_user_context_yields_no_rows():
assert _mapped_drive_rows({}) == []
def test_broken_drives_sort_above_healthy_ones():
"""The broken one must be on screen without scrolling past nine good ones."""
rows = _mapped_drive_rows(_context(netuse=[
_netuse('H:', r'\\srv\home', 'OK'),
_netuse('S:', r'\\srv\shared', 'OK'),
_netuse('Z:', r'\\srv\dead', 'Disconnected'),
]))
ordered = _mapped_drives_unhealthy_first(rows)
assert [row['drive'] for row in ordered] == ['Z:', 'H:', 'S:']
def test_drive_letter_order_survives_within_each_health_group():
"""Sorting only on health keeps the list from reshuffling on refresh.
The unhealthy drives here carry DIFFERENT status words, chosen so that
sorting on the status text would reorder them (Disconnected before
Unavailable) against drive-letter order. A secondary sort key would pass
a test where every broken drive said the same thing.
"""
rows = _mapped_drive_rows(_context(netuse=[
_netuse('A:', r'\\srv\a', 'OK'),
_netuse('B:', r'\\srv\b', 'Unavailable'),
_netuse('C:', r'\\srv\c', 'Connected'),
_netuse('D:', r'\\srv\d', 'Disconnected'),
]))
ordered = _mapped_drives_unhealthy_first(rows)
assert [row['drive'] for row in ordered] == ['B:', 'D:', 'A:', 'C:']

View File

@@ -0,0 +1,74 @@
"""The Monitors tab split: a panel in a group no tab renders disappears.
This has silently eaten cards twice - once when tracked services moved out of
'actions', once on the 'metrics' to 'monitors' rename. Both times the backend
named a group the frontend did not render, and the panel was not shown, not
logged, and not missed until someone went looking for it.
The split is only correct if four places agree, and only two of them are Python:
1. the panels' group here
2. RENDERED_GROUPS in PluginAssetPanels.vue
3. the tab label map in PCDetail.vue
4. the v-show block that gives the tab a layout
These tests pin the Python half so a later edit cannot quietly move a panel into
a group nothing renders. The frontend half is pinned by its own spec.
"""
from plugins.endpoints.plugin import EndpointsPlugin
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
# Derived from the server tuple rather than hand-copied. This used to be a
# literal set duplicating RENDERED_GROUPS, which made it a FIFTH copy of the
# vocabulary and one more thing to forget: it passed for a group the server
# would rewrite to 'details' anyway. ASSET_PANEL_GROUPS is the value that
# actually decides what reaches the client, and panelGroups.spec.js pins it
# against the frontend copy.
RENDERED_GROUPS = set(ASSET_PANEL_GROUPS)
def _panels():
return {panel['id']: panel for panel in EndpointsPlugin().get_asset_panels()}
def test_no_panel_lands_in_a_group_the_frontend_does_not_render():
"""The failure mode that ate cards twice: a group with no tab."""
for panel in EndpointsPlugin().get_asset_panels():
group = panel.get('group', 'details')
assert group in RENDERED_GROUPS, (
f"panel {panel['id']} is in group {group!r}, which no tab renders"
)
def test_tracked_services_and_processes_moved_to_their_own_group():
panels = _panels()
assert panels['endpoints-tracked-services']['group'] == 'serviceprocess'
assert panels['endpoints-tracked-processes']['group'] == 'serviceprocess'
def test_the_service_process_tab_is_not_left_empty():
"""A tab whose panels all moved away reads as broken, not as absent."""
groups = [panel.get('group', 'details')
for panel in EndpointsPlugin().get_asset_panels()]
assert 'serviceprocess' in groups
def test_system_monitors_keeps_panels_of_its_own():
"""The rename is only safe if Monitors still has content after the move."""
groups = [panel.get('group', 'details')
for panel in EndpointsPlugin().get_asset_panels()]
assert 'monitors' in groups
def test_disk_health_stayed_with_the_system_health_panels():
"""Disk health is the box's own health, not something running on it.
It is now a section of the Storage card rather than a panel of its own,
so the group assertion follows it there.
"""
assert 'endpoints-diskhealth' not in _panels()
assert _panels()['endpoints-storage']['group'] == 'monitors'

View File

@@ -69,12 +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) == 7
assert len(panels) == 14
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/')
assert '{assetid}' in panel['endpoint']
assert panel['render'] in ('keyvalue', 'list', 'table', 'operatorreports')
assert panel['render'] in ('keyvalue', 'list', 'table', 'tabs', 'operatorreports')
def test_services_and_processes_are_split_into_two_filtered_cards():
@@ -220,18 +220,21 @@ def test_known_payload_keys_cover_persisted_and_relayed_fields():
assert persisted_or_relayed <= KNOWN_PAYLOAD_KEYS
def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
"""Disk/bios/memory inventory is collected at the agent before the server
persists it. Those keys must be OUTSIDE the known set so ingest logs them
rather than dropping them silently."""
def test_hardware_telemetry_keys_are_persisted_not_merely_logged():
"""Disk/bios/memory/processor/osinfo inventory was collected at the agent and
logged as unknown keys for several versions while the shapes were proven
against real endpoint traffic. They have columns now (endpoints migration
0013), so they must be INSIDE the known set - leaving them out would log
every report as carrying unpersisted keys while the ingest quietly stores
them, which is the most misleading combination available."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
assert key not in KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'pendingreboot', 'usercontext'):
assert key in KNOWN_PAYLOAD_KEYS
# 'applications' USED to be in this list. It is persisted now - that is what
# the installed-software work added - so it belongs in the known set, and
# asserting its absence would fail the moment the feature it tests works.
# 'applications' followed this same staging path earlier and is likewise
# persisted, to its own endpointapplications table rather than a state blob.
assert 'applications' in KNOWN_PAYLOAD_KEYS

View File

@@ -0,0 +1,51 @@
"""The Storage panel serves volumes and disk health as two sections.
Visual grouping only. The two tables share no key - storage is per volume and
health is per physical device - so nothing is reconciled across them. These
tests pin that the sections stay independent and that neither disappears when
empty, because a dropped section takes its tab with it and reads as "no disks"
rather than "nothing was reported".
"""
import pytest
from plugins.endpoints.api.routes import _storage_rows, _STORAGE_COLUMNS, _DISKHEALTH_COLUMNS
def test_freepercent_is_derived_when_an_older_agent_omits_it():
rows = _storage_rows([{'drive': 'C:', 'sizebytes': 1000, 'freebytes': 250}])
assert rows[0]['freepercent'] == '25.0%'
def test_an_agent_supplied_freepercent_is_preferred_over_recomputing():
rows = _storage_rows([
{'drive': 'C:', 'sizebytes': 1000, 'freebytes': 250, 'freepercent': 24.9},
])
assert rows[0]['freepercent'] == '24.9%'
def test_a_zero_size_volume_does_not_divide_by_zero():
rows = _storage_rows([{'drive': 'C:', 'sizebytes': 0, 'freebytes': 0}])
assert rows[0]['freepercent'] is None
def test_free_percent_is_none_rather_than_zero_when_free_is_unknown():
"""Zero free space and unknown free space must not look identical."""
rows = _storage_rows([{'drive': 'C:', 'sizebytes': 1000}])
assert rows[0]['freepercent'] is None
def test_non_dict_entries_are_skipped():
rows = _storage_rows([{'drive': 'C:', 'sizebytes': 1000, 'freebytes': 500}, 'junk', None])
assert len(rows) == 1
@pytest.mark.parametrize('value', [None, {}, 'x'])
def test_a_missing_snapshot_yields_no_rows(value):
assert _storage_rows(value) == []
def test_the_two_section_column_sets_share_no_key():
"""The point of two sections rather than one merged table."""
storage = {column['key'] for column in _STORAGE_COLUMNS}
health = {column['key'] for column in _DISKHEALTH_COLUMNS}
assert not storage & health