Fix endpoint user-context collection running in the wrong session
Mapped drives reported Unavailable on endpoints where the operator could see them fine. The data path was intact; the collection was running under the wrong token. Mapped drives are per logon token, so an elevated probe enumerates a different drive table than the interactive user. Collection: - Pin the user-context spool path so the probe and service agree on one location, and persist/overwrite it instead of deleting on read. - Refuse to collect while elevated (exit code 2) rather than emitting a confident wrong answer, and stamp elevated/username on the spool. - Reject spools stamped elevated or from an unexpected account on read, so an admin run cannot masquerade as valid. - Log netuse/smbmapping/userlistener counts so each run leaves evidence of what it actually saw. Install and launch context: - Move collection to a 'ShopDB user-context probe' scheduled task under S-1-5-32-545 at RunLevel Limited, the only mechanism that reliably runs in the operator's own session. Verify registration and record it in the install ledger. - Stop starting the tray as a child of the elevated installer; explorer.exe de-elevates without changing ACCOUNT, so an admin install produced an admin tray. The Run key only fires at logon, which left the tray dead on a machine already signed in, so add a transient 'ShopDB tray first launch' task that starts it once in the existing session and unregisters itself. - Hide the console window during --collect-usercontext. The agent is a console exe; the tray used to mask this with CreateNoWindow, but the scheduled task invokes it directly and flashed a window every cycle. Installer edits are in _install_script() in plugins/endpoints/api/siteagent.py, which generates the install.ps1 that actually ships. The standalone agent/installer/install-shopdbagent.ps1 was never shipped once the package moved to the UI builder and is deleted here to stop the divergence that caused an earlier fix to land in a file no endpoint ever ran. Versions: agent 1.10.1, tray 1.3.0. These share the pinned spool path and must be deployed together. Pre-existing test failures unrelated to this work (8, identical on a clean tree): docs publishable/versions, plugin contract symlink scan, import guard tamper check, security headers upload sandbox, and three endpoint timestamp tests.
This commit is contained in:
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -10,6 +10,10 @@ by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
51
agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs
Normal file
51
agent/ShopDbEndpointAgent.Core/ConsoleWindow.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,12 +272,15 @@ public sealed class EndpointCollector : ICollector
|
||||
return lastUserContext;
|
||||
}
|
||||
|
||||
// Consume rather than peek: the file is removed once it has been read, so
|
||||
// a tray that has died cannot keep this endpoint reporting the same
|
||||
// mapped drives forever. lastUserContext still repeats the previous value
|
||||
// between successful reads, because the tray collects on a much slower
|
||||
// cadence than telemetry and dropping the key in between would read on
|
||||
// the server as "the drives went away".
|
||||
// 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;
|
||||
|
||||
@@ -15,7 +15,6 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
private readonly LogLevel minLevel;
|
||||
private readonly int retentionDays;
|
||||
private readonly string filePrefix;
|
||||
private readonly bool stampFirst;
|
||||
private readonly object gate = new();
|
||||
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
|
||||
private string currentDateStamp = "";
|
||||
@@ -27,13 +26,12 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
public string? SinkProblem { get; private set; }
|
||||
|
||||
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
|
||||
int retentionDays = 14, string filePrefix = "agent", bool stampFirst = false)
|
||||
int retentionDays = 14, string filePrefix = "agent")
|
||||
{
|
||||
this.directory = directory;
|
||||
this.minLevel = minLevel;
|
||||
this.retentionDays = retentionDays;
|
||||
this.filePrefix = filePrefix;
|
||||
this.stampFirst = stampFirst;
|
||||
// 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
|
||||
@@ -129,10 +127,13 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
writer?.Flush();
|
||||
writer?.Dispose();
|
||||
currentDateStamp = stamp;
|
||||
// stampFirst gives "<datetime>-trayagent.log"; the service keeps its
|
||||
// established "agent-<date>.log" so existing log collection still finds
|
||||
// it. Both roll daily and prune on the same pattern.
|
||||
var name = stampFirst ? $"{stamp}-{filePrefix}.log" : $"{filePrefix}-{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))
|
||||
@@ -147,7 +148,11 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||
var pattern = stampFirst ? $"*-{filePrefix}.log" : $"{filePrefix}-*.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)
|
||||
|
||||
@@ -28,13 +28,19 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// from the agent binary or be tampered with on disk between cycles.
|
||||
public sealed class UserContextProbe
|
||||
{
|
||||
private const string ProbeScript = """
|
||||
// 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]@{}
|
||||
# Must match UserContextSpool.TryGetResultPath on the reader side. Uses
|
||||
# USERPROFILE, which for the shop-floor operator resolves to
|
||||
# C:\Users\ShopFloor - the same fixed location the service reads.
|
||||
$spool = Join-Path $env:USERPROFILE 'ShopDB'
|
||||
# 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"
|
||||
|
||||
@@ -133,6 +139,20 @@ public sealed class UserContextProbe
|
||||
$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 |
|
||||
@@ -144,6 +164,14 @@ public sealed class UserContextProbe
|
||||
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);
|
||||
@@ -160,15 +188,42 @@ public sealed class UserContextProbe
|
||||
}
|
||||
|
||||
// Run the probe in THIS process's session and write the spool file.
|
||||
// Returns true when the probe wrote a result.
|
||||
// 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
|
||||
@@ -187,7 +242,7 @@ public sealed class UserContextProbe
|
||||
// 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(ProbeScript));
|
||||
System.Text.Encoding.Unicode.GetBytes(BuildProbeScript(spoolDirectory)));
|
||||
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
@@ -231,6 +286,28 @@ public sealed class UserContextProbe
|
||||
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)
|
||||
@@ -240,6 +317,37 @@ public sealed class UserContextProbe
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -25,20 +25,38 @@ 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 against the server's
|
||||
// default quiet-report window (endpoints_quietreporthours, 24h): an endpoint
|
||||
// that has not reported inside that window is already shown as 'quiet', so
|
||||
// session data that outlived it would be claiming freshness the rest of the
|
||||
// page no longer claims. Well clear of the tray's collection interval, so an
|
||||
// ordinary missed run does not blank the card.
|
||||
// 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.FromHours(24);
|
||||
private static readonly TimeSpan MaxResultAge = TimeSpan.FromMinutes(90);
|
||||
|
||||
private readonly ILogger<UserContextSpool> logger;
|
||||
|
||||
@@ -67,7 +85,7 @@ public sealed class UserContextSpool
|
||||
if (Directory.Exists(pinned))
|
||||
{
|
||||
reason = null;
|
||||
return Path.Combine(pinned, SpoolFolderName, ResultFileName);
|
||||
return PinnedResultPath();
|
||||
}
|
||||
|
||||
var profileDirectory =
|
||||
@@ -161,6 +179,12 @@ public sealed class UserContextSpool
|
||||
if (IsExpired(parsed, resultPath))
|
||||
return null;
|
||||
|
||||
if (IsWrongAccount(parsed))
|
||||
return null;
|
||||
|
||||
if (IsElevatedCollection(parsed))
|
||||
return null;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
catch (JsonException exception)
|
||||
@@ -179,36 +203,92 @@ public sealed class UserContextSpool
|
||||
}
|
||||
}
|
||||
|
||||
// Read the spool and then REMOVE it, so the next report can only carry a
|
||||
// result the tray produced after this read. Leaving a consumed file in place
|
||||
// means a tray that has stopped running keeps re-reporting the same drives
|
||||
// as though they were current, which is worse than reporting nothing: the
|
||||
// card looks healthy while the data is frozen.
|
||||
// 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.
|
||||
//
|
||||
// The removal is deliberately AFTER a successful parse. If the file was
|
||||
// mid-write (truncated JSON) it is left alone rather than destroyed, so a
|
||||
// read that raced the tray costs one cycle instead of a good result. The
|
||||
// collectedat age check still applies as a second bound for the window
|
||||
// between the tray writing and the service next waking 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);
|
||||
|
||||
// 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)
|
||||
{
|
||||
var parsed = TryReadResult(resultPath);
|
||||
if (parsed is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
if (!parsed.TryGetValue("username", out var raw) || raw is null)
|
||||
{
|
||||
File.Delete(resultPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Non-fatal: the value was already read. It does mean the next cycle
|
||||
// may re-read the same result, which the age check will retire.
|
||||
logger.LogWarning(exception,
|
||||
"Read the user-context spool but could not remove it at {Path}", resultPath);
|
||||
logger.LogWarning(
|
||||
"The user-context spool has no username stamp; ignoring it because "
|
||||
+ "the session it describes cannot be confirmed.");
|
||||
return true;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
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
|
||||
|
||||
@@ -27,8 +27,34 @@
|
||||
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. -->
|
||||
<Version>1.1.1</Version>
|
||||
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. -->
|
||||
<Version>1.3.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -42,6 +42,19 @@ internal sealed class TrayContext : ApplicationContext
|
||||
// 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;
|
||||
@@ -59,13 +72,14 @@ internal sealed class TrayContext : ApplicationContext
|
||||
{
|
||||
logProvider = new FileLoggerProvider(
|
||||
FileLoggerProvider.UserProfileLogDirectory(),
|
||||
filePrefix: "trayagent",
|
||||
stampFirst: true);
|
||||
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",
|
||||
UserContextStartupDelay, UserContextInterval);
|
||||
startupDelay, UserContextInterval);
|
||||
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.Add("Send Status now", null, OnSendStatus);
|
||||
@@ -86,9 +100,25 @@ internal sealed class TrayContext : ApplicationContext
|
||||
};
|
||||
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)UserContextStartupDelay.TotalMilliseconds,
|
||||
Interval = (int)startupDelay.TotalMilliseconds,
|
||||
};
|
||||
userContextTimer.Tick += OnUserContextTimer;
|
||||
userContextTimer.Start();
|
||||
@@ -126,17 +156,44 @@ internal sealed class TrayContext : ApplicationContext
|
||||
{
|
||||
logger.LogInformation("Running the user-context probe: {Agent}", agentExe);
|
||||
var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext");
|
||||
if (exitCode == 0)
|
||||
|
||||
// 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 written to {Spool}.",
|
||||
Path.Combine(
|
||||
FileLoggerProvider.ShopFloorProfileDirectory(), "ShopDB"));
|
||||
"The user-context probe finished; spool present at {Spool}.",
|
||||
resultPath);
|
||||
}
|
||||
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}.", exitCode);
|
||||
"The user-context probe exited with code {ExitCode}; expected a "
|
||||
+ "spool at {Spool}.", exitCode, resultPath);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -353,6 +410,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()
|
||||
|
||||
@@ -29,10 +29,103 @@ 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();
|
||||
@@ -43,30 +136,6 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
|
||||
? FileLoggerProvider.DefaultDirectory()
|
||||
: config.LogPath;
|
||||
|
||||
// Deliberately ahead of the collector config gate. This verb only writes a file
|
||||
// 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 exactly the endpoints that are hardest to
|
||||
// diagnose.
|
||||
if (collectUserContext)
|
||||
{
|
||||
// Logs to the OPERATOR's profile, not the service's ProgramData directory.
|
||||
// This verb always runs as the signed-in user (the tray invokes it), and a
|
||||
// non-privileged user cannot write to the SYSTEM-owned log directory. 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 probeLoggerFactory = LoggerFactory.Create(logging =>
|
||||
logging.AddProvider(new FileLoggerProvider(
|
||||
FileLoggerProvider.UserProfileLogDirectory(),
|
||||
filePrefix: "trayagent",
|
||||
stampFirst: true)));
|
||||
var probe = new UserContextProbe(
|
||||
probeLoggerFactory.CreateLogger<UserContextProbe>());
|
||||
return probe.Collect() ? 0 : 1;
|
||||
}
|
||||
|
||||
if (!config.IsUsable())
|
||||
{
|
||||
bootstrapLogger.LogError(
|
||||
@@ -226,12 +295,15 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
||||
// 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>>()));
|
||||
|
||||
services.AddSingleton(sp => new UserContextProbe(
|
||||
sp.GetRequiredService<ILogger<UserContextProbe>>()));
|
||||
|
||||
services.AddSingleton<ICollector>(sp => new EndpointCollector(
|
||||
sp.GetRequiredService<ILogger<EndpointCollector>>(),
|
||||
config,
|
||||
@@ -274,3 +346,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}");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,47 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 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
|
||||
@@ -90,7 +131,7 @@
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.8.1</Version>
|
||||
<Version>1.10.1</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -1,437 +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
|
||||
}
|
||||
|
||||
# Installer transcript. Deliberately SEPARATE from both the service log
|
||||
# (%ProgramData%\ShopDB\EndpointAgent\logs\agent-<date>.log) and the tray log
|
||||
# (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log): the installer runs
|
||||
# elevated and one-shot, and mixing its output into the service's rolling log
|
||||
# made an install indistinguishable from runtime behaviour when reading back a
|
||||
# failure. This runs as admin, so ProgramData is writable here.
|
||||
$installLogDir = Join-Path $env:ProgramData 'ShopDB\Logs'
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $installLogDir -Force | Out-Null
|
||||
$installLog = Join-Path $installLogDir (
|
||||
'{0}-shopdbinstalls.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||
Start-Transcript -Path $installLog -Append | Out-Null
|
||||
$transcriptStarted = $true
|
||||
}
|
||||
catch {
|
||||
# Never let logging block an install.
|
||||
$transcriptStarted = $false
|
||||
Write-Warning "could not start the install transcript: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# The shop-floor operator profile. The tray and the user-context probe run as
|
||||
# this local account on every shop-floor endpoint, and it is the one place both
|
||||
# the operator (writer) and the SYSTEM service (reader) can reach. Defined here
|
||||
# so the install can provision the directories and report their location.
|
||||
$shopFloorProfile = Join-Path (Join-Path $env:SystemDrive 'Users') 'ShopFloor'
|
||||
|
||||
# Stop the transcript on ANY terminating error, not just the success path. The
|
||||
# script throws on several validation and smoke-test failures, and those are
|
||||
# precisely the installs worth reading back afterwards - an orphaned transcript
|
||||
# would truncate exactly the failure it was started to capture.
|
||||
trap {
|
||||
if ($transcriptStarted) {
|
||||
try { Stop-Transcript | Out-Null } catch { }
|
||||
$script:transcriptStarted = $false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
|
||||
Write-Host "BaseUrl : $BaseUrl"
|
||||
Write-Host "InstallDir : $InstallDir"
|
||||
Write-Host "ServiceName : $ServiceName"
|
||||
if ($transcriptStarted) { Write-Host "InstallLog : $installLog" }
|
||||
|
||||
# 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
|
||||
|
||||
# 1a. Provision the user-context spool and tray log directories in the operator
|
||||
# profile, and grant the operator Modify on them.
|
||||
#
|
||||
# This is the handoff point between the two security contexts (AGENT-CONTRACT.md
|
||||
# section 5): the tray WRITES here as the ShopFloor operator, and the SYSTEM
|
||||
# service READS and then removes the spool. The operator cannot write to
|
||||
# Program Files or to the SYSTEM-owned ProgramData log directory, and the file
|
||||
# log sink degrades to a silent no-op when it cannot create its directory - so
|
||||
# without this step a tray-side failure leaves no trace anywhere on the machine.
|
||||
# Creating them here, elevated, means the directories exist and are writable
|
||||
# before the tray ever starts.
|
||||
Write-Host "[1a/5] Provisioning operator spool/log directories under $shopFloorProfile ..."
|
||||
if (Test-Path $shopFloorProfile) {
|
||||
foreach ($dir in @(
|
||||
(Join-Path $shopFloorProfile 'ShopDB'),
|
||||
(Join-Path $shopFloorProfile 'ShopDB\Logs'))) {
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$acl = Get-Acl $dir
|
||||
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
'ShopFloor', 'Modify',
|
||||
'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
||||
$acl.SetAccessRule($rule)
|
||||
Set-Acl -Path $dir -AclObject $acl
|
||||
Write-Host " provisioned $dir (ShopFloor: Modify)"
|
||||
}
|
||||
catch {
|
||||
# Non-fatal: telemetry, commands and inventory are unaffected. Only
|
||||
# user-context (mapped drives, idle time) depends on this path.
|
||||
Write-Warning " could not provision $dir : $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning " no ShopFloor profile at $shopFloorProfile; user-context (mapped drives, idle time) will be unavailable until that user has logged in once."
|
||||
}
|
||||
|
||||
# 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."
|
||||
# The three log destinations are separate on purpose; see AGENT-CONTRACT.md.
|
||||
Write-Host "Service log : $logDir"
|
||||
Write-Host "Tray log : $(Join-Path $shopFloorProfile 'ShopDB\Logs')"
|
||||
if ($transcriptStarted) {
|
||||
Write-Host "Install log : $installLog"
|
||||
try { Stop-Transcript | Out-Null } catch { }
|
||||
}
|
||||
@@ -106,6 +106,8 @@ arrays. `adopt` is first-contact only.
|
||||
],
|
||||
"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": [
|
||||
@@ -194,8 +196,9 @@ Notes:
|
||||
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 the TRAY runs inside the signed-in
|
||||
operator's session, because mapped drives, user-owned listeners and idle time
|
||||
- `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.
|
||||
@@ -302,9 +305,9 @@ Three processes run as three different principals. Confusing them is what makes
|
||||
|
||||
| Process | Runs as | May write to |
|
||||
|---|---|---|
|
||||
| Service (`shopdbagent.exe`) | LocalSystem, session 0 | `%ProgramData%\ShopDB\EndpointAgent\logs\agent-<date>.log` |
|
||||
| Tray + probe (`shopdb-endpoint-tray.exe`, `shopdbagent.exe --collect-usercontext`) | `ShopFloor` operator, interactive session | `C:\Users\ShopFloor\ShopDB\` and `C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log` |
|
||||
| Installer (`install-shopdbagent.ps1`) | Elevated admin, one-shot | `%ProgramData%\ShopDB\Logs\<datetime>-shopdbinstalls.log` |
|
||||
| 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:
|
||||
|
||||
@@ -314,23 +317,76 @@ Rules:
|
||||
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. **Only the tray collects user context**, 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.
|
||||
3. **The tray WRITES the spool; the service READS it and then REMOVES it.**
|
||||
The service consumes the file after a successful parse, so a tray that has
|
||||
stopped running cannot keep re-reporting stale mapped drives as current. A
|
||||
file that fails to parse (a read that raced the write) is left in place.
|
||||
4. **The spool location is fixed at `C:\Users\ShopFloor\ShopDB\usercontext.json`.**
|
||||
Every shop-floor endpoint uses the same local `ShopFloor` account. Resolving
|
||||
it through `WTSQueryUserToken` is a fallback only: that call is privileged,
|
||||
endpoint protection blocks it, and when it fails the writer and reader
|
||||
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.
|
||||
5. **Losing user context is never fatal.** If the `ShopFloor` profile is absent
|
||||
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.
|
||||
6. **The installer provisions** `C:\Users\ShopFloor\ShopDB\` and its `Logs`
|
||||
subdirectory with `ShopFloor: Modify` before the tray first runs.
|
||||
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
|
||||
|
||||
@@ -217,7 +217,14 @@ 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,
|
||||
[switch] $NoUserContextTask
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -362,10 +369,150 @@ 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 Users
|
||||
# GROUP. 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. GroupId rather
|
||||
# than UserId so the task runs as WHOEVER signs in, each in their own session.
|
||||
$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)
|
||||
|
||||
# BUILTIN\\Users by SID first, then by name: the SID is locale
|
||||
# independent (on non-English Windows the group is not called "Users"),
|
||||
# but a hardened host can still reject it.
|
||||
foreach ($groupId in @("S-1-5-32-545", "BUILTIN\\Users")) {{
|
||||
try {{
|
||||
$taskPrincipal = New-ScheduledTaskPrincipal -GroupId $groupId -RunLevel Limited -ErrorAction Stop
|
||||
Register-ScheduledTask -TaskName $UserContextTaskName -Action $taskAction -Trigger $taskTriggers -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null
|
||||
$taskRegistered = $true
|
||||
$taskDetail = $groupId
|
||||
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 (-not $verified.Principal.GroupId) {{
|
||||
$taskState = "UNVERIFIED:no group principal"
|
||||
Write-Warning " user-context task has no group principal; it would run as one specific account."
|
||||
}}
|
||||
else {{
|
||||
$taskState = "ok:$($verified.Principal.GroupId)/$($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 +544,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 +586,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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user