Endpoint telemetry, PC Detail rework and a checked backup/restore path
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -89,38 +89,44 @@ public sealed class AgentConfig
|
||||
return bare;
|
||||
}
|
||||
|
||||
// Per-name allowlist of pre-staged scripts for the generic run_script
|
||||
// action. Exactly like LaunchPaths, the wire never carries a script body or
|
||||
// path - only a script NAME the agent already holds here. A name absent
|
||||
// from this map fails closed. There are no shipped defaults: a script is a
|
||||
// site-specific artifact, so an empty map means run_script does nothing
|
||||
// until a site stages scripts and lists them in appsettings.json. Keyed by
|
||||
// script name (case-insensitive, .ps1 optional).
|
||||
[JsonPropertyName("scriptpaths")]
|
||||
public Dictionary<string, string> ScriptPaths { get; set; } = new();
|
||||
// REMOVED DELIBERATELY - DO NOT REINTRODUCE: 'scriptpaths' / ScriptPaths.
|
||||
//
|
||||
// This used to be a per-name map of PRE-STAGED scripts on the endpoint
|
||||
// (typically under C:\ProgramData\ShopDB\EndpointAgent\scripts), which
|
||||
// run_script would look up by name and execute. It was superseded by
|
||||
// server-hosted delivery and left behind as dead config: nothing read the
|
||||
// map, so it was an execution path that appeared supported but was not.
|
||||
//
|
||||
// It is gone rather than merely unused because reviving it is a security
|
||||
// regression, and dead code invites revival. That model had NO integrity
|
||||
// check - no digest, no provenance - so whoever could write into the staging
|
||||
// directory chose what the agent ran as SYSTEM. ProgramData is world-
|
||||
// writable by default unless the tree is explicitly tightened, which made
|
||||
// the gate "is this name in appsettings.json", not "is this content
|
||||
// trusted".
|
||||
//
|
||||
// The supported path is run_script with a server-issued delivery descriptor:
|
||||
// the body is fetched from ShopDB over the collector credential and verified
|
||||
// against a server-pinned SHA-256 before it executes, so the content is
|
||||
// authenticated and the action is attributable in the command log. If you
|
||||
// need a script to run on an endpoint, publish it server-side and send it
|
||||
// through that path. If you need to trigger something already installed on
|
||||
// the box, use scheduledtask_run, which is far narrower.
|
||||
|
||||
// The effective script map: appsettings.json entries only (no defaults).
|
||||
// Keys are normalized to bare names so a lookup tolerates 'reset' or
|
||||
// 'reset.ps1'.
|
||||
public IReadOnlyDictionary<string, string> ResolveScriptPaths()
|
||||
{
|
||||
var resolved = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in ScriptPaths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
|
||||
continue;
|
||||
resolved[NormalizeScript(pair.Key)] = pair.Value;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string NormalizeScript(string name)
|
||||
{
|
||||
var bare = (name ?? "").Trim();
|
||||
if (bare.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase))
|
||||
bare = bare[..^4];
|
||||
return bare;
|
||||
}
|
||||
// Approved scheduled task paths for the scheduledtask_run action. The wire
|
||||
// carries a free-text task path so the WebUI can expose it as a single
|
||||
// field; ScheduledTaskPolicy validates it against this list and fails closed
|
||||
// when it does not match. An entry matches that exact task path, or acts as
|
||||
// a folder prefix for anything beneath it.
|
||||
//
|
||||
// Empty means "use the shipped Corporate Tools defaults"
|
||||
// (ScheduledTaskPolicy.DefaultApprovedTaskPaths). Setting it REPLACES those
|
||||
// defaults, so a site can narrow the set as well as widen it without an
|
||||
// agent rebuild. Keep it tight: every entry here is something an operator
|
||||
// holding endpoints.deploy can start, in whatever context the task itself
|
||||
// registered.
|
||||
[JsonPropertyName("approvedtaskpaths")]
|
||||
public List<string> ApprovedTaskPaths { get; set; } = new();
|
||||
|
||||
public bool IsUsable()
|
||||
=> !string.IsNullOrWhiteSpace(BaseUrl)
|
||||
|
||||
@@ -64,6 +64,10 @@ public sealed class CommandExecutor
|
||||
// before they are honored. Local launchPaths always win over a server path.
|
||||
private readonly LaunchPathPolicy launchPathPolicy;
|
||||
|
||||
// Validates the free-text task path carried by scheduledtask_run before the
|
||||
// agent will start anything. Fails closed: an unapproved path never runs.
|
||||
private readonly ScheduledTaskPolicy scheduledTaskPolicy;
|
||||
|
||||
// Held for update_app: the agent joins InstallPath to BaseUrl and downloads
|
||||
// the pinned installer with its own collector credential (ApiKey), so the
|
||||
// installer body never rides the command wire.
|
||||
@@ -77,6 +81,7 @@ public sealed class CommandExecutor
|
||||
this.config = config ?? new AgentConfig();
|
||||
this.launchPaths = this.config.ResolveLaunchPaths();
|
||||
this.launchPathPolicy = new LaunchPathPolicy(this.config.ApprovedLaunchRoots);
|
||||
this.scheduledTaskPolicy = new ScheduledTaskPolicy(this.config.ApprovedTaskPaths);
|
||||
}
|
||||
|
||||
public async Task<CommandResult> ExecuteAsync(
|
||||
@@ -116,6 +121,16 @@ public sealed class CommandExecutor
|
||||
return RequireTarget(action, target)
|
||||
?? await ProcessStart(target, token);
|
||||
|
||||
// Start an already-registered scheduled task by path. Deliberately
|
||||
// narrow: the agent does not create, modify, or define tasks, it
|
||||
// only starts one that already exists AND clears the approved-task
|
||||
// policy. Prefer this over run_script whenever the work is already
|
||||
// packaged as a task - it is a far smaller grant than shipping a
|
||||
// script body, and the command log names exactly what ran.
|
||||
case "scheduledtask_run":
|
||||
return RequireTarget(action, target)
|
||||
?? await ScheduledTaskRun(target, token);
|
||||
|
||||
// Software delivery. run_script fetches a ShopDB-hosted script
|
||||
// pinned by SHA-256 and runs it in the script's configured context
|
||||
// (system or the interactive user); update_app fetches a ShopDB
|
||||
@@ -144,6 +159,14 @@ public sealed class CommandExecutor
|
||||
Message = "Fresh telemetry collection requested",
|
||||
};
|
||||
|
||||
// Full TCP connection table, on demand only. Deliberately not part of
|
||||
// the standard report: established connections churn every second, so
|
||||
// sending them on cadence would bloat every payload and swamp the
|
||||
// stable listener list. As a command the technician pays that cost
|
||||
// once, at the moment they are actually diagnosing connectivity.
|
||||
case "network_connections":
|
||||
return NetworkConnections();
|
||||
|
||||
default:
|
||||
logger.LogWarning("Rejected unknown command action {Action}", action);
|
||||
return new CommandResult
|
||||
@@ -154,6 +177,98 @@ public sealed class CommandExecutor
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot of every TCP connection with its owning process. Capped because a
|
||||
// busy machine can hold thousands of sockets and an unbounded result would
|
||||
// be rejected by the server's payload limit, returning nothing at all -
|
||||
// a truncated answer is far more useful than a failed one.
|
||||
private const int MaxReportedConnections = 500;
|
||||
|
||||
private CommandResult NetworkConnections()
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = TcpTableReader.GetTcpTable();
|
||||
var names = GetProcessNameSnapshot();
|
||||
var connections = new List<object>();
|
||||
var truncated = false;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (connections.Count >= MaxReportedConnections)
|
||||
{
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
names.TryGetValue(row.ProcessId, out var processName);
|
||||
connections.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["localaddress"] = row.LocalAddress,
|
||||
["localport"] = row.LocalPort,
|
||||
["remoteaddress"] = row.RemoteAddress,
|
||||
["remoteport"] = row.RemotePort,
|
||||
["state"] = row.State.ToString().ToLowerInvariant(),
|
||||
["processid"] = row.ProcessId,
|
||||
["processname"] = processName,
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"network_connections returned {Count} connections (truncated: {Truncated})",
|
||||
connections.Count, truncated);
|
||||
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = $"Collected {connections.Count} TCP connections"
|
||||
+ (truncated ? " (truncated)" : ""),
|
||||
Detail = new Dictionary<string, object>
|
||||
{
|
||||
["connections"] = connections,
|
||||
["totalcount"] = rows.Count,
|
||||
["truncated"] = truncated,
|
||||
},
|
||||
};
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "network_connections failed");
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "Could not read the TCP connection table: " + exception.Message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<int, string> GetProcessNameSnapshot()
|
||||
{
|
||||
var names = new Dictionary<int, string>();
|
||||
try
|
||||
{
|
||||
foreach (var process in Process.GetProcesses())
|
||||
{
|
||||
try
|
||||
{
|
||||
names[process.Id] = process.ProcessName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The process exited between enumeration and read.
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Process name snapshot failed");
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// Guard: a generic action with no target has nothing to act on. Returns a
|
||||
// failed result to short-circuit, or null when the target is present.
|
||||
private CommandResult? RequireTarget(string action, string target)
|
||||
@@ -550,6 +665,139 @@ public sealed class CommandExecutor
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---- Scheduled tasks ----
|
||||
|
||||
// Upper bound on how long a task start may block the command worker. This
|
||||
// waits only for schtasks.exe to hand the request to the Task Scheduler
|
||||
// service, NOT for the task's own work to finish - schtasks /run returns as
|
||||
// soon as the task is launched. So success here means "the task was
|
||||
// started", not "the task succeeded", and the timeout only needs to cover
|
||||
// the handoff.
|
||||
private static readonly TimeSpan ScheduledTaskStartTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Start an already-registered scheduled task by path, after the policy
|
||||
// approves it. Uses schtasks.exe with ArgumentList (no shell, no string
|
||||
// concatenation) so the task path is passed as a single argv entry and
|
||||
// cannot break out into additional arguments.
|
||||
//
|
||||
// The task runs in whatever security context its own registered definition
|
||||
// specifies - SYSTEM, an interactive user, whatever principal was set when
|
||||
// the task was created. The agent does not and cannot change that, which is
|
||||
// exactly why this action is narrower than run_script: the endpoint owns the
|
||||
// definition of what happens, the server only asks for it to happen now.
|
||||
private async Task<CommandResult> ScheduledTaskRun(string target, CancellationToken token)
|
||||
{
|
||||
if (!scheduledTaskPolicy.TryValidate(target, out var taskPath, out var reason))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected scheduledtask_run for {Target}: {Reason}", target, reason);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Scheduled task '{target}' rejected: {reason}",
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "schtasks.exe",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("/run");
|
||||
startInfo.ArgumentList.Add("/tn");
|
||||
startInfo.ArgumentList.Add(taskPath);
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Failed to start schtasks.exe for task {taskPath}",
|
||||
};
|
||||
}
|
||||
|
||||
var stdout = "";
|
||||
var stderr = "";
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeout.CancelAfter(ScheduledTaskStartTimeout);
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
// Read only after exit. Reading to end first would block until
|
||||
// the streams close - which IS process exit - so the timeout
|
||||
// above could never fire. Safe here because schtasks emits a
|
||||
// line or two, far below the pipe buffer that would deadlock a
|
||||
// chattier child.
|
||||
stdout = await process.StandardOutput.ReadToEndAsync(token);
|
||||
stderr = await process.StandardError.ReadToEndAsync(token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
||||
{
|
||||
TryKill(process);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Timed out starting scheduled task {taskPath}",
|
||||
};
|
||||
}
|
||||
|
||||
var exit = process.ExitCode;
|
||||
var ok = exit == 0;
|
||||
// schtasks writes the useful diagnosis (task not found, access
|
||||
// denied, disabled) to its output, so surface it rather than making
|
||||
// the operator guess from a bare exit code.
|
||||
var detail = (stderr.Trim().Length > 0 ? stderr.Trim() : stdout.Trim());
|
||||
if (ok)
|
||||
{
|
||||
logger.LogInformation("Started scheduled task {TaskPath}", taskPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"schtasks /run for {TaskPath} exited {Exit}: {Detail}",
|
||||
taskPath, exit, detail);
|
||||
}
|
||||
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = ok,
|
||||
Message = ok
|
||||
? $"Started scheduled task {taskPath}"
|
||||
: $"Scheduled task {taskPath} failed to start (exit {exit})"
|
||||
+ (detail.Length > 0 ? $": {detail}" : ""),
|
||||
};
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "scheduledtask_run for {TaskPath} failed", taskPath);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Scheduled task {taskPath} failed: {error.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void TryKill(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Could not kill timed-out schtasks.exe");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Software delivery: run_script and update_app ----
|
||||
|
||||
// Run a ShopDB-hosted script pinned by the server's delivery descriptor. The
|
||||
@@ -559,6 +807,26 @@ public sealed class CommandExecutor
|
||||
// 'system' through the agent's own service account, or 'user' in the active
|
||||
// console session so the script sees the interactive user's resources (for
|
||||
// example mapped network drives). Anything missing or mismatched fails closed.
|
||||
//
|
||||
// SECURITY CONTRACT - read before changing this method. This is arbitrary
|
||||
// code execution on the endpoint, by design, and the agent is a SYSTEM
|
||||
// service: a script whose descriptor does not pin the user context runs with
|
||||
// full machine privilege. Three properties make that acceptable, and all
|
||||
// three are load-bearing:
|
||||
//
|
||||
// 1. The body is never carried on the command wire. The agent fetches it
|
||||
// itself from ShopDB using its own collector credential, so queueing a
|
||||
// command cannot smuggle in code.
|
||||
// 2. The body is verified against the server-pinned SHA-256 before it
|
||||
// runs. A tampered or swapped body fails closed.
|
||||
// 3. The action is gated on endpoints.deploy server-side and is meant to
|
||||
// sit behind an explicit UI confirmation, the same as system_restart.
|
||||
//
|
||||
// Do not add a path that runs a script the agent found locally rather than
|
||||
// fetched and verified - that was the old pre-staged 'scriptpaths' model and
|
||||
// it was removed on purpose (see the tombstone in AgentConfig). If the goal
|
||||
// is to trigger something already installed on the endpoint, use
|
||||
// scheduledtask_run instead: it is a much smaller grant.
|
||||
private async Task<CommandResult> RunScript(
|
||||
string name, ScriptDeliveryClaim? script, CancellationToken token)
|
||||
{
|
||||
@@ -714,6 +982,12 @@ public sealed class CommandExecutor
|
||||
// to exit and returns its real exit code, so success here means the script
|
||||
// actually ran to a zero exit - not merely that it was launched. Fails closed
|
||||
// when no interactive user is signed in.
|
||||
// Upper bound on how long a user-session script may block the command
|
||||
// worker. Generous, because a staged maintenance script can legitimately run
|
||||
// for minutes, but finite: before this existed the wait was Infinite and a
|
||||
// hung script pinned the caller forever.
|
||||
private static readonly TimeSpan UserScriptWaitTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
private CommandResult RunScriptInUserSession(
|
||||
string name, ScriptDeliveryClaim script, string scriptPath)
|
||||
{
|
||||
@@ -723,7 +997,8 @@ public sealed class CommandExecutor
|
||||
var workingDirectory = Path.GetDirectoryName(scriptPath) ?? "";
|
||||
|
||||
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
||||
"powershell.exe", arguments, workingDirectory, waitForExit: true);
|
||||
"powershell.exe", arguments, workingDirectory, waitForExit: true,
|
||||
waitTimeout: UserScriptWaitTimeout);
|
||||
if (!outcome.Launched)
|
||||
{
|
||||
logger.LogWarning(
|
||||
@@ -737,6 +1012,23 @@ public sealed class CommandExecutor
|
||||
};
|
||||
}
|
||||
|
||||
if (outcome.TimedOut)
|
||||
{
|
||||
// The script is still running; we simply stopped waiting. Report it
|
||||
// as unresolved rather than success, because we never saw an exit
|
||||
// code and cannot claim it worked.
|
||||
logger.LogWarning(
|
||||
"run_script for {Name} still running after {Timeout}; stopped waiting",
|
||||
name, UserScriptWaitTimeout);
|
||||
return new CommandResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"Script {name} did not finish within "
|
||||
+ $"{UserScriptWaitTimeout.TotalMinutes:0} minutes and is still running",
|
||||
Detail = ScriptDetail(name, script, scriptPath, exitCode: null),
|
||||
};
|
||||
}
|
||||
|
||||
var exit = outcome.ExitCode;
|
||||
var ok = exit is null || exit == 0;
|
||||
return new CommandResult
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,57 @@ public sealed class EndpointReport
|
||||
// collection: both Uninstall registry hives, {displayname, displayversion,
|
||||
// publisher}. Server logs it as an unpersisted key until the SBOM store lands.
|
||||
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
|
||||
|
||||
// True when Windows is holding a reboot. A machine that has been waiting to
|
||||
// reboot for weeks explains stalled patching and "the fix did not take"
|
||||
// reports, and on a shop floor PC nobody reboots voluntarily.
|
||||
[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
|
||||
|
||||
// Which signal(s) drove pendingreboot, so the server can tell a box staged
|
||||
// mid-servicing (pendingfilerename) from one with a stale servicing key.
|
||||
// Empty array = checked, nothing pending. Omitted = the check failed.
|
||||
[JsonPropertyName("rebootreasons")] public string[]? RebootReasons { get; set; }
|
||||
|
||||
// hiberbootenabled (fast startup configured) and lastboottype (what the
|
||||
// last boot actually did). Uptime is not trustworthy when these disagree.
|
||||
[JsonPropertyName("bootinfo")] public object? BootInfo { get; set; }
|
||||
|
||||
// Defender health. Persisted but deliberately not surfaced: known false
|
||||
// positives in this environment.
|
||||
[JsonPropertyName("defender")] public object? Defender { get; set; }
|
||||
|
||||
// Physical drive health from two sources: MSFT_PhysicalDisk (the only one
|
||||
// that reports NVMe) and Win32_DiskDrive.Status (ATA/SATA and fallback).
|
||||
// Each entry carries a precomputed predictfailure so an alert is a boolean
|
||||
// test rather than a string match against an enum. Null predictfailure
|
||||
// means the drive did not answer - unknown, not healthy.
|
||||
[JsonPropertyName("diskhealth")] public object[]? DiskHealth { get; set; }
|
||||
|
||||
// Session-scoped data gathered by a probe running as the signed-in user:
|
||||
// mapped drives and user-owned listeners. Null when nobody is signed in or
|
||||
// the probe did not produce a result this cycle - absent means "not
|
||||
// observed", which is deliberately distinct from an empty list.
|
||||
[JsonPropertyName("usercontext")] public object? UserContext { get; set; }
|
||||
|
||||
// Pending Dell Command | Update results, produced by the shopdb-dellupdates
|
||||
// agent utility rather than collected in C#: the data comes from dcu-cli's
|
||||
// own scan output, so the utility that parses it belongs next to the task
|
||||
// that writes it. Null means no scan has ever run (or the utility is not
|
||||
// installed), which is deliberately distinct from a scan that found nothing
|
||||
// pending - the first is "unknown", the second is "known clean".
|
||||
[JsonPropertyName("dellupdates")] public object? DellUpdates { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EndpointEventRecord
|
||||
{
|
||||
[JsonPropertyName("eventid")] public int EventId { get; set; }
|
||||
|
||||
// The Windows event record id: monotonic and unique within its channel on a
|
||||
// given machine. It is both the agent's harvest watermark and the server's
|
||||
// dedupe key, so the same record can never be stored twice. Null only when
|
||||
// the source record did not expose one.
|
||||
[JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; }
|
||||
|
||||
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
|
||||
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
|
||||
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
|
||||
|
||||
@@ -27,7 +27,15 @@ public static class CuratedEventCatalog
|
||||
new(55, "System", "NTFS file system corruption"),
|
||||
new(7011, "System", "Service Control Manager timeout"),
|
||||
new(10016, "System", "DCOM permission error"),
|
||||
// Windows Error Reporting on the Application channel: 1000 is the
|
||||
// faulting-application record (app, version, faulting module, exception
|
||||
// code), 1001 is the WER bucket record Windows pairs with it, and 1002
|
||||
// is an unresponsive app rather than a crash. The structured detail for
|
||||
// all three arrives in the record's EventData, which the collector lifts
|
||||
// into `fields`.
|
||||
new(1000, "Application", "Application error / faulting module"),
|
||||
new(1001, "Application", "Application error / bugcheck report"),
|
||||
new(1002, "Application", "Application hang"),
|
||||
};
|
||||
|
||||
// The distinct channels the collector must read.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Management;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -19,16 +20,28 @@ public sealed class EndpointCollector : ICollector
|
||||
// keep the payload bounded on a chatty Security log.
|
||||
private const int MaxEventsPerChannel = 25;
|
||||
|
||||
// The spool file is read every cycle. Reading it is a small file read, and
|
||||
// the tray - not this cadence - decides how often the data is refreshed, so
|
||||
// throttling the read here would only delay already-collected data reaching
|
||||
// the server without saving any real work.
|
||||
|
||||
private readonly ILogger<EndpointCollector> logger;
|
||||
private readonly AgentConfig config;
|
||||
private readonly IAgentUtilityRunner? utilities;
|
||||
private readonly EventWatermarkStore? watermarks;
|
||||
private readonly UserContextSpool? userContextSpool;
|
||||
|
||||
private object? lastUserContext;
|
||||
|
||||
public EndpointCollector(ILogger<EndpointCollector> logger, AgentConfig config,
|
||||
IAgentUtilityRunner? utilities = null)
|
||||
IAgentUtilityRunner? utilities = null, EventWatermarkStore? watermarks = null,
|
||||
UserContextSpool? userContextSpool = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.config = config;
|
||||
this.utilities = utilities;
|
||||
this.watermarks = watermarks;
|
||||
this.userContextSpool = userContextSpool;
|
||||
}
|
||||
|
||||
public EndpointReport Collect(string agentVersion, bool adopt)
|
||||
@@ -49,23 +62,232 @@ public sealed class EndpointCollector : ICollector
|
||||
MachineNumber = GetMachineNumber(),
|
||||
LastBootTime = GetLastBootTime(),
|
||||
Services = GetMonitoredTargets(),
|
||||
Ports = GetListeningPorts(),
|
||||
Interfaces = GetInterfaces(),
|
||||
Events = GetCuratedEvents(),
|
||||
Disks = GetDisks(),
|
||||
DiskHealth = GetDiskHealth(),
|
||||
Bios = GetBios(),
|
||||
Memory = GetMemory(),
|
||||
Processor = GetProcessor(),
|
||||
OsInfo = GetOsInfo(),
|
||||
Domain = GetDomain(),
|
||||
Applications = GetApplications(),
|
||||
PendingReboot = GetPendingReboot(),
|
||||
RebootReasons = GetRebootReasons(),
|
||||
BootInfo = GetBootInfo(),
|
||||
Defender = GetDefender(),
|
||||
UserContext = GetUserContext(),
|
||||
};
|
||||
|
||||
if (report.LastBootTime is DateTimeOffset boot)
|
||||
report.UptimeSeconds = (long)(DateTimeOffset.UtcNow - boot).TotalSeconds;
|
||||
|
||||
report.CpuPercent = GetCpuPercent();
|
||||
report.MemoryPercent = GetMemoryPercent();
|
||||
report.IdleSeconds = GetIdleSecondsFromUserContext();
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// Instantaneous CPU load from the _Total performance instance.
|
||||
//
|
||||
// Uses the WMI formatted-data class rather than PerformanceCounter because a
|
||||
// PerformanceCounter reads 0 on its first sample and needs a second sample a
|
||||
// second later to mean anything. Sleeping a second inside the collect path
|
||||
// would delay the heartbeat on every tick for one number. The formatted-data
|
||||
// class is already averaged by the OS, so a single read is usable.
|
||||
private double? GetCpuPercent()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT PercentProcessorTime FROM " +
|
||||
"Win32_PerfFormattedData_PerfOS_Processor WHERE Name='_Total'");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var value = ToLong(mo["PercentProcessorTime"]);
|
||||
if (value is long percent)
|
||||
return Math.Clamp(percent, 0, 100);
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "CPU percent query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Percentage of physical memory in use. Reported alongside the absolute
|
||||
// byte counts in the memory object because the percentage is what an alert
|
||||
// threshold keys off, and deriving it server-side would break whenever the
|
||||
// byte fields are absent.
|
||||
private double? GetMemoryPercent()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT TotalVisibleMemorySize, FreePhysicalMemory " +
|
||||
"FROM Win32_OperatingSystem");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var totalKb = ToLong(mo["TotalVisibleMemorySize"]);
|
||||
var freeKb = ToLong(mo["FreePhysicalMemory"]);
|
||||
if (totalKb is long total && total > 0 && freeKb is long free)
|
||||
{
|
||||
var used = (double)(total - free) / total * 100.0;
|
||||
return Math.Round(Math.Clamp(used, 0, 100), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Memory percent query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Idle time is measured by the user-context probe, because GetLastInputInfo
|
||||
// is per session and this service runs in session 0. Null when no probe
|
||||
// result is available, which correctly reads as "not observed" rather than
|
||||
// "the machine has been idle for zero seconds".
|
||||
private long? GetIdleSecondsFromUserContext()
|
||||
{
|
||||
if (lastUserContext is not Dictionary<string, object?> context)
|
||||
return null;
|
||||
if (!context.TryGetValue("idleseconds", out var raw) || raw is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
if (raw is JsonElement element)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Number
|
||||
&& element.TryGetInt64(out var parsed) ? parsed : null;
|
||||
}
|
||||
return Convert.ToInt64(raw);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Could not read idleseconds from the user context");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listening TCP ports with the owning process name.
|
||||
//
|
||||
// Only listeners, never established connections: listeners answer "what
|
||||
// service is this machine offering" (the DNC port, the MTConnect adapter),
|
||||
// which is stable and small. Established connections churn constantly and
|
||||
// would make every report differ from the last for no diagnostic gain, so
|
||||
// the full table is exposed as an on-demand command instead.
|
||||
private const int MaxListeners = 100;
|
||||
|
||||
private object[] GetListeningPorts()
|
||||
{
|
||||
try
|
||||
{
|
||||
var listeners = TcpTableReader.GetListeners();
|
||||
if (listeners.Count == 0)
|
||||
return Array.Empty<object>();
|
||||
|
||||
var names = GetProcessNames();
|
||||
var result = new List<object>();
|
||||
|
||||
foreach (var listener in listeners)
|
||||
{
|
||||
if (result.Count >= MaxListeners)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Listener list truncated at {Limit} entries.", MaxListeners);
|
||||
break;
|
||||
}
|
||||
|
||||
names.TryGetValue(listener.ProcessId, out var processName);
|
||||
result.Add(new
|
||||
{
|
||||
localaddress = listener.LocalAddress,
|
||||
localport = listener.LocalPort,
|
||||
processid = listener.ProcessId,
|
||||
processname = processName,
|
||||
protocol = "tcp",
|
||||
});
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Listening port enumeration failed");
|
||||
return Array.Empty<object>();
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the process table once and index by id. Resolving each listener
|
||||
// individually would be an O(n) call per row and can race with process exit.
|
||||
private Dictionary<int, string> GetProcessNames()
|
||||
{
|
||||
var names = new Dictionary<int, string>();
|
||||
try
|
||||
{
|
||||
foreach (var process in Process.GetProcesses())
|
||||
{
|
||||
try
|
||||
{
|
||||
names[process.Id] = process.ProcessName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The process exited between enumeration and read.
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Process name snapshot failed");
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// Session-scoped data, read from the spool file the tray writes. The service
|
||||
// only reads: it cannot collect this itself, because mapped drives and idle
|
||||
// time do not exist in session 0. Between reads the previous result is
|
||||
// repeated, because a mapped drive stays mapped and dropping the key would
|
||||
// read on the server as "the drive went away". The spool's own age limit is
|
||||
// what eventually retires a result, so a logged-off machine goes quiet
|
||||
// instead of reporting the last operator forever.
|
||||
private object? GetUserContext()
|
||||
{
|
||||
if (userContextSpool is null)
|
||||
return null;
|
||||
|
||||
var resultPath = userContextSpool.TryGetResultPath(out var reason);
|
||||
if (resultPath is null)
|
||||
{
|
||||
logger.LogDebug("Skipping the user-context read: {Reason}", reason);
|
||||
return lastUserContext;
|
||||
}
|
||||
|
||||
// Peek rather than consume: the probe overwrites the spool on every run,
|
||||
// so the newest result is always the one on disk. The file deliberately
|
||||
// survives a service restart, which the previous delete-on-read did not -
|
||||
// lastUserContext is in-memory only, so a restart used to leave the agent
|
||||
// with no drives to report until the tray's next probe up to 30 minutes
|
||||
// later. lastUserContext still repeats the previous value between reads,
|
||||
// because the tray collects on a slower cadence than telemetry and
|
||||
// dropping the key in between would read on the server as "the drives
|
||||
// went away".
|
||||
var collected = userContextSpool.ConsumeResult(resultPath);
|
||||
if (collected is not null)
|
||||
lastUserContext = collected;
|
||||
|
||||
return lastUserContext;
|
||||
}
|
||||
|
||||
// The interactive tray app ships and updates independently of this service,
|
||||
// so the agent reports the tray's file version as its own field. Resolve the
|
||||
// tray exe beside the agent first (the normal install layout), then the
|
||||
@@ -296,6 +518,7 @@ public sealed class EndpointCollector : ICollector
|
||||
private object[] GetInterfaces()
|
||||
{
|
||||
var result = new List<object>();
|
||||
var linkstate = GetLinkState();
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
@@ -321,6 +544,10 @@ public sealed class EndpointCollector : ICollector
|
||||
var gateway = gateways is null || gateways.Length == 0
|
||||
? null : gateways[0];
|
||||
|
||||
// Paired by MAC from MSFT_NetAdapter. Absent when that class is
|
||||
// unavailable, which must leave the rest of the entry intact.
|
||||
linkstate.TryGetValue(NormalizeMac(mac) ?? "", out var link);
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
alias = mo["Description"]?.ToString()?.Trim() ?? "",
|
||||
@@ -332,6 +559,10 @@ public sealed class EndpointCollector : ICollector
|
||||
gateway = gateway ?? "",
|
||||
subnet = subnet ?? "",
|
||||
dhcp = mo["DHCPEnabled"] as bool? ?? false,
|
||||
// Null rather than 0/false when link state was not
|
||||
// readable: an unknown duplex must not read as half.
|
||||
linkspeedbps = link.Speed,
|
||||
fullduplex = link.FullDuplex,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -451,40 +682,33 @@ public sealed class EndpointCollector : ICollector
|
||||
}
|
||||
}
|
||||
|
||||
// Walk each curated channel newest-first, keep only the curated IDs for
|
||||
// that channel, cap per channel. Reads Security/System/Application - the
|
||||
// old scaffold read System only and filtered by the wrong IDs.
|
||||
// Walk each curated channel newest-first and report ONLY records newer than
|
||||
// the stored watermark, so a record crosses the wire exactly once.
|
||||
//
|
||||
// Two things changed here versus the original scaffold. First, filtering by
|
||||
// event id now happens inside the event log service through an XPath query
|
||||
// instead of walking every entry in the channel from C#; on a busy Security
|
||||
// log that is the difference between touching a handful of records and
|
||||
// hundreds of thousands. Second, EventLogReader (not the legacy EventLog
|
||||
// API) exposes RecordId, which is what makes exactly-once possible.
|
||||
//
|
||||
// First contact on a channel has no watermark, so it sends a bounded tail
|
||||
// (MaxEventsPerChannel) for initial context rather than the whole backlog.
|
||||
// The same cap also bounds a burst: if more than the cap arrived since the
|
||||
// last cycle, the NEWEST are taken and the watermark still jumps to the
|
||||
// newest, so the payload can never balloon.
|
||||
private EndpointEventRecord[] GetCuratedEvents()
|
||||
{
|
||||
var records = new List<EndpointEventRecord>();
|
||||
foreach (var channel in CuratedEventCatalog.Channels)
|
||||
{
|
||||
var wanted = CuratedEventCatalog.IdsForChannel(channel);
|
||||
if (wanted.Count == 0)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
using var log = new EventLog(channel);
|
||||
int kept = 0;
|
||||
for (int i = log.Entries.Count - 1;
|
||||
i >= 0 && kept < MaxEventsPerChannel; i--)
|
||||
{
|
||||
EventLogEntry entry;
|
||||
try { entry = log.Entries[i]; } catch { continue; }
|
||||
|
||||
int id = (int)(entry.InstanceId & 0xFFFF);
|
||||
if (!wanted.Contains(id))
|
||||
continue;
|
||||
|
||||
records.Add(new EndpointEventRecord
|
||||
{
|
||||
EventId = id,
|
||||
EventChannel = channel,
|
||||
EventSource = entry.Source,
|
||||
EventTime = new DateTimeOffset(
|
||||
entry.TimeGenerated.ToUniversalTime(), TimeSpan.Zero),
|
||||
EventMessage = Truncate(entry.Message, 1000),
|
||||
});
|
||||
kept++;
|
||||
}
|
||||
records.AddRange(ReadChannel(channel, wanted));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
@@ -493,9 +717,172 @@ public sealed class EndpointCollector : ICollector
|
||||
logger.LogDebug(error, "Event channel {Channel} unreadable", channel);
|
||||
}
|
||||
}
|
||||
|
||||
watermarks?.Save();
|
||||
return records.ToArray();
|
||||
}
|
||||
|
||||
private IEnumerable<EndpointEventRecord> ReadChannel(
|
||||
string channel, IReadOnlySet<int> wanted)
|
||||
{
|
||||
long watermark = watermarks?.GetWatermark(channel) ?? 0;
|
||||
bool seeding = watermarks is null || !watermarks.HasWatermark(channel);
|
||||
|
||||
var query = new EventLogQuery(channel, PathType.LogName, BuildIdFilter(wanted))
|
||||
{
|
||||
// Newest-first so the cap keeps the most recent records and we can
|
||||
// stop as soon as we reach the watermark.
|
||||
ReverseDirection = true,
|
||||
};
|
||||
|
||||
var found = new List<EndpointEventRecord>();
|
||||
long newestSeen = 0;
|
||||
|
||||
using var reader = new EventLogReader(query);
|
||||
while (found.Count < MaxEventsPerChannel)
|
||||
{
|
||||
EventRecord? entry;
|
||||
try { entry = reader.ReadEvent(); }
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Event read failed on {Channel}", channel);
|
||||
break;
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
break;
|
||||
|
||||
using (entry)
|
||||
{
|
||||
long recordId = entry.RecordId ?? 0;
|
||||
if (recordId > newestSeen)
|
||||
newestSeen = recordId;
|
||||
|
||||
// Reverse order means everything from here down is older than
|
||||
// the watermark and has already been reported.
|
||||
if (!seeding && recordId != 0 && recordId <= watermark)
|
||||
break;
|
||||
|
||||
found.Add(ToEventRecord(entry, channel, recordId));
|
||||
}
|
||||
}
|
||||
|
||||
// A cleared log restarts record ids low; reseed rather than go silent.
|
||||
if (!seeding && newestSeen > 0
|
||||
&& watermarks is not null && watermarks.ResetIfStale(channel, newestSeen))
|
||||
{
|
||||
return found;
|
||||
}
|
||||
|
||||
if (newestSeen > 0)
|
||||
watermarks?.Advance(channel, newestSeen);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private EndpointEventRecord ToEventRecord(
|
||||
EventRecord entry, string channel, long recordId)
|
||||
{
|
||||
string? message = null;
|
||||
try { message = entry.FormatDescription(); }
|
||||
catch (Exception error)
|
||||
{
|
||||
// A provider whose message DLL is missing cannot render text; the
|
||||
// id, time, and source are still worth reporting.
|
||||
logger.LogDebug(error, "Event message not renderable on {Channel}", channel);
|
||||
}
|
||||
|
||||
return new EndpointEventRecord
|
||||
{
|
||||
EventId = entry.Id,
|
||||
EventRecordId = recordId == 0 ? null : recordId,
|
||||
EventChannel = channel,
|
||||
EventSource = entry.ProviderName,
|
||||
EventTime = entry.TimeCreated is DateTime created
|
||||
? new DateTimeOffset(created.ToUniversalTime(), TimeSpan.Zero)
|
||||
: null,
|
||||
EventMessage = Truncate(message, 1000),
|
||||
Fields = GetEventFields(entry, channel),
|
||||
};
|
||||
}
|
||||
|
||||
// Bounds on the structured field extraction. The rendered message is often
|
||||
// a paragraph of prose while the useful specifics (which account, which
|
||||
// logon type, which faulting module) live in EventData, so it is worth
|
||||
// carrying - but a single record can declare a lot of elements and some
|
||||
// providers emit large blobs, so both the count and each value are capped.
|
||||
private const int MaxEventFields = 30;
|
||||
private const int MaxEventFieldLength = 512;
|
||||
|
||||
// Pull the provider's structured payload out of the event XML into the
|
||||
// existing `fields` dictionary, which the server already stores as
|
||||
// payloadjson. Most providers use <EventData><Data Name="x">v</Data></>;
|
||||
// some use <UserData> with an arbitrary provider-defined subtree, so both
|
||||
// are walked. Unnamed <Data> elements keep their positional index as the
|
||||
// key, because for several catalog IDs position IS the meaning.
|
||||
//
|
||||
// Never throws: a provider with malformed or unreadable XML degrades to no
|
||||
// fields rather than losing the whole record.
|
||||
private Dictionary<string, object>? GetEventFields(EventRecord entry, string channel)
|
||||
{
|
||||
try
|
||||
{
|
||||
var xml = entry.ToXml();
|
||||
if (string.IsNullOrWhiteSpace(xml))
|
||||
return null;
|
||||
|
||||
var document = System.Xml.Linq.XDocument.Parse(xml);
|
||||
var root = document.Root;
|
||||
if (root is null)
|
||||
return null;
|
||||
|
||||
var container =
|
||||
root.Elements().FirstOrDefault(e => e.Name.LocalName == "EventData")
|
||||
?? root.Elements().FirstOrDefault(e => e.Name.LocalName == "UserData");
|
||||
if (container is null)
|
||||
return null;
|
||||
|
||||
var fields = new Dictionary<string, object>();
|
||||
int unnamed = 0;
|
||||
foreach (var element in container.Descendants())
|
||||
{
|
||||
if (fields.Count >= MaxEventFields)
|
||||
break;
|
||||
if (element.HasElements)
|
||||
continue;
|
||||
|
||||
var value = element.Value;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
continue;
|
||||
|
||||
var name = element.Attribute("Name")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
name = element.Name.LocalName == "Data"
|
||||
? "data" + unnamed++
|
||||
: element.Name.LocalName;
|
||||
}
|
||||
|
||||
fields[name!] = Truncate(value, MaxEventFieldLength) ?? string.Empty;
|
||||
}
|
||||
|
||||
return fields.Count > 0 ? fields : null;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Event XML not parseable on {Channel}", channel);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// An XPath the event log service evaluates itself, e.g.
|
||||
// *[System[(EventID=4624 or EventID=4625)]]
|
||||
private static string BuildIdFilter(IReadOnlySet<int> wanted)
|
||||
{
|
||||
var clauses = string.Join(" or ", wanted.Select(id => "EventID=" + id));
|
||||
return "*[System[(" + clauses + ")]]";
|
||||
}
|
||||
|
||||
// Installed-software SBOM. Mirrors the Assess-Orchestrator applications
|
||||
// collection exactly: enumerate both Uninstall registry hives (64-bit and
|
||||
// WOW6432Node), keep only entries with a DisplayName, and emit
|
||||
@@ -566,6 +953,243 @@ public sealed class EndpointCollector : ICollector
|
||||
}
|
||||
}
|
||||
|
||||
// Whether Windows is waiting on a reboot.
|
||||
//
|
||||
// There is no single API for this, so we check the three signals Windows
|
||||
// itself uses: the CBS servicing flag, the Windows Update auto-update flag,
|
||||
// and a queued file-rename operation. Any one of them is sufficient. Checked
|
||||
// by key EXISTENCE rather than value, which is how these flags work - the
|
||||
// key is created when a reboot is needed and deleted once it happens.
|
||||
// Link speed and duplex, keyed by MAC so the caller can pair them onto the
|
||||
// matching GetInterfaces() entry. Deliberately a separate query: the
|
||||
// IPEnabled config class above does not carry link state, and a NIC that
|
||||
// negotiated 100Mb half-duplex on a gigabit switch port is a cable/port
|
||||
// fault that otherwise presents as "the machine is slow sometimes".
|
||||
private Dictionary<string, (long? Speed, bool? FullDuplex)> GetLinkState()
|
||||
{
|
||||
var byMac = new Dictionary<string, (long?, bool?)>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// MSFT_NetAdapter carries duplex; Win32_NetworkAdapter does not. It
|
||||
// lives in the same Storage-style optional namespace family, so a
|
||||
// failure here must not take the interface list with it.
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
@"root\StandardCimv2",
|
||||
"SELECT PermanentAddress, Speed, FullDuplex, MediaConnectState " +
|
||||
"FROM MSFT_NetAdapter");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var mac = NormalizeMac(mo["PermanentAddress"]?.ToString());
|
||||
if (mac is null)
|
||||
continue;
|
||||
byMac[mac] = (ToLong(mo["Speed"]), mo["FullDuplex"] as bool?);
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Net adapter link state query failed");
|
||||
}
|
||||
|
||||
return byMac;
|
||||
}
|
||||
|
||||
// WMI reports MACs in several shapes (00:11:22, 001122AABBCC). Strip to hex
|
||||
// so the two sources can be matched at all.
|
||||
private static string? NormalizeMac(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
var hex = new string(Array.FindAll(raw.ToCharArray(), Uri.IsHexDigit));
|
||||
return hex.Length == 12 ? hex : null;
|
||||
}
|
||||
|
||||
// Whether the last start was a true cold boot or a fast-startup resume.
|
||||
//
|
||||
// This matters because uptime LIES under fast startup: the box reports days
|
||||
// of uptime after what the operator experienced as a shutdown, and a
|
||||
// "reboot" that was really a hybrid resume does not clear the driver or
|
||||
// kernel state that a support call is usually trying to clear.
|
||||
//
|
||||
// Two distinct facts, deliberately not collapsed:
|
||||
// hiberbootenabled - fast startup is CONFIGURED (registry, always readable)
|
||||
// lastboottype - what the last boot ACTUALLY did (Kernel-Boot event 27)
|
||||
// The registry alone cannot tell you what the last boot did, and the event
|
||||
// may have aged out of the log, so neither substitutes for the other.
|
||||
private object? GetBootInfo()
|
||||
{
|
||||
bool? hiberboot = null;
|
||||
try
|
||||
{
|
||||
using var power = Registry.LocalMachine.OpenSubKey(
|
||||
@"SYSTEM\CurrentControlSet\Control\Session Manager\Power");
|
||||
if (power?.GetValue("HiberbootEnabled") is int raw)
|
||||
hiberboot = raw != 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Hiberboot registry read failed");
|
||||
}
|
||||
|
||||
string? lastboottype = null;
|
||||
try
|
||||
{
|
||||
// Kernel-Boot 27 BootType: 0 cold, 1 hybrid (fast startup),
|
||||
// 2 resume from hibernate.
|
||||
var query = new EventLogQuery(
|
||||
"System", PathType.LogName,
|
||||
"*[System[Provider[@Name='Microsoft-Windows-Kernel-Boot'] " +
|
||||
"and (EventID=27)]]")
|
||||
{ ReverseDirection = true };
|
||||
using var reader = new EventLogReader(query);
|
||||
using var record = reader.ReadEvent();
|
||||
if (record?.Properties.Count > 0)
|
||||
{
|
||||
lastboottype = ToInt(record.Properties[0].Value) switch
|
||||
{
|
||||
0 => "Cold",
|
||||
1 => "Fast startup",
|
||||
2 => "Resume",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Boot type event read failed");
|
||||
}
|
||||
|
||||
if (hiberboot is null && lastboottype is null)
|
||||
return null;
|
||||
|
||||
return new
|
||||
{
|
||||
hiberbootenabled = hiberboot,
|
||||
lastboottype = lastboottype ?? "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
// Defender status. Collected and persisted but NOT surfaced: this
|
||||
// environment is known to produce false positives, so wiring it to a card
|
||||
// or an alert now would train operators to ignore the card. Stored so the
|
||||
// signal is already there when someone is ready to interpret it.
|
||||
private object? GetDefender()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
@"root\Microsoft\Windows\Defender",
|
||||
"SELECT AMServiceEnabled, RealTimeProtectionEnabled, " +
|
||||
"AntivirusSignatureAge, AntispywareSignatureAge, " +
|
||||
"QuickScanAge FROM MSFT_MpComputerStatus");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
return new
|
||||
{
|
||||
serviceenabled = mo["AMServiceEnabled"] as bool?,
|
||||
realtimeenabled = mo["RealTimeProtectionEnabled"] as bool?,
|
||||
// Ages in days. Null when Defender did not report rather
|
||||
// than 0, which would read as "updated today".
|
||||
signatureagedays = ToLong(mo["AntivirusSignatureAge"]),
|
||||
antispywareagedays = ToLong(mo["AntispywareSignatureAge"]),
|
||||
quickscanagedays = ToLong(mo["QuickScanAge"]),
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// A third-party AV box or a stripped image has no Defender WMI
|
||||
// provider at all. That is normal here, not a fault.
|
||||
logger.LogDebug(error, "Defender status query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Which signal(s) drove pendingreboot=true, in report order.
|
||||
//
|
||||
// pendingreboot is a single bool OR-ed from three sources, so it cannot say
|
||||
// WHY. That distinction is the point: PendingFileRenameOperations is the
|
||||
// stronger signal - files are staged to be replaced on next boot, so the
|
||||
// box is mid-servicing - whereas a lone CBS or Windows Update key can sit
|
||||
// set for weeks on a machine nobody has rebooted. Same boolean, very
|
||||
// different urgency.
|
||||
//
|
||||
// Empty array = checked, nothing pending. Null = the check failed, matching
|
||||
// pendingreboot's own null-is-unknown rule.
|
||||
private string[]? GetRebootReasons()
|
||||
{
|
||||
try
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
if (LocalMachineKeyExists(
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"))
|
||||
{
|
||||
reasons.Add("componentbasedservicing");
|
||||
}
|
||||
|
||||
if (LocalMachineKeyExists(
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"))
|
||||
{
|
||||
reasons.Add("windowsupdate");
|
||||
}
|
||||
|
||||
using var sessionManager = Registry.LocalMachine.OpenSubKey(
|
||||
@"SYSTEM\CurrentControlSet\Control\Session Manager");
|
||||
if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending
|
||||
&& pending.Length > 0)
|
||||
{
|
||||
reasons.Add("pendingfilerename");
|
||||
}
|
||||
|
||||
return reasons.ToArray();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Reboot reason check failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool? GetPendingReboot()
|
||||
{
|
||||
try
|
||||
{
|
||||
const string cbs =
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending";
|
||||
const string windowsUpdate =
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired";
|
||||
|
||||
if (LocalMachineKeyExists(cbs) || LocalMachineKeyExists(windowsUpdate))
|
||||
return true;
|
||||
|
||||
using var sessionManager = Registry.LocalMachine.OpenSubKey(
|
||||
@"SYSTEM\CurrentControlSet\Control\Session Manager");
|
||||
if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending
|
||||
&& pending.Length > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Null, not false: an unreadable registry means we do not know, and
|
||||
// reporting a confident "no reboot pending" would be worse than
|
||||
// reporting nothing.
|
||||
logger.LogDebug(error, "Pending reboot check failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LocalMachineKeyExists(string path)
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(path);
|
||||
return key is not null;
|
||||
}
|
||||
|
||||
// Fixed local disks with capacity/free so the server can flag a PC that is
|
||||
// running out of space. Win32_LogicalDisk DriveType 3 = local fixed disk;
|
||||
// network/removable drives are skipped. Bytes stay as longs; the server can
|
||||
@@ -591,6 +1215,14 @@ public sealed class EndpointCollector : ICollector
|
||||
freebytes = free,
|
||||
usedbytes = size is long total && free is long available
|
||||
? total - available : (long?)null,
|
||||
// Precomputed so an alert threshold is a direct comparison.
|
||||
// A 40GB free reading means nothing without the capacity it
|
||||
// came from, and the server would have to guard against a
|
||||
// null or zero size on every evaluation to derive it.
|
||||
freepercent = size is long capacity && capacity > 0
|
||||
&& free is long remaining
|
||||
? Math.Round((double)remaining / capacity * 100.0, 1)
|
||||
: (double?)null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -601,6 +1233,129 @@ public sealed class EndpointCollector : ICollector
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
// Physical drive health, so a failing disk is caught before it takes the
|
||||
// machine and whatever was local on it.
|
||||
//
|
||||
// Two sources, because neither covers the fleet alone:
|
||||
//
|
||||
// MSFT_PhysicalDisk (Storage namespace) is the only one that reports NVMe.
|
||||
// The classic SMART class, MSStorageDriver_FailurePredictStatus, is ATA/SATA
|
||||
// only and returns NOTHING on an NVMe box - verified on a two-NVMe machine
|
||||
// here, where it yielded zero instances while the drives were present and
|
||||
// healthy. Collecting only that class would have reported an empty disk
|
||||
// health list on every modern endpoint and looked like "no problems".
|
||||
//
|
||||
// Win32_DiskDrive.Status is the old reliable string ("OK"/"Pred Fail") and
|
||||
// covers anything the Storage namespace does not, including machines where
|
||||
// that namespace is missing or blocked.
|
||||
//
|
||||
// Keyed by DeviceId so the server can pair the two views per drive the same
|
||||
// way mapped drives are paired.
|
||||
private object[] GetDiskHealth()
|
||||
{
|
||||
var result = new List<object>();
|
||||
|
||||
// MSFT_PhysicalDisk returns raw enum INTEGERS through
|
||||
// ManagementObjectSearcher, not the friendly strings Get-PhysicalDisk
|
||||
// prints. Verified on a live box: Health=0 Op=2 for a healthy NVMe.
|
||||
// Mapping them here keeps the wire self-describing; shipping bare
|
||||
// integers would make the server guess at an undocumented enum.
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
@"root\Microsoft\Windows\Storage",
|
||||
"SELECT DeviceId, FriendlyName, MediaType, BusType, " +
|
||||
"HealthStatus, OperationalStatus FROM MSFT_PhysicalDisk");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var health = ToInt(mo["HealthStatus"]);
|
||||
result.Add(new
|
||||
{
|
||||
deviceid = mo["DeviceId"]?.ToString()?.Trim() ?? "",
|
||||
model = mo["FriendlyName"]?.ToString()?.Trim() ?? "",
|
||||
mediatype = PhysicalDiskMediaType(ToInt(mo["MediaType"])),
|
||||
bustype = PhysicalDiskBusType(ToInt(mo["BusType"])),
|
||||
healthstatus = PhysicalDiskHealth(health),
|
||||
// The actionable bit, precomputed so an alert is a direct
|
||||
// boolean test rather than a string comparison against an
|
||||
// enum the server would have to keep in sync with this file.
|
||||
// Null when health is unreadable: unknown is not healthy,
|
||||
// but it is not a confident failure either.
|
||||
predictfailure = health is null ? (bool?)null : health != 0,
|
||||
source = "physicaldisk",
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Physical disk health query failed");
|
||||
}
|
||||
|
||||
// Win32_DiskDrive as the ATA/SATA and fallback view. Status is a plain
|
||||
// string here, no enum mapping needed.
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT DeviceID, Model, Status FROM Win32_DiskDrive");
|
||||
foreach (var mo in searcher.Get())
|
||||
{
|
||||
var status = mo["Status"]?.ToString()?.Trim() ?? "";
|
||||
result.Add(new
|
||||
{
|
||||
deviceid = mo["DeviceID"]?.ToString()?.Trim() ?? "",
|
||||
model = mo["Model"]?.ToString()?.Trim() ?? "",
|
||||
mediatype = "",
|
||||
bustype = "",
|
||||
healthstatus = status,
|
||||
// "OK" is the only healthy value this class reports;
|
||||
// "Pred Fail" is the SMART trip. An empty status means the
|
||||
// drive did not answer, which is unknown rather than failed.
|
||||
predictfailure = status.Length == 0
|
||||
? (bool?)null
|
||||
: !status.Equals("OK", StringComparison.OrdinalIgnoreCase),
|
||||
source = "diskdrive",
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Disk drive status query failed");
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
// MSFT_PhysicalDisk HealthStatus: 0 Healthy, 1 Warning, 2 Unhealthy.
|
||||
private static string PhysicalDiskHealth(int? value) => value switch
|
||||
{
|
||||
0 => "Healthy",
|
||||
1 => "Warning",
|
||||
2 => "Unhealthy",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
// MSFT_PhysicalDisk MediaType: 3 HDD, 4 SSD, 5 SCM.
|
||||
private static string PhysicalDiskMediaType(int? value) => value switch
|
||||
{
|
||||
3 => "HDD",
|
||||
4 => "SSD",
|
||||
5 => "SCM",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
// MSFT_PhysicalDisk BusType. Only the values that actually appear on an
|
||||
// endpoint are named; anything else stays numeric rather than guessed at.
|
||||
private static string PhysicalDiskBusType(int? value) => value switch
|
||||
{
|
||||
3 => "ATA",
|
||||
7 => "USB",
|
||||
8 => "RAID",
|
||||
9 => "iSCSI",
|
||||
11 => "SATA",
|
||||
17 => "NVMe",
|
||||
_ => value is null ? "Unknown" : value.Value.ToString(),
|
||||
};
|
||||
|
||||
// Firmware identity from Win32_BIOS: vendor, version string, and release
|
||||
// date. Lets the server track BIOS/UEFI levels for a fleet update campaign.
|
||||
private object? GetBios()
|
||||
@@ -737,6 +1492,14 @@ public sealed class EndpointCollector : ICollector
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static int? ToInt(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
return null;
|
||||
try { return Convert.ToInt32(value); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC
|
||||
// string; anything unparseable passes through untouched so nothing is lost.
|
||||
private string? ToBiosDate(string? raw)
|
||||
|
||||
132
agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs
Normal file
132
agent/ShopDbEndpointAgent.Core/EventWatermarkStore.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Remembers the newest Windows event record id already reported for each
|
||||
// curated channel, so a collect cycle harvests ONLY records the server has not
|
||||
// seen.
|
||||
//
|
||||
// Why this exists: the collector used to re-read the newest N records of every
|
||||
// channel on every cycle, and the server ingest appends event rows without a
|
||||
// dedupe check. A single crash therefore landed in the database once per
|
||||
// cadence tick for as long as it stayed inside that window - up to 96 identical
|
||||
// rows a day per event. Carrying a watermark makes the steady state "only what
|
||||
// is new", which is also what makes the full event XML affordable to send.
|
||||
//
|
||||
// The watermark is persisted under %ProgramData% (not in memory) because a
|
||||
// service restart would otherwise re-report the whole window again, which is
|
||||
// exactly the crash-loop case where duplicate events are most likely.
|
||||
//
|
||||
// Record ids are per channel and monotonic on a given machine. A cleared or
|
||||
// rebuilt event log restarts them at 1; that is handled by treating a stored
|
||||
// watermark HIGHER than the newest available record as stale and resetting it,
|
||||
// otherwise the channel would go permanently silent.
|
||||
public sealed class EventWatermarkStore
|
||||
{
|
||||
private const string StoreFileName = "eventwatermark.json";
|
||||
|
||||
// %ProgramData%\ShopDB\EndpointAgent - the same root the rolling agent log
|
||||
// lives under, so all agent-owned state sits in one place.
|
||||
public static string DefaultStateDirectory => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ShopDB", "EndpointAgent");
|
||||
|
||||
private readonly string storePath;
|
||||
private readonly ILogger<EventWatermarkStore> logger;
|
||||
private readonly Dictionary<string, long> watermarks;
|
||||
|
||||
public EventWatermarkStore(ILogger<EventWatermarkStore> logger,
|
||||
string? stateDirectory = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
var directory = string.IsNullOrWhiteSpace(stateDirectory)
|
||||
? DefaultStateDirectory
|
||||
: stateDirectory;
|
||||
storePath = Path.Combine(directory, StoreFileName);
|
||||
watermarks = Load();
|
||||
}
|
||||
|
||||
// The last record id reported for this channel, or 0 when the channel has
|
||||
// never been harvested. 0 means "first contact": the caller sends a bounded
|
||||
// tail for initial context rather than the entire backlog.
|
||||
public long GetWatermark(string channel)
|
||||
=> watermarks.TryGetValue(channel, out var value) ? value : 0;
|
||||
|
||||
public bool HasWatermark(string channel)
|
||||
=> watermarks.ContainsKey(channel);
|
||||
|
||||
// Advance the channel forward only. Never moves backwards, so an
|
||||
// out-of-order read cannot cause the same record to be reported twice.
|
||||
public void Advance(string channel, long recordId)
|
||||
{
|
||||
if (recordId <= 0)
|
||||
return;
|
||||
if (watermarks.TryGetValue(channel, out var current) && current >= recordId)
|
||||
return;
|
||||
watermarks[channel] = recordId;
|
||||
}
|
||||
|
||||
// A cleared or rebuilt log restarts record ids near 1. When the newest
|
||||
// record available is below the stored watermark the store is stale, so
|
||||
// drop it and let the channel re-seed instead of going silent forever.
|
||||
public bool ResetIfStale(string channel, long newestAvailableRecordId)
|
||||
{
|
||||
if (newestAvailableRecordId <= 0)
|
||||
return false;
|
||||
if (!watermarks.TryGetValue(channel, out var current) || current <= newestAvailableRecordId)
|
||||
return false;
|
||||
|
||||
logger.LogInformation(
|
||||
"Event channel {Channel} record id went backwards ({Stored} -> {Newest}); log was cleared, reseeding",
|
||||
channel, current, newestAvailableRecordId);
|
||||
watermarks.Remove(channel);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Best-effort persist. A failure here costs at most a repeated report of
|
||||
// records already sent, so it must never abort a collection.
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(storePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Write-then-rename so a crash mid-write cannot leave a truncated
|
||||
// file that would reseed every channel on the next start.
|
||||
var temporaryPath = storePath + ".tmp";
|
||||
File.WriteAllText(temporaryPath,
|
||||
JsonSerializer.Serialize(watermarks));
|
||||
File.Move(temporaryPath, storePath, overwrite: true);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Could not persist event watermarks to {Path}", storePath);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, long> Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(storePath))
|
||||
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var loaded = JsonSerializer.Deserialize<Dictionary<string, long>>(
|
||||
File.ReadAllText(storePath));
|
||||
return loaded is null
|
||||
? new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
|
||||
: new Dictionary<string, long>(loaded, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// A corrupt store reseeds every channel, which costs one bounded
|
||||
// tail per channel - acceptable versus failing the collection.
|
||||
logger.LogWarning(error,
|
||||
"Event watermark store unreadable at {Path}; reseeding channels", storePath);
|
||||
return new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,32 +14,74 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
private readonly string directory;
|
||||
private readonly LogLevel minLevel;
|
||||
private readonly int retentionDays;
|
||||
private readonly string filePrefix;
|
||||
private readonly object gate = new();
|
||||
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
|
||||
private string currentDateStamp = "";
|
||||
private StreamWriter? writer;
|
||||
|
||||
// Why the sink can be unusable, or null when it opened normally. The
|
||||
// constructor cannot throw (see below), but a caller that has a second,
|
||||
// known-writable place to complain to needs to be able to ask.
|
||||
public string? SinkProblem { get; private set; }
|
||||
|
||||
public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information,
|
||||
int retentionDays = 14)
|
||||
int retentionDays = 14, string filePrefix = "agent")
|
||||
{
|
||||
this.directory = directory;
|
||||
this.minLevel = minLevel;
|
||||
this.retentionDays = retentionDays;
|
||||
this.filePrefix = filePrefix;
|
||||
// Best-effort: creating the log directory can fail on a locked-down host
|
||||
// or when a non-privileged user targets a SYSTEM-owned path. Never let
|
||||
// that abort construction; Write() degrades to a no-op if the sink is
|
||||
// unusable.
|
||||
//
|
||||
// The reason is KEPT rather than discarded. A silently dead sink is what
|
||||
// made the user-context probe undiagnosable: running as the operator it
|
||||
// could not write into the SYSTEM-owned ProgramData log directory, so a
|
||||
// probe that failed and a probe that never ran produced identical
|
||||
// silence. Callers that have a writable fallback can now surface this.
|
||||
try { Directory.CreateDirectory(directory); }
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException) { }
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
SinkProblem = $"{directory}: {error.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// The default log directory when config leaves LogPath empty:
|
||||
// %ProgramData%\ShopDB\EndpointAgent\logs
|
||||
//
|
||||
// SERVICE-ONLY. This path is SYSTEM-owned and a signed-in operator cannot
|
||||
// write to it, so anything running in the user's session must use
|
||||
// UserProfileLogDirectory() instead.
|
||||
public static string DefaultDirectory()
|
||||
=> Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ShopDB", "EndpointAgent", "logs");
|
||||
|
||||
// The shop-floor operator account. Every shop-floor endpoint uses this same
|
||||
// local account name, so the path is fixed rather than resolved through the
|
||||
// interactive token: the token lookup is the part that fails on a hardened
|
||||
// endpoint, and when it fails the writer and the reader silently disagree
|
||||
// about where the spool lives. If a site ever uses a different account only
|
||||
// user-context is lost - telemetry, commands and inventory are unaffected.
|
||||
public const string ShopFloorUserName = "ShopFloor";
|
||||
|
||||
public static string ShopFloorProfileDirectory()
|
||||
=> Path.Combine(DrivesRoot, "Users", ShopFloorUserName);
|
||||
|
||||
private static string DrivesRoot =>
|
||||
Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows))
|
||||
?? @"C:\";
|
||||
|
||||
// Where the TRAY and the user-context probe log:
|
||||
// C:\Users\ShopFloor\ShopDB\Logs
|
||||
// The operator owns this directory, so it is writable from the user session
|
||||
// where ProgramData is not.
|
||||
public static string UserProfileLogDirectory()
|
||||
=> Path.Combine(ShopFloorProfileDirectory(), "ShopDB", "Logs");
|
||||
|
||||
public ILogger CreateLogger(string categoryName)
|
||||
=> loggers.GetOrAdd(categoryName, name => new FileLogger(this, name));
|
||||
|
||||
@@ -85,7 +127,14 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
writer?.Flush();
|
||||
writer?.Dispose();
|
||||
currentDateStamp = stamp;
|
||||
var path = Path.Combine(directory, $"agent-{stamp}.log");
|
||||
// One convention for every log this codebase writes:
|
||||
// "<yyyyMMdd>-<prefix>.log", so agent, tray and installer logs sort
|
||||
// chronologically when they share a directory and can be collected with
|
||||
// a single date-leading glob. The service previously used the opposite
|
||||
// order, which meant a support request for "the logs from the 5th" had to
|
||||
// know which component wrote which shape.
|
||||
var name = $"{stamp}-{filePrefix}.log";
|
||||
var path = Path.Combine(directory, name);
|
||||
writer = new StreamWriter(new FileStream(
|
||||
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
||||
{
|
||||
@@ -99,7 +148,12 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||
foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log"))
|
||||
// Both shapes: retention has to keep covering the "<prefix>-<date>"
|
||||
// files written before the convention was unified, or an upgraded
|
||||
// endpoint would keep its pre-upgrade logs forever.
|
||||
string[] patterns = [$"*-{filePrefix}.log", $"{filePrefix}-*.log"];
|
||||
foreach (var pattern in patterns)
|
||||
foreach (var file in Directory.EnumerateFiles(directory, pattern))
|
||||
{
|
||||
if (File.GetLastWriteTime(file) < cutoff)
|
||||
File.Delete(file);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
@@ -18,7 +19,11 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// Only process_start uses this. Stop/status/service actions stay as SYSTEM.
|
||||
public static class InteractiveProcessLauncher
|
||||
{
|
||||
public readonly record struct LaunchOutcome(bool Launched, string? Error, int? ExitCode = null);
|
||||
// TimedOut=true means the process launched fine but was still running when
|
||||
// the caller's wait budget expired. It is deliberately NOT killed, so the
|
||||
// work still completes; the caller just stops blocking on it.
|
||||
public readonly record struct LaunchOutcome(
|
||||
bool Launched, string? Error, int? ExitCode = null, bool TimedOut = false);
|
||||
|
||||
private const int TokenDuplicate = 0x0002;
|
||||
private const int TokenQuery = 0x0008;
|
||||
@@ -34,6 +39,10 @@ public static class InteractiveProcessLauncher
|
||||
|
||||
private const uint Infinite = 0xFFFFFFFF;
|
||||
|
||||
// WaitForSingleObject returns this when the budget expires with the handle
|
||||
// still unsignalled.
|
||||
private const uint WaitTimeout = 0x00000102;
|
||||
|
||||
// Returns Launched=true when CreateProcessAsUser succeeds. Never throws;
|
||||
// failures come back as Launched=false with a descriptive Error so the
|
||||
// caller can surface a clean CommandResult.
|
||||
@@ -51,9 +60,17 @@ public static class InteractiveProcessLauncher
|
||||
// needs the actual outcome (run_script in the user session) can report
|
||||
// completed/failed instead of fire-and-forget "launched". GUI callers
|
||||
// (process_start) leave it false and return as soon as the window is up.
|
||||
//
|
||||
// waitTimeout bounds that wait. This matters because the caller may be the
|
||||
// telemetry loop, and telemetry IS the heartbeat: a script that hangs in the
|
||||
// user session used to block here forever (the wait was hardcoded to
|
||||
// Infinite), which would stall reporting and make a perfectly healthy PC
|
||||
// read as offline in the WebUI. On expiry the child is left running and the
|
||||
// outcome comes back with TimedOut=true. Null means wait forever and should
|
||||
// only be used by a caller that is not on a timed path.
|
||||
public static LaunchOutcome LaunchInActiveSession(
|
||||
string applicationPath, string? arguments, string? workingDirectory,
|
||||
bool waitForExit = false)
|
||||
bool waitForExit = false, TimeSpan? waitTimeout = null)
|
||||
{
|
||||
var sessionId = WTSGetActiveConsoleSessionId();
|
||||
if (sessionId == InvalidSessionId)
|
||||
@@ -129,11 +146,25 @@ public static class InteractiveProcessLauncher
|
||||
}
|
||||
|
||||
int? exitCode = null;
|
||||
bool timedOut = false;
|
||||
if (waitForExit && processInformation.hProcess != IntPtr.Zero)
|
||||
{
|
||||
WaitForSingleObject(processInformation.hProcess, Infinite);
|
||||
if (GetExitCodeProcess(processInformation.hProcess, out var code))
|
||||
uint milliseconds = waitTimeout is TimeSpan budget
|
||||
? (uint)Math.Clamp(budget.TotalMilliseconds, 0, uint.MaxValue - 1)
|
||||
: Infinite;
|
||||
|
||||
if (WaitForSingleObject(processInformation.hProcess, milliseconds)
|
||||
== WaitTimeout)
|
||||
{
|
||||
// Still running. Leave it alone - killing it would abandon
|
||||
// half-done work, and a file-spooling probe will still write
|
||||
// its result for the next cycle to pick up.
|
||||
timedOut = true;
|
||||
}
|
||||
else if (GetExitCodeProcess(processInformation.hProcess, out var code))
|
||||
{
|
||||
exitCode = unchecked((int)code);
|
||||
}
|
||||
}
|
||||
|
||||
if (processInformation.hProcess != IntPtr.Zero)
|
||||
@@ -141,7 +172,7 @@ public static class InteractiveProcessLauncher
|
||||
if (processInformation.hThread != IntPtr.Zero)
|
||||
CloseHandle(processInformation.hThread);
|
||||
|
||||
return new LaunchOutcome(true, null, exitCode);
|
||||
return new LaunchOutcome(true, null, exitCode, timedOut);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -154,6 +185,57 @@ public static class InteractiveProcessLauncher
|
||||
}
|
||||
}
|
||||
|
||||
// Profile directory of the active console user, for example
|
||||
// C:\Users\ShopFloor. The agent runs as LocalSystem, so expanding
|
||||
// %USERPROFILE% here would return SYSTEM's own profile under
|
||||
// config\systemprofile rather than the operator's. The interactive user's
|
||||
// token is the only reliable source, and this class already knows how to
|
||||
// obtain it. Returns null with a reason when nobody is signed in.
|
||||
public static string? TryGetActiveUserProfileDirectory(out string? error)
|
||||
{
|
||||
var sessionId = WTSGetActiveConsoleSessionId();
|
||||
if (sessionId == InvalidSessionId)
|
||||
{
|
||||
error = "No active console session (no interactive user is signed in).";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!WTSQueryUserToken(sessionId, out var userToken))
|
||||
{
|
||||
error = "Could not obtain the interactive user token: "
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
uint size = 0;
|
||||
GetUserProfileDirectory(userToken, null, ref size);
|
||||
if (size == 0)
|
||||
{
|
||||
error = "Could not size the user profile directory: "
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
|
||||
return null;
|
||||
}
|
||||
|
||||
var buffer = new StringBuilder((int)size);
|
||||
if (!GetUserProfileDirectory(userToken, buffer, ref size))
|
||||
{
|
||||
error = "Could not resolve the user profile directory: "
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
|
||||
return null;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return buffer.ToString();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (userToken != IntPtr.Zero)
|
||||
CloseHandle(userToken);
|
||||
}
|
||||
}
|
||||
|
||||
private enum SecurityImpersonationLevel
|
||||
{
|
||||
SecurityAnonymous,
|
||||
@@ -222,6 +304,10 @@ public static class InteractiveProcessLauncher
|
||||
[DllImport("userenv.dll", SetLastError = true)]
|
||||
private static extern bool DestroyEnvironmentBlock(IntPtr environment);
|
||||
|
||||
[DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool GetUserProfileDirectory(
|
||||
IntPtr token, StringBuilder? profileDirectory, ref uint size);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CreateProcessAsUser(
|
||||
IntPtr token,
|
||||
|
||||
159
agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs
Normal file
159
agent/ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Guards which registered scheduled tasks the scheduledtask_run action may
|
||||
// start. The wire carries a free-text task path (so the WebUI can expose it as
|
||||
// one field, and a site can widen the set without an agent rebuild); this
|
||||
// policy is the safeguard that makes that free text safe to honor.
|
||||
//
|
||||
// WHY THIS EXISTS AT ALL. The agent runs as SYSTEM. A scheduled task is an
|
||||
// INDIRECTION: the task path names a definition, and that definition chooses
|
||||
// what actually runs. So "start the task named X" is only as safe as the set of
|
||||
// X the agent will accept - an unconstrained task name would be a general-
|
||||
// purpose "run anything already registered on this box, as whatever context it
|
||||
// asked for" primitive. That is a materially bigger grant than any other
|
||||
// allowlisted action, and it would be invisible in the audit trail (the command
|
||||
// log would read "scheduledtask_run \Foo" no matter what \Foo does).
|
||||
//
|
||||
// WHAT THIS DOES AND DOES NOT BUY. A prefix allowlist stops a compromised or
|
||||
// misconfigured SERVER from starting arbitrary tasks on the fleet - that is the
|
||||
// threat it actually addresses, and it is the realistic one. It does NOT stop an
|
||||
// attacker who can already register or rewrite task definitions on the endpoint:
|
||||
// such an attacker can define a task under an approved prefix and have the agent
|
||||
// start it. That is accepted, because anyone who can write task definitions on a
|
||||
// box already has the privilege the task would grant them. Local task definition
|
||||
// ACLs, not this policy, are the control for that.
|
||||
//
|
||||
// The approved-prefix list ships with the Corporate Tools set and is overridable
|
||||
// per site via AgentConfig.ApprovedTaskPaths, matching the LaunchPathPolicy
|
||||
// pattern so the future Site Agent Configuration tool can narrow or widen it
|
||||
// without a rebuild.
|
||||
public sealed class ScheduledTaskPolicy
|
||||
{
|
||||
// Shipped defaults: the Corporate Tools task set. Prefix entries end with a
|
||||
// backslash and match a whole folder; entries without one match that exact
|
||||
// task path only. Kept deliberately tight - widen per site, not here.
|
||||
//
|
||||
// The ShopDB-* entries are tasks ShopDB itself owns. They are separate task
|
||||
// definitions from any corporate-initiated equivalent ON PURPOSE: a
|
||||
// ShopDB-initiated Dell Command scan must write its results somewhere the
|
||||
// agent's collector can attribute to ShopDB, rather than into whatever
|
||||
// location a corporate-managed task already writes. Keeping the definitions
|
||||
// distinct is what makes "who asked for this scan" answerable at all - see
|
||||
// the note in commands.py about the DCU output path convention.
|
||||
public static IReadOnlyList<string> DefaultApprovedTaskPaths { get; } = new[]
|
||||
{
|
||||
@"\ReportIP",
|
||||
@"\SFLD-ApplyDSCConfig",
|
||||
@"\SFLD - Consume Credentials",
|
||||
@"\ShopDB-DellCommandScan",
|
||||
@"\ShopDB-DellCommandUpdate",
|
||||
};
|
||||
|
||||
private readonly IReadOnlyList<string> approvedPaths;
|
||||
|
||||
public ScheduledTaskPolicy(IEnumerable<string>? approvedPathsOverride = null)
|
||||
{
|
||||
var paths = (approvedPathsOverride ?? Enumerable.Empty<string>())
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Select(NormalizeTaskPath)
|
||||
.ToList();
|
||||
approvedPaths = paths.Count > 0 ? paths : DefaultApprovedTaskPaths;
|
||||
}
|
||||
|
||||
// Validates a candidate task path. On success taskPath is the normalized
|
||||
// path to hand to Task Scheduler; on failure reason explains the rejection
|
||||
// so the command result tells the operator WHY rather than just "denied".
|
||||
public bool TryValidate(string? candidate, out string taskPath, out string reason)
|
||||
{
|
||||
taskPath = "";
|
||||
reason = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
reason = "empty task path";
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = NormalizeTaskPath(candidate);
|
||||
|
||||
// Traversal in a task path is meaningless to Task Scheduler but very
|
||||
// meaningful to a prefix check: "\Approved\..\Evil" starts with an
|
||||
// approved prefix as a STRING while naming something else entirely.
|
||||
// Reject rather than try to canonicalize it away.
|
||||
if (normalized.Contains(".."))
|
||||
{
|
||||
reason = "task path contains a traversal segment";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Control characters and quotes have no place in a task path and are the
|
||||
// shape of an attempt to break out of the argument. The invocation uses
|
||||
// ArgumentList (no shell, no string concatenation) so this is defense in
|
||||
// depth rather than the primary control, but a task path that needs
|
||||
// these is not a task path we should be starting.
|
||||
foreach (var ch in normalized)
|
||||
{
|
||||
if (char.IsControl(ch) || ch == '"' || ch == '&' || ch == '|')
|
||||
{
|
||||
reason = "task path contains an illegal character";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!IsApproved(normalized))
|
||||
{
|
||||
reason = "task path is not in the approved task list";
|
||||
return false;
|
||||
}
|
||||
|
||||
taskPath = normalized;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Task Scheduler paths are rooted and backslash-separated. Accept the
|
||||
// operator writing "ReportIP", "\ReportIP", or "/ReportIP" and settle on the
|
||||
// rooted form, so an allowlist entry does not hinge on how it was typed.
|
||||
private static string NormalizeTaskPath(string path)
|
||||
{
|
||||
var trimmed = (path ?? "").Trim().Replace('/', '\\');
|
||||
if (trimmed.Length == 0)
|
||||
return "";
|
||||
if (!trimmed.StartsWith('\\'))
|
||||
trimmed = "\\" + trimmed;
|
||||
// Collapse a trailing separator so "\Folder\" and "\Folder" compare
|
||||
// equal as exact entries; prefix semantics are handled in IsApproved.
|
||||
while (trimmed.Length > 1 && trimmed.EndsWith('\\'))
|
||||
trimmed = trimmed[..^1];
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// An entry ending in a separator is a FOLDER prefix and matches anything
|
||||
// beneath it; any other entry must match the whole task path exactly. The
|
||||
// exact-match case is why "\Approved" does not silently authorize
|
||||
// "\ApprovedEvil".
|
||||
private bool IsApproved(string candidate)
|
||||
{
|
||||
foreach (var entry in approvedPaths)
|
||||
{
|
||||
if (entry.Length == 0)
|
||||
continue;
|
||||
|
||||
var isFolderPrefix = entry.EndsWith('\\');
|
||||
if (isFolderPrefix)
|
||||
{
|
||||
if (candidate.StartsWith(entry, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(candidate, entry, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Allow the folder form without its trailing separator to still act
|
||||
// as a folder: "\SFLD" approves "\SFLD\Thing" but never "\SFLDEvil".
|
||||
if (candidate.StartsWith(entry + "\\", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
<PackageReference Include="System.Diagnostics.EventLog" Version="8.0.0" />
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||
<!-- EventWaitHandleAcl: creating the user-context refresh event with an
|
||||
explicit DACL. The service creates it as LocalSystem, and without a
|
||||
granted ACE the unelevated tray cannot open it. -->
|
||||
<PackageReference Include="System.Threading.AccessControl" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
128
agent/ShopDbEndpointAgent.Core/TcpTableReader.cs
Normal file
128
agent/ShopDbEndpointAgent.Core/TcpTableReader.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using System.Net;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// TCP connection state with owning process ids, via GetExtendedTcpTable.
|
||||
//
|
||||
// Why P/Invoke instead of shelling out to netstat.exe: parsing netstat text
|
||||
// output means spawning a process on every telemetry tick and depending on the
|
||||
// column layout of a localized command. GetExtendedTcpTable is the API netstat
|
||||
// itself calls, returns process ids directly, and costs a single allocation.
|
||||
//
|
||||
// Listeners are the cheap, bounded half of this (a handful of rows, stable over
|
||||
// time) and ride the normal cadence. The full connection table is unbounded and
|
||||
// churns constantly, so it stays on-demand.
|
||||
public static class TcpTableReader
|
||||
{
|
||||
private const int AF_INET = 2;
|
||||
private const uint ERROR_INSUFFICIENT_BUFFER = 122;
|
||||
private const int TCP_TABLE_OWNER_PID_ALL = 5;
|
||||
|
||||
public enum TcpState
|
||||
{
|
||||
Closed = 1,
|
||||
Listen = 2,
|
||||
SynSent = 3,
|
||||
SynReceived = 4,
|
||||
Established = 5,
|
||||
FinWait1 = 6,
|
||||
FinWait2 = 7,
|
||||
CloseWait = 8,
|
||||
Closing = 9,
|
||||
LastAck = 10,
|
||||
TimeWait = 11,
|
||||
DeleteTcb = 12,
|
||||
}
|
||||
|
||||
public readonly record struct TcpRow(
|
||||
string LocalAddress,
|
||||
int LocalPort,
|
||||
string RemoteAddress,
|
||||
int RemotePort,
|
||||
TcpState State,
|
||||
int ProcessId);
|
||||
|
||||
// Full IPv4 TCP table. Throws only on catastrophic interop failure; callers
|
||||
// treat an empty list as "not observed".
|
||||
public static List<TcpRow> GetTcpTable()
|
||||
{
|
||||
var rows = new List<TcpRow>();
|
||||
var buffer = IntPtr.Zero;
|
||||
var size = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// First call sizes the buffer. It is expected to fail with
|
||||
// INSUFFICIENT_BUFFER; anything else is a real error.
|
||||
var result = GetExtendedTcpTable(
|
||||
IntPtr.Zero, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0);
|
||||
if (result != ERROR_INSUFFICIENT_BUFFER && result != 0)
|
||||
return rows;
|
||||
|
||||
buffer = Marshal.AllocHGlobal(size);
|
||||
result = GetExtendedTcpTable(
|
||||
buffer, ref size, false, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0);
|
||||
if (result != 0)
|
||||
return rows;
|
||||
|
||||
var entryCount = Marshal.ReadInt32(buffer);
|
||||
var rowSize = Marshal.SizeOf<MIB_TCPROW_OWNER_PID>();
|
||||
var cursor = IntPtr.Add(buffer, 4);
|
||||
|
||||
for (var index = 0; index < entryCount; index++)
|
||||
{
|
||||
var row = Marshal.PtrToStructure<MIB_TCPROW_OWNER_PID>(cursor);
|
||||
rows.Add(new TcpRow(
|
||||
FormatAddress(row.localAddress),
|
||||
FormatPort(row.localPort),
|
||||
FormatAddress(row.remoteAddress),
|
||||
FormatPort(row.remotePort),
|
||||
(TcpState)row.state,
|
||||
(int)row.owningProcessId));
|
||||
cursor = IntPtr.Add(cursor, rowSize);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (buffer != IntPtr.Zero)
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static List<TcpRow> GetListeners()
|
||||
=> GetTcpTable().FindAll(row => row.State == TcpState.Listen);
|
||||
|
||||
private static string FormatAddress(uint address)
|
||||
=> new IPAddress(BitConverter.GetBytes(address)).ToString();
|
||||
|
||||
// The port arrives as a big-endian value packed into the low two bytes of a
|
||||
// host-order uint, so the bytes must be swapped rather than simply cast.
|
||||
private static int FormatPort(uint port)
|
||||
{
|
||||
var bytes = BitConverter.GetBytes(port);
|
||||
return (bytes[0] << 8) | bytes[1];
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MIB_TCPROW_OWNER_PID
|
||||
{
|
||||
public uint state;
|
||||
public uint localAddress;
|
||||
public uint localPort;
|
||||
public uint remoteAddress;
|
||||
public uint remotePort;
|
||||
public uint owningProcessId;
|
||||
}
|
||||
|
||||
[DllImport("iphlpapi.dll", SetLastError = true)]
|
||||
private static extern uint GetExtendedTcpTable(
|
||||
IntPtr tcpTable,
|
||||
ref int size,
|
||||
bool order,
|
||||
int addressFamily,
|
||||
int tableClass,
|
||||
int reserved);
|
||||
}
|
||||
@@ -15,6 +15,11 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// cadence tick rather than hammering the server.
|
||||
public sealed class TelemetryWorker : BackgroundService
|
||||
{
|
||||
// The one agent utility whose output is forwarded to the server today. The
|
||||
// name is the .ps1 filename without its extension, which is how
|
||||
// PowerShellUtilityRunner keys its results.
|
||||
public const string DellUpdatesUtilityName = "shopdb-dellupdates";
|
||||
|
||||
private readonly ICollector collector;
|
||||
private readonly CollectorClient client;
|
||||
private readonly IAgentUtilityRunner? utilities;
|
||||
@@ -22,11 +27,31 @@ public sealed class TelemetryWorker : BackgroundService
|
||||
private readonly string agentVersion;
|
||||
private readonly ILogger<TelemetryWorker> logger;
|
||||
private readonly CollectSignal? collectSignal;
|
||||
private readonly UserContextSpool? userContextSpool;
|
||||
private readonly UserContextRefreshChannel? userContextRefresh;
|
||||
private readonly Random jitter = new();
|
||||
|
||||
// How long to wait for the tray to answer a refresh request.
|
||||
//
|
||||
// Bounded, and bounded SHORT. Telemetry is this agent's liveness signal: a
|
||||
// report that arrives with slightly stale mapped drives is a working
|
||||
// endpoint, while a report that never arrives because we blocked on an
|
||||
// absent tray is an endpoint that looks dead. The probe itself is a single
|
||||
// short PowerShell run, so a tray that is alive answers well inside this;
|
||||
// anything longer means nobody is home.
|
||||
private static readonly TimeSpan UserContextRefreshTimeout = TimeSpan.FromSeconds(12);
|
||||
|
||||
// Poll interval while waiting for the stamp to advance. There is no
|
||||
// completion signal coming back the other way - the tray's answer IS the
|
||||
// rewritten spool file - so this is a poll by design, kept coarse enough to
|
||||
// be free and fine enough not to add visible latency.
|
||||
private static readonly TimeSpan UserContextPollInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
public TelemetryWorker(ICollector collector, CollectorClient client,
|
||||
AgentConfig config, string agentVersion, ILogger<TelemetryWorker> logger,
|
||||
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null)
|
||||
IAgentUtilityRunner? utilities = null, CollectSignal? collectSignal = null,
|
||||
UserContextSpool? userContextSpool = null,
|
||||
UserContextRefreshChannel? userContextRefresh = null)
|
||||
{
|
||||
this.collector = collector;
|
||||
this.client = client;
|
||||
@@ -35,6 +60,77 @@ public sealed class TelemetryWorker : BackgroundService
|
||||
this.logger = logger;
|
||||
this.utilities = utilities;
|
||||
this.collectSignal = collectSignal;
|
||||
this.userContextSpool = userContextSpool;
|
||||
this.userContextRefresh = userContextRefresh;
|
||||
}
|
||||
|
||||
// Request a tray-side re-probe and wait, briefly, for the spool to be
|
||||
// rewritten. Never throws and never fails the run.
|
||||
//
|
||||
// The service does NOT collect session data itself and must not start doing
|
||||
// so: mapped drives belong to the operator's logon token, and a LocalSystem
|
||||
// probe reads a different, near-empty drive table. See the tombstone in
|
||||
// Program.cs. This method is the entire service-side involvement - ask, then
|
||||
// wait for the answer to appear on disk.
|
||||
private async Task RefreshUserContextAsync(CancellationToken token)
|
||||
{
|
||||
if (userContextSpool is null || userContextRefresh is null
|
||||
|| !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resultPath = userContextSpool.TryGetResultPath(out _);
|
||||
if (resultPath is null)
|
||||
return;
|
||||
|
||||
// Captured BEFORE the request so "advanced" is measured against what we
|
||||
// would otherwise have reported. A missing spool reads as null, and any
|
||||
// stamp at all then counts as progress - that is the first-probe case.
|
||||
var before = userContextSpool.TryReadCollectedAt(resultPath);
|
||||
|
||||
if (!userContextRefresh.RequestRefresh())
|
||||
{
|
||||
// Could not signal at all. Note that the inverse does NOT mean a tray
|
||||
// is listening: the channel opens-or-creates and does not hold the
|
||||
// handle, so signalling into an empty machine succeeds by creating
|
||||
// the event and finding nobody waiting on it. Detecting an absent
|
||||
// tray is the timeout's job below, not this return value's.
|
||||
logger.LogDebug(
|
||||
"Could not request a user-context refresh; reporting the existing spool.");
|
||||
return;
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow + UserContextRefreshTimeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(UserContextPollInterval, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var after = userContextSpool.TryReadCollectedAt(resultPath);
|
||||
if (after is not null && (before is null || after > before))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"User-context spool refreshed by the tray (collectedat={Stamp}).",
|
||||
after);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Timed out. Proceed anyway - deliberately. Session data is one card on
|
||||
// one page; telemetry carries heartbeat, inventory, events and command
|
||||
// results, and holding all of that hostage to a tray that did not answer
|
||||
// in twelve seconds trades a small inaccuracy for a total outage.
|
||||
logger.LogDebug(
|
||||
"The tray did not refresh the user-context spool within {Timeout}; "
|
||||
+ "reporting with the existing session data.",
|
||||
UserContextRefreshTimeout);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -111,6 +207,15 @@ public sealed class TelemetryWorker : BackgroundService
|
||||
config.Monitors.Processes.Length, config.Monitors.Services.Length);
|
||||
}
|
||||
|
||||
// Ask the tray to re-probe session data and WAIT for it, before
|
||||
// collecting. Ordering is the whole point: the collector reads the spool
|
||||
// file, so a refresh that lands after Collect() is a refresh the server
|
||||
// does not see until the NEXT cycle. That was the bug - "Send Status"
|
||||
// and "Refresh Status" both re-sent a spool nobody had rewritten, so a
|
||||
// drive the operator had just unmapped kept reporting OK for up to half
|
||||
// an hour and no amount of clicking changed it.
|
||||
await RefreshUserContextAsync(token);
|
||||
|
||||
var report = collector.Collect(agentVersion, adopt);
|
||||
|
||||
if (utilities is not null)
|
||||
@@ -118,6 +223,17 @@ public sealed class TelemetryWorker : BackgroundService
|
||||
var extra = utilities.RunAll(token);
|
||||
if (extra.Count > 0)
|
||||
logger.LogDebug("Loaded {Count} agent utility result(s)", extra.Count);
|
||||
|
||||
// NARROW ON PURPOSE. The runner executes every .ps1 in the utilities
|
||||
// directory, but only this one known key is attached to the report.
|
||||
// Forwarding the whole dictionary would mean any script dropped into
|
||||
// that directory starts uploading its output to the server on every
|
||||
// cadence - see the signing caveat in PowerShellUtilityRunner's
|
||||
// header. Widening this to the full map is the intended end state of
|
||||
// the IAgentUtilityRunner seam, but it needs script signing or an
|
||||
// allowlist first. Until then, add a key here per utility.
|
||||
if (extra.TryGetValue(DellUpdatesUtilityName, out var dellUpdates))
|
||||
report.DellUpdates = dellUpdates;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
|
||||
362
agent/ShopDbEndpointAgent.Core/UserContextProbe.cs
Normal file
362
agent/ShopDbEndpointAgent.Core/UserContextProbe.cs
Normal file
@@ -0,0 +1,362 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Collects the session-scoped data that does not exist in the agent's own
|
||||
// context, by running a short PowerShell probe IN THE CALLING PROCESS'S
|
||||
// SESSION and writing the result to the user's spool file.
|
||||
//
|
||||
// Collects only data that genuinely does not exist in the agent's own context:
|
||||
// netuse - mapped drives are per logon session; "net use" from
|
||||
// LocalSystem returns SYSTEM's (empty) mapping table, never the
|
||||
// operator's Z: to the file server.
|
||||
// userlisteners- TCP listeners owned by the signed-in user's processes, which
|
||||
// is what identifies an operator-launched DNC or CMM listener.
|
||||
// idleseconds - GetLastInputInfo is per session, so session 0 cannot answer
|
||||
// it. Sampled at probe time only, so treat it as a coarse
|
||||
// "was anyone at the machine" signal, not live presence.
|
||||
//
|
||||
// This runs from `shopdbagent.exe --collect-usercontext`, which the tray - and
|
||||
// only the tray - invokes, because the tray is already running as the signed-in
|
||||
// operator. The service does NOT call this: it previously tried to descend into
|
||||
// the session with CreateProcessAsUser, which is the direction that has to
|
||||
// duplicate a token, is what endpoint protection watches for, and produced
|
||||
// nothing on a live endpoint. Nothing here crosses a privilege boundary now;
|
||||
// the probe simply runs as whoever the tray is.
|
||||
//
|
||||
// The script is embedded rather than shipped as a .ps1 so the probe cannot drift
|
||||
// from the agent binary or be tampered with on disk between cycles.
|
||||
public sealed class UserContextProbe
|
||||
{
|
||||
// The spool directory is injected by BuildProbeScript rather than derived in
|
||||
// the script, so the writer cannot disagree with the reader. See
|
||||
// UserContextSpool.PinnedSpoolDirectory for why $env:USERPROFILE was wrong.
|
||||
private const string SpoolDirectoryToken = "__SHOPDB_SPOOL_DIR__";
|
||||
|
||||
private const string ProbeScriptTemplate = """
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$result = [ordered]@{}
|
||||
# Injected by the agent from UserContextSpool.PinnedSpoolDirectory() so the
|
||||
# writer and the reader resolve the same directory. Deliberately NOT
|
||||
# $env:USERPROFILE: the tray can inherit another account's environment on a
|
||||
# locked-down image, which silently wrote this file into the wrong profile.
|
||||
$spool = '__SHOPDB_SPOOL_DIR__'
|
||||
$target = Join-Path $spool 'usercontext.json'
|
||||
$temporary = "$target.tmp"
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $spool -Force | Out-Null
|
||||
|
||||
$netuse = @()
|
||||
try {
|
||||
Get-CimInstance -ClassName Win32_NetworkConnection -ErrorAction Stop |
|
||||
ForEach-Object {
|
||||
$netuse += [ordered]@{
|
||||
localname = $_.LocalName
|
||||
remotename = $_.RemoteName
|
||||
status = $_.Status
|
||||
username = $_.UserName
|
||||
persistent = [bool]$_.Persistent
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$result['netuseerror'] = $_.Exception.Message
|
||||
}
|
||||
$result['netuse'] = $netuse
|
||||
|
||||
# Second, independent view of the same drives. Win32_NetworkConnection
|
||||
# is the "net use" table and Get-SmbMapping is the SMB client's own,
|
||||
# and they disagree: a share can be Unavailable in one and absent or
|
||||
# OK in the other. Neither is authoritative, so both are reported as
|
||||
# collected and the server decides what to show. Normalising them
|
||||
# here would discard exactly the disagreement that is diagnostic.
|
||||
$smbmapping = @()
|
||||
try {
|
||||
Get-SmbMapping -ErrorAction Stop |
|
||||
ForEach-Object {
|
||||
$smbmapping += [ordered]@{
|
||||
localpath = $_.LocalPath
|
||||
remotepath = $_.RemotePath
|
||||
status = [string]$_.Status
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Absent on older builds and in some session types. A missing
|
||||
# source is not a drive fault, so it is recorded and left out
|
||||
# rather than reported as a failed mapping.
|
||||
$result['smbmappingerror'] = $_.Exception.Message
|
||||
}
|
||||
$result['smbmapping'] = $smbmapping
|
||||
|
||||
$listeners = @()
|
||||
try {
|
||||
$ownprocesses = @{}
|
||||
Get-Process -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$ownprocesses[$_.Id] = $_.ProcessName
|
||||
}
|
||||
Get-NetTCPConnection -State Listen -ErrorAction Stop |
|
||||
Where-Object { $ownprocesses.ContainsKey($_.OwningProcess) } |
|
||||
ForEach-Object {
|
||||
$listeners += [ordered]@{
|
||||
localaddress = $_.LocalAddress
|
||||
localport = $_.LocalPort
|
||||
processid = $_.OwningProcess
|
||||
processname = $ownprocesses[$_.OwningProcess]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$result['userlistenerserror'] = $_.Exception.Message
|
||||
}
|
||||
$result['userlisteners'] = $listeners
|
||||
|
||||
# Idle time must be measured inside the user's session:
|
||||
# GetLastInputInfo is per session, so asking from the agent's
|
||||
# session 0 would report the service's own idle time, which is
|
||||
# meaningless. This is what distinguishes "powered on" from
|
||||
# "someone is actually working at this machine".
|
||||
try {
|
||||
# Built as a single-quoted string rather than a here-string: a
|
||||
# here-string terminator must sit at column 0, which cannot be
|
||||
# expressed inside an indented C# raw string literal.
|
||||
$signature = 'using System; using System.Runtime.InteropServices; ' +
|
||||
'public class ShopDbIdle { ' +
|
||||
'[StructLayout(LayoutKind.Sequential)] ' +
|
||||
'struct LASTINPUTINFO { public uint cbSize; public uint dwTime; } ' +
|
||||
'[DllImport("user32.dll")] ' +
|
||||
'static extern bool GetLastInputInfo(ref LASTINPUTINFO info); ' +
|
||||
'public static long IdleSeconds() { ' +
|
||||
'LASTINPUTINFO info = new LASTINPUTINFO(); ' +
|
||||
'info.cbSize = (uint)Marshal.SizeOf(info); ' +
|
||||
'if (!GetLastInputInfo(ref info)) return -1; ' +
|
||||
'return (long)((uint)Environment.TickCount - info.dwTime) / 1000; } }'
|
||||
Add-Type -TypeDefinition $signature -ErrorAction Stop
|
||||
$idle = [ShopDbIdle]::IdleSeconds()
|
||||
if ($idle -ge 0) { $result['idleseconds'] = $idle }
|
||||
} catch {
|
||||
$result['idlesecondserror'] = $_.Exception.Message
|
||||
}
|
||||
|
||||
$result['collectedat'] = (Get-Date).ToUniversalTime().ToString('o')
|
||||
$result['username'] = $env:USERNAME
|
||||
|
||||
# Stamp the token's elevation state. The username alone does not
|
||||
# settle it: the SAME account elevated and unelevated gets two
|
||||
# different logon tokens and therefore two different drive tables,
|
||||
# so a spool from an elevated ShopFloor session passes the account
|
||||
# check while still reporting every drive as broken.
|
||||
try {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
$result['elevated'] = [bool] $principal.IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
} catch {
|
||||
$result['elevatederror'] = $_.Exception.Message
|
||||
}
|
||||
|
||||
# Write to a temporary name and rename into place, so the reader can
|
||||
# never observe a half-written file and discard a good result.
|
||||
$result | ConvertTo-Json -Depth 4 -Compress |
|
||||
Set-Content -Path $temporary -Encoding UTF8 -NoNewline
|
||||
Move-Item -Path $temporary -Destination $target -Force
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
""";
|
||||
|
||||
// Substitute the resolved spool directory into the template. The path goes in
|
||||
// as a PowerShell single-quoted literal, so an embedded quote is doubled and
|
||||
// nothing else in the path is interpreted - backslashes and $ are literal
|
||||
// inside single quotes, which is exactly what a Windows path needs.
|
||||
private static string BuildProbeScript(string spoolDirectory)
|
||||
=> ProbeScriptTemplate.Replace(
|
||||
SpoolDirectoryToken, spoolDirectory.Replace("'", "''"));
|
||||
|
||||
// How long the probe is allowed to take. Generous: this no longer runs on
|
||||
// the telemetry path, so an unresponsive WMI call delays nothing but itself.
|
||||
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
// powershell.exe's exit code when it rejects its command line and prints
|
||||
// usage instead of running anything. Named so the failure is recognisable.
|
||||
private const int PowerShellStartupFailure = -196608;
|
||||
|
||||
private readonly ILogger<UserContextProbe> logger;
|
||||
|
||||
public UserContextProbe(ILogger<UserContextProbe> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
// Run the probe in THIS process's session and write the spool file.
|
||||
// Returns true only when the spool file is present on disk afterwards.
|
||||
//
|
||||
// Called from the --collect-usercontext entry point, which the tray runs as
|
||||
// the signed-in operator. Never throws: this is background collection and a
|
||||
// failure must not take the tray or the agent process down with it.
|
||||
public bool Collect()
|
||||
{
|
||||
var spoolDirectory = UserContextSpool.PinnedSpoolDirectory();
|
||||
var resultPath = UserContextSpool.PinnedResultPath();
|
||||
|
||||
try
|
||||
{
|
||||
// Logged before the launch, not after. When this goes wrong the
|
||||
// interesting question is always "which directory did it target",
|
||||
// and a failure later in this method must not take the answer with
|
||||
// it.
|
||||
logger.LogInformation(
|
||||
"Running the user-context probe as {User}; spool target {Path}",
|
||||
Environment.UserName, resultPath);
|
||||
|
||||
// The probe writes a .tmp beside the target and moves it into place,
|
||||
// so the directory has to exist before powershell.exe starts. The
|
||||
// script creates it too, but doing it here means a permission problem
|
||||
// is reported by the agent with a real exception rather than showing
|
||||
// up as an opaque non-zero exit code.
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(spoolDirectory);
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogWarning(error,
|
||||
"Could not create the user-context spool directory at {Directory}",
|
||||
spoolDirectory);
|
||||
return false;
|
||||
}
|
||||
// Passed as -EncodedCommand rather than staged as a .ps1 and run with
|
||||
// -File. The script used to be written into %TEMP% and executed from
|
||||
// there, which put a dependency on that file still existing, and still
|
||||
// being readable, at the moment powershell.exe started. On a hardened
|
||||
// endpoint that window is not safe: endpoint protection quarantines a
|
||||
// freshly written script in the temp directory between the write and
|
||||
// the launch, and roaming/redirected profiles can make the path
|
||||
// unusable outright.
|
||||
//
|
||||
// When powershell.exe cannot use its -File argument it prints its usage
|
||||
// banner and exits -196608 (0xFFFD0000) WITHOUT running a single
|
||||
// statement. That is a startup failure, not a script failure, so the
|
||||
// script's own try/catch never runs, nothing is logged about drives,
|
||||
// and the operator sees a silent "no mapped drives reported" while the
|
||||
// log only shows the bare exit code. -EncodedCommand carries the script
|
||||
// in the command line itself, so there is no file to lose and no
|
||||
// execution-policy or quarantine step between us and the probe.
|
||||
var encoded = Convert.ToBase64String(
|
||||
System.Text.Encoding.Unicode.GetBytes(BuildProbeScript(spoolDirectory)));
|
||||
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments =
|
||||
"-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "
|
||||
+ encoded,
|
||||
WorkingDirectory = Path.GetTempPath(),
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
logger.LogWarning("The user-context probe could not be started.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!process.WaitForExit((int)ProbeTimeout.TotalMilliseconds))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context probe did not finish within {Seconds} seconds.",
|
||||
ProbeTimeout.TotalSeconds);
|
||||
TryKill(process);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
// 0xFFFD0000 is powershell.exe refusing its own command line and
|
||||
// printing usage: the probe never ran. Called out by name because
|
||||
// as a bare number it reads like a script error and sends the
|
||||
// reader looking for a WMI or drive fault that is not there.
|
||||
var detail = process.ExitCode == PowerShellStartupFailure
|
||||
? " (powershell.exe rejected its command line; the probe did not run)"
|
||||
: string.Empty;
|
||||
logger.LogWarning(
|
||||
"The user-context probe exited with code {ExitCode}.{Detail}",
|
||||
process.ExitCode, detail);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exit code 0 is necessary but NOT sufficient. The probe used to be
|
||||
// trusted on its exit code alone, which meant a run that wrote its
|
||||
// file into a different profile entirely still reported success - the
|
||||
// log claimed a spool had been written for weeks while nothing was
|
||||
// ever there. Confirm the file exists where the reader will look.
|
||||
if (!File.Exists(resultPath))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context probe reported success but no spool file is "
|
||||
+ "present at {Path}.", resultPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Byte count alone does not distinguish a healthy spool from a
|
||||
// well-formed empty one, and "0 drives" is the exact symptom this
|
||||
// whole path exists to diagnose. Summarising the contents means a
|
||||
// single log line from any given task run answers whether the probe
|
||||
// saw the operator's drives, without needing the spool itself.
|
||||
var summary = SummariseSpool(resultPath);
|
||||
logger.LogInformation(
|
||||
"The user-context probe wrote {Bytes} bytes to {Path} as {User}: {Summary}",
|
||||
new FileInfo(resultPath).Length, resultPath, Environment.UserName, summary);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "The user-context probe failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// One-line description of what the probe actually captured, for the run log.
|
||||
// Best-effort by design: this is diagnostic colour on an already-successful
|
||||
// run, so a parse problem here must never turn that run into a failure.
|
||||
private static string SummariseSpool(string resultPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = System.Text.Json.JsonDocument.Parse(
|
||||
File.ReadAllText(resultPath));
|
||||
var root = document.RootElement;
|
||||
|
||||
static int CountOf(System.Text.Json.JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var value)
|
||||
&& value.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||
? value.GetArrayLength()
|
||||
: 0;
|
||||
|
||||
var user = root.TryGetProperty("username", out var stamped)
|
||||
? stamped.GetString()
|
||||
: "(unstamped)";
|
||||
|
||||
return $"user={user} netuse={CountOf(root, "netuse")} "
|
||||
+ $"smbmapping={CountOf(root, "smbmapping")} "
|
||||
+ $"listeners={CountOf(root, "userlisteners")}";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "(could not summarise)";
|
||||
}
|
||||
}
|
||||
|
||||
private void TryKill(System.Diagnostics.Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Could not stop the timed-out user-context probe.");
|
||||
}
|
||||
}
|
||||
}
|
||||
179
agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs
Normal file
179
agent/ShopDbEndpointAgent.Core/UserContextRefreshChannel.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// The service-to-tray request channel for an on-demand user-context refresh.
|
||||
//
|
||||
// WHY THIS EXISTS AT ALL. Mapped drives are a property of the operator's logon
|
||||
// token, not of the machine. The agent is a session-0 LocalSystem service, so it
|
||||
// CANNOT enumerate them - not by impersonation, not by CreateProcessAsUser, not
|
||||
// by starting the probe task itself. That approach was built once and reverted;
|
||||
// see the tombstone in Program.cs where UserContextProbe is deliberately kept
|
||||
// out of the service's DI container. The only process on the box that can see
|
||||
// the right drive table is one already running inside the operator's session:
|
||||
// the tray.
|
||||
//
|
||||
// So the service does not probe. It ASKS. This channel is the ask, and the
|
||||
// spool file remains the answer.
|
||||
//
|
||||
// WHY A NAMED EVENT rather than a pipe or a socket. The request carries no data
|
||||
// - it is a single bit, "please re-probe now" - and the reply already has a
|
||||
// transport (the spool file, whose collectedat stamp the service watches
|
||||
// advance). A kernel event is the smallest thing that crosses the session-0
|
||||
// boundary, needs no serialization, no port, no firewall rule, and no cleanup if
|
||||
// either side dies. A file-watcher trigger would also work but leaves litter on
|
||||
// disk when the service is killed mid-request.
|
||||
//
|
||||
// THE Global\ PREFIX IS LOAD-BEARING. Without it the name is scoped to the
|
||||
// caller's session, so the service (session 0) and the tray (session 1+) would
|
||||
// create two unrelated events with the same name and never see each other. That
|
||||
// failure is silent: both sides "work", nothing is ever refreshed.
|
||||
//
|
||||
// THE DACL IS ALSO LOAD-BEARING. The service creates the event as LocalSystem,
|
||||
// which by default produces an object a non-elevated user cannot even open. The
|
||||
// tray runs unelevated as ShopFloor by design (an ELEVATED tray is refused by
|
||||
// the probe, because it would read the wrong drive table). So the creator grants
|
||||
// Users explicit Synchronize + Modify rights, or the tray gets access-denied on
|
||||
// open and the refresh silently never happens.
|
||||
public sealed class UserContextRefreshChannel
|
||||
{
|
||||
// Session-global so session 0 and the interactive session share one object.
|
||||
public const string EventName = @"Global\ShopDbEndpointAgent.UserContextRefresh";
|
||||
|
||||
private readonly ILogger logger;
|
||||
|
||||
// Takes the base ILogger rather than ILogger<T>: both the service (generic
|
||||
// host) and the tray (its own file-backed logger named "Tray") construct
|
||||
// this, and the tray has no DI container to resolve a typed logger from.
|
||||
public UserContextRefreshChannel(ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
// Signal the tray that a fresh probe is wanted.
|
||||
//
|
||||
// Returns false when the event could not be opened or created, which is the
|
||||
// normal state on a machine with nobody signed in and no tray running. The
|
||||
// caller treats that as "no refresh available" and reports what it already
|
||||
// has - a stale mapped-drive list is worth strictly more than a missed
|
||||
// heartbeat, because telemetry IS the liveness signal.
|
||||
//
|
||||
// AutoReset, so exactly one waiting tray is released per request and the
|
||||
// event does not latch on for every subsequent listener iteration.
|
||||
[SupportedOSPlatform("windows")]
|
||||
public bool RequestRefresh()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var handle = OpenOrCreate(out var created);
|
||||
if (handle is null)
|
||||
return false;
|
||||
|
||||
handle.Set();
|
||||
logger.LogDebug(
|
||||
"Requested a user-context refresh on {Event} (created={Created})",
|
||||
EventName, created);
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Never throws to the caller. This sits directly on the telemetry
|
||||
// path, and a refresh is an enhancement to the report, not a
|
||||
// precondition for sending it.
|
||||
logger.LogDebug(error,
|
||||
"Could not request a user-context refresh on {Event}", EventName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Tray side: block until the service asks for a refresh, the timeout expires,
|
||||
// or the token is cancelled. Returns true only for an actual request.
|
||||
[SupportedOSPlatform("windows")]
|
||||
public bool WaitForRequest(TimeSpan timeout, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var handle = OpenOrCreate(out _);
|
||||
if (handle is null)
|
||||
return false;
|
||||
|
||||
var index = WaitHandle.WaitAny(
|
||||
new[] { handle, token.WaitHandle }, timeout);
|
||||
|
||||
// Index 1 is cancellation, WaitTimeout is the idle path; only index 0
|
||||
// is a genuine request.
|
||||
return index == 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error,
|
||||
"Could not wait on the user-context refresh channel {Event}", EventName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the shared event, creating it if this process is first.
|
||||
//
|
||||
// Either side may start first - the service can boot before anyone signs in,
|
||||
// and the tray can start before the service finishes initializing - so both
|
||||
// sides use the same create-or-open call rather than designating an owner.
|
||||
// Whoever wins applies the DACL; the loser opens the existing object.
|
||||
[SupportedOSPlatform("windows")]
|
||||
private EventWaitHandle? OpenOrCreate(out bool created)
|
||||
{
|
||||
created = false;
|
||||
try
|
||||
{
|
||||
var security = new EventWaitHandleSecurity();
|
||||
|
||||
// BUILTIN\Users by SID, not by name: on a non-English Windows the
|
||||
// group is not called "Users", and this agent ships to whatever
|
||||
// locale the site runs. Same reasoning as the task registration.
|
||||
var users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
security.AddAccessRule(new EventWaitHandleAccessRule(
|
||||
users,
|
||||
EventWaitHandleRights.Synchronize | EventWaitHandleRights.Modify,
|
||||
AccessControlType.Allow));
|
||||
|
||||
// FullControl for SYSTEM and Administrators so the service retains
|
||||
// ownership semantics regardless of which side created the object.
|
||||
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
security.AddAccessRule(new EventWaitHandleAccessRule(
|
||||
system, EventWaitHandleRights.FullControl, AccessControlType.Allow));
|
||||
|
||||
var admins = new SecurityIdentifier(
|
||||
WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
security.AddAccessRule(new EventWaitHandleAccessRule(
|
||||
admins, EventWaitHandleRights.FullControl, AccessControlType.Allow));
|
||||
|
||||
return EventWaitHandleAcl.Create(
|
||||
initialState: false,
|
||||
mode: EventResetMode.AutoReset,
|
||||
name: EventName,
|
||||
createdNew: out created,
|
||||
eventSecurity: security);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// The object exists with a DACL that does not admit this caller.
|
||||
// Retry read-side-only rights: the tray only ever needs to wait, and
|
||||
// an event created by an older agent build may not grant Modify.
|
||||
try
|
||||
{
|
||||
return EventWaitHandle.OpenExisting(EventName);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error,
|
||||
"The user-context refresh event {Event} exists but is not accessible",
|
||||
EventName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
389
agent/ShopDbEndpointAgent.Core/UserContextSpool.cs
Normal file
389
agent/ShopDbEndpointAgent.Core/UserContextSpool.cs
Normal file
@@ -0,0 +1,389 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Reads the result file that the tray-run user-context probe writes into the
|
||||
// signed-in user's ShopDB spool folder.
|
||||
//
|
||||
// Why a file and not a return value: the agent is a session-0 LocalSystem
|
||||
// service, so anything session-scoped (mapped drives from "net use", per-user
|
||||
// network state) simply does not exist in its own context. The probe runs as the
|
||||
// operator under the tray, in a completely separate process with no pipe back to
|
||||
// the service. A file in the user's own profile is the handoff, and the user can
|
||||
// always write there without elevation.
|
||||
//
|
||||
// Freshness is judged from the 'collectedat' stamp inside the file, NOT by
|
||||
// deleting the file around each read. Writer and reader are now independent
|
||||
// processes on unrelated schedules, so a delete-then-read would race the tray
|
||||
// and usually destroy a perfectly good result before it was ever reported. The
|
||||
// file is therefore left alone and simply ignored once it ages out, which also
|
||||
// means a logged-off machine stops reporting session data instead of pinning the
|
||||
// last operator's mapped drives forever.
|
||||
public sealed class UserContextSpool
|
||||
{
|
||||
public const string SpoolFolderName = "ShopDB";
|
||||
public const string ResultFileName = "usercontext.json";
|
||||
|
||||
// The one definition of where the spool lives. Both the probe (writer) and
|
||||
// this class (reader) call it, so the two can no longer disagree.
|
||||
//
|
||||
// The writer used to build its own path from $env:USERPROFILE while the
|
||||
// reader used the pinned ShopFloor profile. On a locked-down image those are
|
||||
// not the same directory - the tray is launched through explorer.exe by an
|
||||
// elevated installer and can inherit another account's environment block, as
|
||||
// the tray already documents for %TEMP%. The probe then wrote a perfectly
|
||||
// good file into the wrong profile, exited 0, and the service found nothing:
|
||||
// no spool ever appeared and no mapped drive was ever reported.
|
||||
public static string PinnedSpoolDirectory()
|
||||
=> Path.Combine(FileLoggerProvider.ShopFloorProfileDirectory(), SpoolFolderName);
|
||||
|
||||
public static string PinnedResultPath()
|
||||
=> Path.Combine(PinnedSpoolDirectory(), ResultFileName);
|
||||
|
||||
// A probe result is small - a handful of mapped drives and listeners. A file
|
||||
// larger than this is either corrupt or hostile, and we will not parse it.
|
||||
private const long MaxResultBytes = 512 * 1024;
|
||||
|
||||
// A result older than this is treated as absent. Sized at roughly three tray
|
||||
// probe intervals (30 min each), so a single missed run never blanks the card
|
||||
// but genuinely stale session data retires quickly.
|
||||
//
|
||||
// This was 24h when the reader deleted the file after each read; the delete
|
||||
// was doing the real retiring and the age check was only a backstop. Now that
|
||||
// the spool persists, this value IS the staleness bound, so it is sized
|
||||
// against the writer's cadence rather than the server's quiet-report window.
|
||||
//
|
||||
// Not read from the server: this is an offline-safe local bound, and the
|
||||
// agent must retire stale session data even when it cannot reach ShopDB.
|
||||
private static readonly TimeSpan MaxResultAge = TimeSpan.FromMinutes(90);
|
||||
|
||||
private readonly ILogger<UserContextSpool> logger;
|
||||
|
||||
public UserContextSpool(ILogger<UserContextSpool> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
// Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because
|
||||
// every shop-floor endpoint signs in as the same local ShopFloor account.
|
||||
//
|
||||
// This used to be resolved through WTSQueryUserToken/GetUserProfileDirectory,
|
||||
// and that lookup is precisely what fails on a hardened endpoint: querying
|
||||
// another session's token is a privileged operation that endpoint protection
|
||||
// blocks. When it failed the service simply reported "no session data" while
|
||||
// the tray was writing a perfectly good file, so writer and reader silently
|
||||
// disagreed about the location and the operator saw an empty card.
|
||||
//
|
||||
// The token lookup is kept ONLY as a fallback for a machine whose operator
|
||||
// account is named something else. If both are unavailable there is no
|
||||
// session data, which costs mapped drives and idle time but never affects
|
||||
// telemetry, commands or inventory.
|
||||
public string? TryGetResultPath(out string? reason)
|
||||
{
|
||||
var pinned = FileLoggerProvider.ShopFloorProfileDirectory();
|
||||
if (Directory.Exists(pinned))
|
||||
{
|
||||
reason = null;
|
||||
return PinnedResultPath();
|
||||
}
|
||||
|
||||
var profileDirectory =
|
||||
InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error);
|
||||
if (string.IsNullOrWhiteSpace(profileDirectory))
|
||||
{
|
||||
reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, "
|
||||
+ "and the interactive profile could not be resolved either: "
|
||||
+ (error ?? "unknown error");
|
||||
return null;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return Path.Combine(profileDirectory, SpoolFolderName, ResultFileName);
|
||||
}
|
||||
|
||||
// Remove a result file. Only used to discard one that is corrupt or has aged
|
||||
// out; a valid result is left in place for the next read.
|
||||
public void ClearStaleResult(string resultPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(resultPath))
|
||||
{
|
||||
File.Delete(resultPath);
|
||||
logger.LogDebug(
|
||||
"Removed a stale user-context spool file at {Path}", resultPath);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Not fatal on its own, but it does mean we cannot trust whatever we
|
||||
// read next, so the caller must know.
|
||||
logger.LogWarning(exception,
|
||||
"Could not remove the stale user-context spool file at {Path}", resultPath);
|
||||
}
|
||||
}
|
||||
|
||||
public void EnsureSpoolDirectory(string resultPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(resultPath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception,
|
||||
"Could not create the user-context spool directory at {Directory}", directory);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the probe result without consuming it. Returns null when there is no
|
||||
// usable, sufficiently recent result, which the caller reports as "no session
|
||||
// data this cycle" rather than as empty data - absent and empty mean
|
||||
// different things here.
|
||||
public Dictionary<string, object?>? TryReadResult(string resultPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(resultPath))
|
||||
return null;
|
||||
|
||||
var info = new FileInfo(resultPath);
|
||||
if (info.Length == 0)
|
||||
{
|
||||
logger.LogDebug("The user-context spool file was empty.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (info.Length > MaxResultBytes)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context spool file was {Bytes} bytes, above the {Limit} byte "
|
||||
+ "limit; discarding it.",
|
||||
info.Length, MaxResultBytes);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(resultPath);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
|
||||
if (parsed is null || parsed.Count == 0)
|
||||
return null;
|
||||
|
||||
if (IsExpired(parsed, resultPath))
|
||||
return null;
|
||||
|
||||
if (IsWrongAccount(parsed))
|
||||
return null;
|
||||
|
||||
if (IsElevatedCollection(parsed))
|
||||
return null;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
// A truncated file is the expected shape of this failure: the probe
|
||||
// was still writing when we looked. Next cycle gets a clean one.
|
||||
logger.LogWarning(exception,
|
||||
"The user-context spool file was not valid JSON; discarding it.");
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception,
|
||||
"Could not read the user-context spool file at {Path}", resultPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Read the spool WITHOUT removing it. The probe overwrites the file on every
|
||||
// run (write .tmp, move into place), so the newest result always replaces the
|
||||
// previous one and there is nothing for the reader to clean up.
|
||||
//
|
||||
// This used to delete the file after a successful read, to stop a dead tray
|
||||
// re-reporting the same drives forever. The collectedat age check already
|
||||
// does that job, and deleting created a worse problem: lastUserContext in the
|
||||
// collector is in-memory only, so a service restart wiped the cache while the
|
||||
// spool had already been consumed. The agent then reported NO mapped drives
|
||||
// until the tray's next probe - and the installer restarts the service and
|
||||
// the tray on every install, so a fresh install was guaranteed to show an
|
||||
// empty card at exactly the moment someone looked at it.
|
||||
//
|
||||
// The contents are the operator's own session data, visible to them in their
|
||||
// own context, so leaving the file in place costs nothing.
|
||||
public Dictionary<string, object?>? ConsumeResult(string resultPath)
|
||||
=> TryReadResult(resultPath);
|
||||
|
||||
// The collectedat stamp on disk, or null when there is no readable stamp.
|
||||
//
|
||||
// This is how the service PROVES a refresh actually happened rather than
|
||||
// assuming it. Signalling the tray is asynchronous and the probe takes a
|
||||
// second or two, so a caller that simply slept would either send the old
|
||||
// spool anyway or pad the delay and hope. Watching this value advance past
|
||||
// the pre-request stamp is the only honest way to know the file in front of
|
||||
// us is the answer to OUR request and not the previous one.
|
||||
//
|
||||
// Deliberately NOT routed through TryReadResult: this must report the stamp
|
||||
// of whatever is on disk, including a spool that is expired, elevated, or
|
||||
// from the wrong account. Those are all rejected later for reporting, but
|
||||
// for ordering we only care whether the file was rewritten - conflating "not
|
||||
// usable" with "not fresh" would make the service wait out the full timeout
|
||||
// on every cycle for a machine whose spool is being rejected for some other
|
||||
// reason.
|
||||
//
|
||||
// Also deliberately NOT the file's LastWriteTime, for the reason IsExpired
|
||||
// gives: a spool can be copied or restored with a roaming profile and carry
|
||||
// a misleading filesystem timestamp. The stamp inside the file is written by
|
||||
// the probe itself.
|
||||
public DateTimeOffset? TryReadCollectedAt(string resultPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(resultPath))
|
||||
return null;
|
||||
|
||||
var info = new FileInfo(resultPath);
|
||||
if (info.Length == 0 || info.Length > MaxResultBytes)
|
||||
return null;
|
||||
|
||||
var json = File.ReadAllText(resultPath);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
|
||||
if (parsed is null
|
||||
|| !parsed.TryGetValue("collectedat", out var raw)
|
||||
|| raw is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
raw.ToString(),
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind,
|
||||
out var collectedat))
|
||||
{
|
||||
return collectedat;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A torn read while the probe is mid-write is the expected failure
|
||||
// here and is not worth a warning: the caller polls, so the next
|
||||
// attempt a moment later sees the completed file.
|
||||
logger.LogDebug(exception,
|
||||
"Could not read the user-context collectedat stamp at {Path}", resultPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject a spool that some other account produced.
|
||||
//
|
||||
// Pinning the path guarantees the file lands where the reader looks, but it
|
||||
// cannot guarantee WHO ran the probe. Mapped drives and idle time are per
|
||||
// logon session, so a probe that ran as an administrator (for example a tray
|
||||
// that inherited an elevated installer's environment) would enumerate that
|
||||
// account's drive table and write it into the operator's spool. That is worse
|
||||
// than reporting nothing, because the card would look populated and correct
|
||||
// while showing drives the operator does not have.
|
||||
//
|
||||
// The probe stamps $env:USERNAME into the file, so the mismatch is checkable.
|
||||
// Logged at warning, not debug: this is a misconfigured endpoint, not a quiet
|
||||
// absence of data.
|
||||
private bool IsWrongAccount(Dictionary<string, object?> parsed)
|
||||
{
|
||||
if (!parsed.TryGetValue("username", out var raw) || raw is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context spool has no username stamp; ignoring it because "
|
||||
+ "the session it describes cannot be confirmed.");
|
||||
return true;
|
||||
}
|
||||
|
||||
var username = raw.ToString();
|
||||
if (string.Equals(username, FileLoggerProvider.ShopFloorUserName,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"The user-context spool was written by {Actual}, not {Expected}; "
|
||||
+ "ignoring it. Session data is per logon session, so this would "
|
||||
+ "report the wrong account's mapped drives.",
|
||||
username, FileLoggerProvider.ShopFloorUserName);
|
||||
return true;
|
||||
}
|
||||
|
||||
// An elevated probe runs under a different logon token to the operator's
|
||||
// interactive one and enumerates a different, usually empty, drive table.
|
||||
// The agent refuses to collect while elevated, so a spool stamped elevated
|
||||
// should not exist; this rejects any that predates that guard or arrives
|
||||
// from a hand-run --collect-usercontext in an admin shell.
|
||||
//
|
||||
// Note the asymmetry with IsWrongAccount: a MISSING stamp is accepted here
|
||||
// rather than rejected. An unstamped spool is simply one written by an older
|
||||
// agent, and treating that as elevated would blank out session data across a
|
||||
// fleet mid-upgrade. An unstamped spool has already had to pass the username
|
||||
// check to reach this point.
|
||||
private bool IsElevatedCollection(Dictionary<string, object?> parsed)
|
||||
{
|
||||
if (!parsed.TryGetValue("elevated", out var raw) || raw is null)
|
||||
return false;
|
||||
|
||||
var elevated = raw is JsonElement element
|
||||
? element.ValueKind == JsonValueKind.True
|
||||
: string.Equals(raw.ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!elevated)
|
||||
return false;
|
||||
|
||||
logger.LogWarning(
|
||||
"The user-context spool was collected by an ELEVATED process; ignoring "
|
||||
+ "it. Mapped drives are per logon token, so an elevated probe reports "
|
||||
+ "the wrong drive table even under the right account. Collection should "
|
||||
+ "come from the 'ShopDB user-context probe' scheduled task, which runs "
|
||||
+ "at RunLevel Limited.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Age is taken from the stamp the probe wrote, not from the file's last
|
||||
// write time, because the file can be copied or restored with a roaming
|
||||
// profile and carry a misleading timestamp. A result with no readable stamp
|
||||
// is treated as expired: we cannot prove it is current, so we will not claim
|
||||
// it is.
|
||||
private bool IsExpired(Dictionary<string, object?> parsed, string resultPath)
|
||||
{
|
||||
if (!parsed.TryGetValue("collectedat", out var raw) || raw is null
|
||||
|| !DateTimeOffset.TryParse(
|
||||
raw.ToString(),
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind,
|
||||
out var collectedat))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"The user-context spool file has no usable collectedat stamp; ignoring it.");
|
||||
return true;
|
||||
}
|
||||
|
||||
var age = DateTimeOffset.UtcNow - collectedat;
|
||||
if (age <= MaxResultAge)
|
||||
return false;
|
||||
|
||||
logger.LogDebug(
|
||||
"The user-context spool file is {Hours} hours old, past the {Limit} hour limit.",
|
||||
Math.Round(age.TotalHours, 1), MaxResultAge.TotalHours);
|
||||
ClearStaleResult(resultPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,57 @@ else
|
||||
var shortRoot = Path.Combine(@"C:\PROGRA~1", "LppFixture", "good.exe");
|
||||
Check("8.3 short name resolving into the approved root", true, shortRoot, policy);
|
||||
|
||||
// --- ScheduledTaskPolicy ----------------------------------------------------
|
||||
// Pure string logic, unlike LaunchPathPolicy: a task path names an entry in the
|
||||
// Task Scheduler namespace, not a file, so there is no disk state to plant. The
|
||||
// cases that matter are the ones where a candidate LOOKS like an approved entry
|
||||
// as a string but is not the same task.
|
||||
|
||||
void CheckTask(string label, bool expectAccept, string candidate, ScheduledTaskPolicy policy)
|
||||
{
|
||||
var ok = policy.TryValidate(candidate, out var full, out var reason);
|
||||
var pass = ok == expectAccept;
|
||||
if (!pass) failures++;
|
||||
var verdict = pass ? "PASS" : "FAIL";
|
||||
var got = ok ? $"accepted -> {full}" : $"rejected ({reason})";
|
||||
Console.WriteLine($"{verdict} {label}");
|
||||
Console.WriteLine($" want={(expectAccept ? "accept" : "reject")} got={got}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("--- ScheduledTaskPolicy ---");
|
||||
|
||||
var taskPolicy = new ScheduledTaskPolicy(new[] { @"\ReportIP", @"\SFLD\" });
|
||||
|
||||
CheckTask("exact approved task", true, @"\ReportIP", taskPolicy);
|
||||
// Task Scheduler is case-insensitive, so the policy must be too, or the same
|
||||
// task typed differently would be refused.
|
||||
CheckTask("approved task, different case", true, @"\reportip", taskPolicy);
|
||||
// The operator should not have to know the rooted form.
|
||||
CheckTask("approved task without leading separator", true, "ReportIP", taskPolicy);
|
||||
CheckTask("approved task with forward slashes", true, "/ReportIP", taskPolicy);
|
||||
// The prefix-suffix confusion this policy exists to prevent: "\ReportIP" must
|
||||
// NOT authorize "\ReportIPEvil".
|
||||
CheckTask("sibling task sharing an approved prefix", false, @"\ReportIPEvil", taskPolicy);
|
||||
CheckTask("unapproved task", false, @"\Evil", taskPolicy);
|
||||
CheckTask("empty task path", false, "", taskPolicy);
|
||||
// Folder entries authorize everything beneath them, but not a sibling folder
|
||||
// whose name merely starts the same way.
|
||||
CheckTask("task inside an approved folder", true, @"\SFLD\ApplyDSCConfig", taskPolicy);
|
||||
CheckTask("folder itself", true, @"\SFLD", taskPolicy);
|
||||
CheckTask("sibling folder sharing the prefix", false, @"\SFLDEvil\Thing", taskPolicy);
|
||||
// Traversal would let an approved prefix string-match something else entirely.
|
||||
CheckTask("traversal out of an approved folder", false, @"\SFLD\..\Evil", taskPolicy);
|
||||
// Defense in depth: the invocation uses ArgumentList, but a task path with
|
||||
// these characters is not one we should be starting.
|
||||
CheckTask("argument-injection characters", false, "\\ReportIP\" & calc", taskPolicy);
|
||||
CheckTask("control character", false, "\\Report\u0000IP", taskPolicy);
|
||||
// An empty override must fall back to the shipped defaults, never to "allow
|
||||
// everything".
|
||||
var defaultPolicy = new ScheduledTaskPolicy(Array.Empty<string>());
|
||||
CheckTask("shipped default is honored when override is empty", true, @"\ReportIP", defaultPolicy);
|
||||
CheckTask("empty override does not mean allow-all", false, @"\Evil", defaultPolicy);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(failures == 0 ? "ALL EXPECTATIONS MET" : $"{failures} EXPECTATION(S) NOT MET");
|
||||
return failures == 0 ? 0 : 1;
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../ShopDbEndpointAgent.Core/LaunchPathPolicy.cs" Link="LaunchPathPolicy.cs" />
|
||||
<!-- ScheduledTaskPolicy is pure string logic with no filesystem state to
|
||||
plant, so unlike LaunchPathPolicy it would run anywhere. It is linked
|
||||
here anyway to keep both allowlist policies provable from one harness. -->
|
||||
<Compile Include="../ShopDbEndpointAgent.Core/ScheduledTaskPolicy.cs" Link="ScheduledTaskPolicy.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,11 +8,70 @@
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<RootNamespace>ShopDbEndpointAgent.Tray</RootNamespace>
|
||||
<AssemblyName>shopdb-endpoint-tray</AssemblyName>
|
||||
<!-- 1.1.0: the tray now collects session-scoped user context. It invokes
|
||||
shopdbagent.exe with the collect-usercontext verb on a timer (once
|
||||
shortly after logon, then periodically) and the agent service reads the
|
||||
resulting spool file. The tray owns this because it is the only ShopDB
|
||||
process already running as the operator: mapped drives, user-owned
|
||||
listeners, and input idle time do not exist in the service's session 0.
|
||||
Requires agent 1.8.0 or newer: an older agent does not recognize the
|
||||
verb and falls through to starting a full host, so the tray and agent
|
||||
must be deployed together. -->
|
||||
<!-- 1.0.0: first explicitly stamped tray version. The tray and the agent
|
||||
service version independently (they are separate exes and can be pushed
|
||||
apart), so the tray carries its own <Version> and the agent reports it
|
||||
as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). -->
|
||||
<Version>1.0.0</Version>
|
||||
<!-- 1.1.1: the tray now logs. Every user-context failure path was silent -
|
||||
a missing shopdbagent.exe returned bare, and the probe launch was
|
||||
wrapped in an empty catch - so "no mapped drives" produced no evidence
|
||||
anywhere on the machine. The tray writes
|
||||
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log (the operator's
|
||||
own profile; it cannot write to ProgramData or Program Files) covering
|
||||
tray startup, agent-exe resolution, probe launch and probe exit code. -->
|
||||
<!-- 1.2.0: the tray stops claiming success it did not verify. The probe
|
||||
completion message printed a hardcoded spool path regardless of what
|
||||
happened, so a probe that exited 0 without writing anything read as a
|
||||
clean collection; it now reports the real pinned path and distinguishes
|
||||
spool-present, exit-0-but-no-file, and non-zero exit. Startup probe
|
||||
delay is chosen from session uptime: the 2-minute delay still applies at
|
||||
logon (drive mappings are not established yet), but a tray started into
|
||||
an already-running session - which is what an installer does - probes
|
||||
after 15 seconds instead of leaving a fresh install blind for the full
|
||||
30-minute cycle. A log sink that cannot open its directory raises a
|
||||
one-time balloon, since it cannot report its own failure through the
|
||||
log. Requires agent 1.9.0: the two carry the shared pinned spool path
|
||||
and must be deployed together. -->
|
||||
<!-- 1.3.0: the tray is no longer the authoritative user-context collector.
|
||||
The 'ShopDB user-context probe' scheduled task now owns collection,
|
||||
because it is the only mechanism that runs in the operator's own logon
|
||||
session at RunLevel Limited; the tray keeps its own probe cadence as
|
||||
belt-and-braces and writes the same spool. The installer no longer
|
||||
starts the tray directly (explorer.exe de-elevated it but did not
|
||||
change ACCOUNT, so an admin install produced an admin tray whose
|
||||
probe saw the wrong drive table). Autostart at logon is the Run key;
|
||||
install-time startup on a machine someone is already signed in to is
|
||||
a transient 'ShopDB tray first launch' scheduled task, which is the
|
||||
only way to enter the operator's existing session unelevated. Probe
|
||||
exit code 2 - the agent refusing to collect while elevated - is
|
||||
reported by name instead of as a bare code. Requires agent 1.10.1. -->
|
||||
<!-- 1.4.0: the tray answers on-demand refresh requests. A background
|
||||
listener waits on Global\ShopDbEndpointAgent.UserContextRefresh and
|
||||
runs the probe when the service asks, which is what makes a WebUI
|
||||
"Refresh Status" click produce current mapped drives instead of the
|
||||
last cadence's answer - the service cannot collect this itself at any
|
||||
price, since drive letters live on the operator's logon token, so the
|
||||
request has to be serviced HERE. Each probe is marshalled onto the UI
|
||||
thread: the cadence timer and a service request could otherwise both
|
||||
pass the in-flight guard and race the same spool file. "Send Status"
|
||||
now awaits a probe before invoking the once verb, because that verb
|
||||
reads the spool and would otherwise publish the previous probe's
|
||||
answer to an operator who just changed something and is watching
|
||||
for it. The probe
|
||||
body is one shared method used by all three callers rather than three
|
||||
copies of the exit-code handling. A tray that cannot open the channel
|
||||
logs and keeps its timer, so the failure mode is "refresh is not
|
||||
immediate", not "session data stops working". Requires agent 1.11.0. -->
|
||||
<Version>1.4.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using ShopDbEndpointAgent.Core;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
@@ -25,8 +27,83 @@ internal sealed class TrayContext : ApplicationContext
|
||||
private readonly Icon brandIcon;
|
||||
private bool sending;
|
||||
|
||||
// Session-scoped collection. The service is LocalSystem and cannot see the
|
||||
// operator's mapped drives, user-owned listeners, or input idle time, so the
|
||||
// tray - which is already the operator - collects them and leaves the result
|
||||
// in a spool file the service reads on its own cadence.
|
||||
//
|
||||
// Deliberately longer than the telemetry cadence: this data changes rarely
|
||||
// (a mapped drive is mapped at logon and stays mapped), and each run spawns
|
||||
// a short PowerShell process on a machine an operator is actively using.
|
||||
private static readonly TimeSpan UserContextInterval = TimeSpan.FromMinutes(30);
|
||||
|
||||
// Logon is the worst moment to add work: drive mappings and profile setup
|
||||
// are still settling, and a probe fired now would capture an incomplete
|
||||
// picture while competing with everything else starting up.
|
||||
private static readonly TimeSpan UserContextStartupDelay = TimeSpan.FromMinutes(2);
|
||||
|
||||
// ...but that reasoning only applies at logon. When the tray starts into a
|
||||
// session that has been up for a while - which is what an installer restart
|
||||
// or a manual relaunch does - nothing is settling and the full delay just
|
||||
// extends the window where the server has no session data. The installer
|
||||
// kills and restarts the tray on every install, so the 2-minute delay was
|
||||
// being paid at exactly the moment someone was watching the card to see
|
||||
// whether the install had worked.
|
||||
private static readonly TimeSpan UserContextEstablishedDelay = TimeSpan.FromSeconds(15);
|
||||
|
||||
// How long the session must have been up to count as established. Comfortably
|
||||
// past the logon storm, well short of any realistic install-to-check gap.
|
||||
private static readonly TimeSpan EstablishedSessionUptime = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly System.Windows.Forms.Timer userContextTimer;
|
||||
private bool userContextStarted;
|
||||
private bool collectingUserContext;
|
||||
|
||||
// Lets the service ask this tray to re-probe on demand, so a "Refresh
|
||||
// Status" click in the WebUI produces genuinely current session data instead
|
||||
// of whatever the last 30-minute tick happened to capture. The service
|
||||
// cannot collect this itself at any price - mapped drives live on the
|
||||
// operator's logon token - so the only way for an on-demand refresh to be
|
||||
// accurate is for the request to end up here, in the operator's session.
|
||||
private readonly UserContextRefreshChannel? refreshChannel;
|
||||
private readonly CancellationTokenSource refreshCancellation = new();
|
||||
|
||||
// How long each wait blocks before looping. The wait returns false on
|
||||
// timeout and the loop simply waits again, so this is not a deadline for
|
||||
// anything - it just bounds how long the handle stays open, which lets a
|
||||
// channel that was somehow broken get re-opened on the next pass instead of
|
||||
// leaving the tray deaf until it restarts.
|
||||
private static readonly TimeSpan RefreshWaitTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
// The UI thread's context, captured in the constructor. The listener runs on
|
||||
// a background thread, but every probe attempt is marshalled back here so
|
||||
// that collectingUserContext is only ever read or written from one thread.
|
||||
// Without this the timer tick and a service request could both pass the
|
||||
// guard and launch two probes at the same spool file.
|
||||
private readonly SynchronizationContext? uiContext;
|
||||
|
||||
// The tray logs into the operator's own profile
|
||||
// (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log), NOT into the
|
||||
// service's ProgramData directory. The operator cannot write there, and the
|
||||
// file sink fails silently when it cannot create its directory, so tray-side
|
||||
// failures previously left no trace anywhere. Keeping the two apart means a
|
||||
// tray problem is diagnosable from a file the operator can actually reach.
|
||||
private readonly FileLoggerProvider logProvider;
|
||||
private readonly ILogger logger;
|
||||
|
||||
public TrayContext()
|
||||
{
|
||||
logProvider = new FileLoggerProvider(
|
||||
FileLoggerProvider.UserProfileLogDirectory(),
|
||||
filePrefix: "trayagent");
|
||||
logger = logProvider.CreateLogger("Tray");
|
||||
|
||||
var startupDelay = ResolveStartupDelay();
|
||||
logger.LogInformation(
|
||||
"Tray {Version} started; user-context probe in {Delay}, then every {Interval}.",
|
||||
typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||
startupDelay, UserContextInterval);
|
||||
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.Add("Send Status now", null, OnSendStatus);
|
||||
menu.Items.Add("Report an issue...", null, OnReportIssue);
|
||||
@@ -45,6 +122,218 @@ internal sealed class TrayContext : ApplicationContext
|
||||
ContextMenuStrip = menu,
|
||||
};
|
||||
trayIcon.DoubleClick += OnSendStatus;
|
||||
|
||||
// A dead log sink cannot report itself through the log. Complaining via
|
||||
// the logger would write the one message that explains the silence into
|
||||
// the sink that is swallowing messages, so the balloon is the only
|
||||
// channel that survives. Shown once at startup and never repeated: this
|
||||
// is a "your diagnostics are blind" notice for whoever is standing at the
|
||||
// machine, not a recurring nag about something the operator cannot fix.
|
||||
if (logProvider.SinkProblem is not null)
|
||||
{
|
||||
trayIcon.ShowBalloonTip(
|
||||
10000,
|
||||
"ShopDB Endpoint Agent",
|
||||
"Tray logging is unavailable, so tray diagnostics will not be "
|
||||
+ $"recorded. {logProvider.SinkProblem}",
|
||||
ToolTipIcon.Warning);
|
||||
}
|
||||
|
||||
userContextTimer = new System.Windows.Forms.Timer
|
||||
{
|
||||
Interval = (int)startupDelay.TotalMilliseconds,
|
||||
};
|
||||
userContextTimer.Tick += OnUserContextTimer;
|
||||
userContextTimer.Start();
|
||||
|
||||
uiContext = SynchronizationContext.Current;
|
||||
|
||||
// Best-effort. A tray that cannot open the channel still probes on its
|
||||
// timer, so the failure mode is "refresh is not immediate", not "session
|
||||
// data stops working" - which is why this is caught and logged rather
|
||||
// than allowed to take the tray down at startup.
|
||||
try
|
||||
{
|
||||
refreshChannel = new UserContextRefreshChannel(
|
||||
logProvider.CreateLogger(nameof(UserContextRefreshChannel)));
|
||||
StartRefreshListener();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception,
|
||||
"Could not open the user-context refresh channel; on-demand "
|
||||
+ "refresh requests from the service will be ignored and session "
|
||||
+ "data will only update on the {Interval} timer.",
|
||||
UserContextInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Waits for service refresh requests for the life of the tray.
|
||||
//
|
||||
// A dedicated background thread rather than a Task: this blocks on a kernel
|
||||
// wait handle essentially forever, which is exactly the workload that should
|
||||
// not sit on a thread-pool thread. IsBackground keeps it from holding the
|
||||
// process open at exit.
|
||||
private void StartRefreshListener()
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
var token = refreshCancellation.Token;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!refreshChannel!.WaitForRequest(RefreshWaitTimeout, token))
|
||||
continue;
|
||||
|
||||
logger.LogInformation(
|
||||
"The service requested a user-context refresh.");
|
||||
|
||||
// Marshalled, not awaited: the service is not waiting on a
|
||||
// reply through this channel, it is watching the spool file's
|
||||
// collectedat stamp. Our only job is to get the probe started
|
||||
// on the UI thread and go back to waiting.
|
||||
var post = uiContext;
|
||||
if (post is not null)
|
||||
post.Post(_ => _ = CollectUserContextAsync(), null);
|
||||
else
|
||||
_ = CollectUserContextAsync();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Never let a bad wait kill the listener - if this thread
|
||||
// exits, on-demand refresh is silently dead for the rest of
|
||||
// the session with no other symptom.
|
||||
logger.LogWarning(exception,
|
||||
"The user-context refresh listener failed; retrying.");
|
||||
try
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "ShopDB user-context refresh listener",
|
||||
};
|
||||
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
// Fires once after the startup delay, then re-arms at the steady-state
|
||||
// interval. Failures are swallowed on purpose: session data is a
|
||||
// nice-to-have, and an operator must never see a dialog or a crashed tray
|
||||
// because a probe could not read a drive mapping.
|
||||
private async void OnUserContextTimer(object? sender, EventArgs e)
|
||||
{
|
||||
if (!userContextStarted)
|
||||
{
|
||||
userContextStarted = true;
|
||||
userContextTimer.Interval = (int)UserContextInterval.TotalMilliseconds;
|
||||
}
|
||||
|
||||
await CollectUserContextAsync();
|
||||
}
|
||||
|
||||
// Runs the probe once and returns when the spool has been rewritten.
|
||||
//
|
||||
// Shared by all three callers - the cadence timer, an on-demand service
|
||||
// request, and "Send Status now" - so there is exactly one place that knows
|
||||
// how to invoke the probe and how to interpret its exit codes. Returns true
|
||||
// only when a spool file is actually on disk afterwards, which is what lets
|
||||
// Send Status decide whether it is worth waiting on.
|
||||
private async Task<bool> CollectUserContextAsync()
|
||||
{
|
||||
if (collectingUserContext)
|
||||
{
|
||||
// Another probe is already in flight against the same spool path.
|
||||
// Its result is at most a second or two old and satisfies whatever
|
||||
// this caller wanted, so coalescing is correct here, not a shortcut.
|
||||
logger.LogDebug(
|
||||
"A user-context probe is already running; skipping this request.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var agentExe = ResolveAgentExe();
|
||||
if (agentExe is null)
|
||||
{
|
||||
// Previously a bare return. This is the single most likely reason for
|
||||
// "no mapped drives" with nothing at all in any log, so it must say so.
|
||||
logger.LogWarning(
|
||||
"Skipping the user-context probe: {Agent} was not found beside the tray "
|
||||
+ "({Beside}) or at {Installed}.",
|
||||
AgentExeName, AppContext.BaseDirectory, DefaultInstallDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
collectingUserContext = true;
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Running the user-context probe: {Agent}", agentExe);
|
||||
var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext");
|
||||
|
||||
// Check the disk rather than announcing what the exit code implies.
|
||||
// This line used to print a hardcoded path whenever the child exited
|
||||
// 0, so it reported "spool written to C:\Users\ShopFloor\ShopDB" for
|
||||
// weeks while the probe was actually writing into a different profile
|
||||
// and the service was finding nothing.
|
||||
var resultPath = UserContextSpool.PinnedResultPath();
|
||||
if (exitCode == 0 && File.Exists(resultPath))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"The user-context probe finished; spool present at {Spool}.",
|
||||
resultPath);
|
||||
return true;
|
||||
}
|
||||
else if (exitCode == 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context probe reported success but no spool file is "
|
||||
+ "present at {Spool}.", resultPath);
|
||||
}
|
||||
else if (exitCode == 2)
|
||||
{
|
||||
// The agent refused because it was elevated. Called out by name
|
||||
// because as a bare exit code it reads like a probe fault and
|
||||
// sends the reader looking for a drive or WMI problem that is
|
||||
// not there - the fix is to start the tray unelevated, or let
|
||||
// the scheduled task do the collecting.
|
||||
logger.LogWarning(
|
||||
"The user-context probe refused to run because this tray is "
|
||||
+ "ELEVATED. Mapped drives are per logon token, so an elevated "
|
||||
+ "probe would report the wrong drive table. Start the tray "
|
||||
+ "unelevated, or rely on the 'ShopDB user-context probe' "
|
||||
+ "scheduled task.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context probe exited with code {ExitCode}; expected a "
|
||||
+ "spool at {Spool}.", exitCode, resultPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// No longer silent. The agent logs its own detail, but if the process
|
||||
// could not even be started the agent never ran and never logged.
|
||||
logger.LogWarning(exception, "Could not run the user-context probe.");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
collectingUserContext = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnReportIssue(object? sender, EventArgs e)
|
||||
@@ -169,6 +458,18 @@ internal sealed class TrayContext : ApplicationContext
|
||||
|
||||
try
|
||||
{
|
||||
// Probe BEFORE reporting, and await it. The operator clicking this
|
||||
// has just changed something - remapped a drive, signed in - and
|
||||
// expects the result to show up. --once reads the spool file, so a
|
||||
// probe started after it, or not awaited, would publish the previous
|
||||
// probe's answer and the operator would click again and see the same
|
||||
// stale card.
|
||||
//
|
||||
// Not fatal if it fails: a report with older session data still
|
||||
// carries the heartbeat, inventory and events, which is most of what
|
||||
// this button is for.
|
||||
await CollectUserContextAsync();
|
||||
|
||||
int exitCode = await RunAgentAsync(agentExe, "--once");
|
||||
if (exitCode == 0)
|
||||
ShowBalloon("Send Status", "Status sent to ShopDB.",
|
||||
@@ -249,6 +550,35 @@ internal sealed class TrayContext : ApplicationContext
|
||||
startInfo.Environment.Remove(key);
|
||||
}
|
||||
|
||||
// Full delay at logon, short delay when the session is already established.
|
||||
//
|
||||
// Environment.TickCount64 is machine uptime, not session uptime, which is a
|
||||
// deliberate simplification: the two differ only when someone signs out and
|
||||
// back in without rebooting, and treating that as "established" is harmless -
|
||||
// the profile is already built and the drives are already mapped in that
|
||||
// case too. A failure to read it falls back to the conservative long delay.
|
||||
private TimeSpan ResolveStartupDelay()
|
||||
{
|
||||
try
|
||||
{
|
||||
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
|
||||
if (uptime >= EstablishedSessionUptime)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Session has been up {Uptime}; using the established-session probe delay.",
|
||||
uptime);
|
||||
return UserContextEstablishedDelay;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception,
|
||||
"Could not read system uptime; using the logon probe delay.");
|
||||
}
|
||||
|
||||
return UserContextStartupDelay;
|
||||
}
|
||||
|
||||
// Same directory first (the installer ships the tray next to the agent),
|
||||
// then the known Program Files install directory as a fallback.
|
||||
private static string? ResolveAgentExe()
|
||||
@@ -316,6 +646,15 @@ internal sealed class TrayContext : ApplicationContext
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
userContextTimer.Stop();
|
||||
userContextTimer.Dispose();
|
||||
|
||||
// Cancel and let the listener unwind on its own. The channel opens
|
||||
// and closes its handle inside each wait rather than holding one, so
|
||||
// there is nothing here to dispose.
|
||||
refreshCancellation.Cancel();
|
||||
refreshCancellation.Dispose();
|
||||
|
||||
trayIcon.Dispose();
|
||||
brandIcon.Dispose();
|
||||
}
|
||||
|
||||
@@ -22,15 +22,110 @@ using ShopDbEndpointAgent.Core;
|
||||
|
||||
var runOnce = args.Any(a => string.Equals(a, "--once", StringComparison.OrdinalIgnoreCase));
|
||||
var forceConsole = args.Any(a => string.Equals(a, "--console", StringComparison.OrdinalIgnoreCase));
|
||||
var collectUserContext = args.Any(a =>
|
||||
string.Equals(a, "--collect-usercontext", StringComparison.OrdinalIgnoreCase));
|
||||
var reportPayloadPath = ArgValue(args, "--report");
|
||||
|
||||
var agentVersion = Assembly.GetExecutingAssembly()
|
||||
.GetName().Version?.ToString() ?? "0.0.0";
|
||||
|
||||
// FIRST, ahead of everything else. This verb runs as the signed-in operator (the
|
||||
// tray invokes it) and must not touch a single privileged resource on the way in.
|
||||
//
|
||||
// It used to sit below the bootstrap logger, the fault monitor, and the config
|
||||
// load, which meant the operator-context process opened the SYSTEM-owned
|
||||
// ProgramData log directory, attempted an EventLog source registration that only
|
||||
// an administrator can perform, and read CollectorKey out of HKLM - all before it
|
||||
// discovered it was only being asked to write a file into the user's own profile.
|
||||
// On an EPM-managed host (CyberArk) those are exactly the calls a broker
|
||||
// intercepts, and the registry read put the collector secret in a process that
|
||||
// has no business holding it.
|
||||
//
|
||||
// It also has no config dependency by design: it only writes into the caller's
|
||||
// own profile and never talks to the server, so it must still work on a machine
|
||||
// whose CollectorKey has not been provisioned yet - otherwise session data would
|
||||
// be missing on precisely the endpoints that are hardest to diagnose.
|
||||
if (collectUserContext)
|
||||
{
|
||||
// First statement in this branch. The scheduled task runs this exe directly
|
||||
// in the operator's interactive session, and shopdbagent.exe is a console
|
||||
// app, so without this a black window flashes up on every collection cycle.
|
||||
// Done before any logging or probing so the window is gone immediately
|
||||
// rather than lingering for the life of the probe.
|
||||
ConsoleWindow.Hide();
|
||||
|
||||
// Logs to the OPERATOR's profile, not the service's ProgramData directory.
|
||||
// A non-privileged user cannot write to the SYSTEM-owned log directory, and
|
||||
// the file sink degrades to a no-op when it cannot create its directory, so
|
||||
// pointing this at the service location meant every probe failure was
|
||||
// discarded unread - which is exactly how a broken probe looked identical to
|
||||
// a probe that was never invoked.
|
||||
using var probeLogProvider = new FileLoggerProvider(
|
||||
FileLoggerProvider.UserProfileLogDirectory(),
|
||||
filePrefix: "trayagent");
|
||||
using var probeLoggerFactory = LoggerFactory.Create(
|
||||
logging => logging.AddProvider(probeLogProvider));
|
||||
|
||||
var probeLogger = probeLoggerFactory.CreateLogger<UserContextProbe>();
|
||||
ReportSinkProblem(probeLogProvider, "user-context probe");
|
||||
|
||||
// Refuse to collect from an elevated process. Drive letters are a property
|
||||
// of the logon token, not the machine: an elevated process gets a different
|
||||
// token to the operator's interactive one and enumerates a DIFFERENT, almost
|
||||
// always empty, drive table. Measured on a live endpoint, the same two
|
||||
// shares reported Unavailable from the elevated shell and OK from the
|
||||
// operator's, in both Win32_NetworkConnection and Get-SmbMapping.
|
||||
//
|
||||
// Refusing here rather than filtering later is deliberate. An elevated probe
|
||||
// does not fail - it succeeds and reports every mapped drive as broken,
|
||||
// which is indistinguishable from a genuine outage at the far end. Better to
|
||||
// write nothing and say why than to publish a confident wrong answer.
|
||||
if (IsProcessElevated())
|
||||
{
|
||||
probeLogger.LogError(
|
||||
"Refusing to collect user context: this process is elevated. Mapped drives " +
|
||||
"are per-logon-token, so an elevated probe reports the wrong drive table. " +
|
||||
"Run the '{Task}' scheduled task (RunLevel Limited) instead of invoking " +
|
||||
"--collect-usercontext from an admin shell.",
|
||||
"ShopDB user-context probe");
|
||||
Console.Error.WriteLine(
|
||||
"shopdbagent: refusing to collect user context from an elevated process; " +
|
||||
"mapped drives would be wrong. Use the scheduled task.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var probe = new UserContextProbe(probeLogger);
|
||||
return probe.Collect() ? 0 : 1;
|
||||
}
|
||||
|
||||
// True when the current process holds the elevated (high-integrity) token.
|
||||
static bool IsProcessElevated()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||
var principal = new System.Security.Principal.WindowsPrincipal(identity);
|
||||
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cannot tell. Treat as not elevated so an unreadable token does not
|
||||
// suppress collection on a host where the probe would have worked; the
|
||||
// spool's own account stamp is still checked on the way back in.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap logger first so config loading (including the registry read that
|
||||
// CyberArk/EPM may gate) is fully diagnosable before the host starts.
|
||||
using var bootstrapFileLogger = new FileLoggerProvider(FileLoggerProvider.DefaultDirectory());
|
||||
var bootstrapLogger = bootstrapFileLogger.CreateLogger("Startup");
|
||||
ReportSinkProblem(bootstrapFileLogger, "service");
|
||||
|
||||
var faultMonitor = new AgentFaultMonitor(bootstrapLogger);
|
||||
faultMonitor.Install();
|
||||
@@ -191,10 +286,37 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
||||
sp.GetRequiredService<ILogger<PowerShellUtilityRunner>>()));
|
||||
}
|
||||
|
||||
// Persisted per-channel event watermark so each Windows event record is
|
||||
// reported exactly once, instead of re-sending the newest window of every
|
||||
// channel on every cadence tick.
|
||||
services.AddSingleton(sp => new EventWatermarkStore(
|
||||
sp.GetRequiredService<ILogger<EventWatermarkStore>>()));
|
||||
|
||||
// Session-scoped collection. The agent is LocalSystem, so mapped drives and
|
||||
// user-owned listeners are invisible to it. The tray, which already runs as
|
||||
// the operator, writes the spool file; the service only reads it.
|
||||
//
|
||||
// UserContextProbe is deliberately NOT registered here. The service must
|
||||
// never run the probe: descending into the session with CreateProcessAsUser
|
||||
// is the reverted design, and leaving a resolvable dependency in the
|
||||
// container is an invitation to reintroduce it. The probe is constructed
|
||||
// only on the --collect-usercontext path, which the tray invokes.
|
||||
services.AddSingleton(sp => new UserContextSpool(
|
||||
sp.GetRequiredService<ILogger<UserContextSpool>>()));
|
||||
|
||||
// The ask-the-tray side of the same split. Registering this is NOT a
|
||||
// loophole around the paragraph above: the service still cannot collect
|
||||
// session data, it can only request that the process which can does so, and
|
||||
// then read the file that appears. The probe stays out of this container.
|
||||
services.AddSingleton(sp => new UserContextRefreshChannel(
|
||||
sp.GetRequiredService<ILogger<UserContextRefreshChannel>>()));
|
||||
|
||||
services.AddSingleton<ICollector>(sp => new EndpointCollector(
|
||||
sp.GetRequiredService<ILogger<EndpointCollector>>(),
|
||||
config,
|
||||
sp.GetService<IAgentUtilityRunner>()));
|
||||
sp.GetService<IAgentUtilityRunner>(),
|
||||
sp.GetRequiredService<EventWatermarkStore>(),
|
||||
sp.GetRequiredService<UserContextSpool>()));
|
||||
|
||||
services.AddSingleton(sp => new CollectorClient(
|
||||
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||
@@ -206,7 +328,9 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config
|
||||
agentVersion,
|
||||
sp.GetRequiredService<ILogger<TelemetryWorker>>(),
|
||||
sp.GetService<IAgentUtilityRunner>(),
|
||||
sp.GetRequiredService<CollectSignal>()));
|
||||
sp.GetRequiredService<CollectSignal>(),
|
||||
sp.GetRequiredService<UserContextSpool>(),
|
||||
sp.GetRequiredService<UserContextRefreshChannel>()));
|
||||
|
||||
// Command channel: outbound long-poll for allowlisted WebUI commands, run
|
||||
// through the fail-closed executor. Hosted alongside telemetry but on its
|
||||
@@ -231,3 +355,22 @@ static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
|
||||
logging.AddProvider(new FileLoggerProvider(logDirectory));
|
||||
logging.SetMinimumLevel(LogLevel.Information);
|
||||
}
|
||||
|
||||
// Say so, loudly, when a file sink could not open its directory.
|
||||
//
|
||||
// FileLoggerProvider never throws on construction and silently drops lines when
|
||||
// its directory is unusable. That is the right behaviour - logging must not take
|
||||
// the agent down - but it made two different failures indistinguishable: a probe
|
||||
// that ran and failed, and a probe whose log had nowhere to go. The provider
|
||||
// records the reason; this is the part that was missing, the caller actually
|
||||
// asking for it. stderr is the only sink guaranteed to exist at this point, so
|
||||
// that is where the complaint goes.
|
||||
static void ReportSinkProblem(FileLoggerProvider provider, string which)
|
||||
{
|
||||
if (provider.SinkProblem is null)
|
||||
return;
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"shopdbagent: the {which} log sink is unusable and log lines will be "
|
||||
+ $"dropped - {provider.SinkProblem}");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,88 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 1.6.3: operator issue reports stage under the interactive operator's
|
||||
<!-- 1.10.1: the collect-usercontext verb hides its console window. The agent
|
||||
is a console exe, which the tray used to mask by launching the probe with
|
||||
CreateNoWindow; the scheduled task runs the exe directly in the operator's
|
||||
session, so a black window appeared on every 30-minute collection. -->
|
||||
<!-- 1.10.0: user context is collected by a scheduled task in the operator's
|
||||
own logon session, not by a process the elevated installer launches.
|
||||
Drive letters are per logon token: an elevated probe enumerates a
|
||||
different, usually empty, drive table, so an install performed by an
|
||||
admin produced a spool reporting every mapped drive as Unavailable while
|
||||
the operator's own session showed OK. The installer no longer starts the
|
||||
tray via explorer.exe (which de-elevates but does NOT change account) and
|
||||
instead registers 'ShopDB user-context probe' against BUILTIN\Users at
|
||||
RunLevel Limited, verifies it by reading the task back, and records the
|
||||
outcome in the install ledger. The collect-usercontext verb now refuses
|
||||
to run elevated (exit 2) rather than writing a confident wrong answer,
|
||||
the probe stamps its token elevation, and the service rejects any spool
|
||||
stamped elevated. Probe runs log the drive and listener counts. -->
|
||||
<!-- 1.9.0: the spool is finally written where the service looks for it. The
|
||||
probe script resolved its output directory from $env:USERPROFILE while
|
||||
the reader used the pinned C:\Users\ShopFloor path; under a brokered or
|
||||
re-parented tray launch those differ, so the probe reported success and
|
||||
wrote the file somewhere the service never read. Both sides now derive
|
||||
the path from one helper (UserContextSpool.PinnedResultPath). Collect()
|
||||
verifies the file exists on disk before reporting success, because a
|
||||
zero exit code from powershell.exe proved nothing about the write.
|
||||
Minor rather than patch because the spool handoff itself changed shape
|
||||
(AGENT-CONTRACT.md 5): the spool is now PERSISTED and overwritten each
|
||||
run rather than consumed on read. The collector cache is in-memory only,
|
||||
so consume-on-read plus a service restart blanked mapped drives until the
|
||||
next tray cycle. Max age tightened 24h -> 90m now that staleness, not
|
||||
deletion, is the retirement mechanism. A spool whose username stamp is
|
||||
absent or not ShopFloor is rejected outright - wrong-session drive data
|
||||
is worse than none.
|
||||
The collect-usercontext branch moved to the top of Program.cs: it used to
|
||||
open the SYSTEM-owned log directory, register an EventLog source, and
|
||||
read CollectorKey out of HKLM before discovering it only needed to write
|
||||
a file into the caller's own profile - all as the operator, and all
|
||||
exactly what an EPM broker intercepts. Log filenames unified on
|
||||
<date>-<component>.log across service, tray, and installer. A log sink
|
||||
that cannot open its directory now says so on stderr instead of
|
||||
discarding every line unread. Pairs with tray 1.2.0. -->
|
||||
<!-- 1.8.1: user-context logging and spool location are split from the
|
||||
service's. The probe runs as the signed-in operator but logged into
|
||||
%ProgramData%\ShopDB\EndpointAgent\logs, which is SYSTEM-owned and not
|
||||
writable from a user session; the file sink swallows that failure, so a
|
||||
probe that failed and a probe that never ran looked identical - no log
|
||||
line either way. The probe and the tray now log to
|
||||
C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log, which the
|
||||
operator owns. The spool path is pinned to C:\Users\ShopFloor\ShopDB
|
||||
instead of being resolved through WTSQueryUserToken (a privileged call
|
||||
endpoint protection blocks, after which writer and reader disagreed
|
||||
silently about the location). The service now CONSUMES the spool -
|
||||
removing it after a successful parse - so a dead tray stops re-reporting
|
||||
stale drives instead of pinning them forever. The probe itself is
|
||||
launched with -EncodedCommand rather than a staged .ps1 run via -File:
|
||||
a quarantined or missing temp script makes powershell.exe print usage
|
||||
and exit -196608 without running a statement. -->
|
||||
<!-- 1.8.0: user-context collection is inverted. The service no longer
|
||||
descends into the operator's session with CreateProcessAsUser to run the
|
||||
probe; that path needs to duplicate a token, is what endpoint protection
|
||||
watches for, and produced no spool file at all on a live endpoint. The
|
||||
tray, which is already running as the operator, now invokes
|
||||
shopdbagent.exe with the collect-usercontext verb on its own cadence and
|
||||
the service only reads the spool. Requires the 1.1.0 tray: an older tray
|
||||
the new verb, so usercontext stays null until the tray is updated.
|
||||
Spool freshness moved from delete-around-read to the in-file collectedat
|
||||
stamp with a max age, because writer and reader are now independent
|
||||
processes and a delete-then-read would race the tray and discard good
|
||||
results. -->
|
||||
<!-- 1.7.0: curated events are harvested against a persisted per-channel
|
||||
Windows record-id watermark and carry eventrecordid, so one real event
|
||||
is reported and stored exactly once instead of once per cadence tick
|
||||
for as long as it stayed in the newest-25 window. Adds WER 1000/1002,
|
||||
event XML fields, listening ports, an on-demand network_connections
|
||||
command, pendingreboot, disk freepercent, and populates cpupercent/
|
||||
memorypercent/idleseconds (previously declared but always null).
|
||||
Session-scoped data (mapped drives, user-owned listeners, idle time)
|
||||
comes from a probe run in the interactive user's session via a spool
|
||||
file, because none of it exists in the agent's LocalSystem session 0.
|
||||
run_script user-context waits are now bounded; the previous Infinite
|
||||
wait could pin the command worker on a hung script.
|
||||
1.6.3: operator issue reports stage under the interactive operator's
|
||||
%USERPROFILE%\ShopDBScreenshots (resolved in the ShopFloor tray session)
|
||||
instead of %TEMP%, which on locked-down endpoints redirected to another
|
||||
user's AppData\Local\Temp and left the SYSTEM-context report upload
|
||||
@@ -50,7 +131,26 @@
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.6.3</Version>
|
||||
<!-- 1.11.0: on-demand user-context refresh. "Refresh Status" (collect_now)
|
||||
and the tray's "Send Status" re-sent whatever was already in the spool,
|
||||
so a drive the operator had just unmapped kept reporting OK until the
|
||||
next 30-minute probe and no amount of clicking changed it. The service
|
||||
still does not - and must not - collect session data itself; it signals
|
||||
Global\ShopDbEndpointAgent.UserContextRefresh and waits up to 12s for the
|
||||
spool's collectedat stamp to ADVANCE before collecting, which is the only
|
||||
proof the file in front of it answers this request and not the previous
|
||||
one. The wait is bounded and non-fatal by design: telemetry is the
|
||||
liveness signal, so a timeout reports slightly stale session data rather
|
||||
than skipping the report. UserContextSpool.TryReadCollectedAt reads the
|
||||
stamp outside the usability gates, so a spool rejected as expired or
|
||||
wrong-account does not burn the full timeout every cycle. Minor because
|
||||
the spool handoff gains a second channel (AGENT-CONTRACT.md 5) and
|
||||
because the installer now registers the probe task to the ShopFloor USER
|
||||
rather than BUILTIN\Users: a group-principal task cannot be started by a
|
||||
member of that group, so Start-ScheduledTask returned access denied and
|
||||
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
|
||||
is the side that listens, so an older tray simply never answers. -->
|
||||
<Version>1.11.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
# install-shopdbagent.ps1
|
||||
# ShopDB Endpoint Agent installer for the test endpoint. Run from an ELEVATED
|
||||
# (admin) PowerShell on the endpoint. Provisions the registry secrets, copies
|
||||
# the agent files to Program Files, runs a one-shot smoke test, then installs
|
||||
# and starts the Windows service.
|
||||
#
|
||||
# Secrets are provisioned to HKLM\SOFTWARE\GE\ShopDB. On locked-down hosts
|
||||
# (CyberArk/EPM) the agent may be blocked from READING that hive, so the same
|
||||
# values are also stamped into the installed appsettings.json as a fallback.
|
||||
#
|
||||
# The agent and tray are published as single-file self-contained executables,
|
||||
# so copying the two .exe files plus appsettings.json is the complete payload.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# ShopDB server root URL for this instance. REQUIRED - there is no default,
|
||||
# because a default here means one site's hostname shipped to every site
|
||||
# (ADR-015) and, worse, a working install pointed at somebody else's server.
|
||||
#
|
||||
# https is required. The collector key travels in the X-API-Key header and
|
||||
# every telemetry payload follows it, so plain http puts both on the wire in
|
||||
# clear. Pass -AllowInsecureHttp only for a local test instance.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $BaseUrl,
|
||||
|
||||
# Endpoints-only collector key: this instance's .env COLLECTOR_API_KEY_ENDPOINTS.
|
||||
# REQUIRED and never defaulted - a key in source is a key in git history, and
|
||||
# this one is also the command channel's kill switch.
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $CollectorKey,
|
||||
|
||||
# Permit an http:// BaseUrl. For a local test instance only.
|
||||
[switch] $AllowInsecureHttp,
|
||||
|
||||
|
||||
[string] $InstallDir = "C:\Program Files\ShopDB\EndpointAgent",
|
||||
[string] $ServiceName = "ShopDbEndpointAgent",
|
||||
|
||||
# Interactive tray app that shows the system-tray icon and the on-demand
|
||||
# "Send Status now" action. Separate process from the SYSTEM service (a
|
||||
# service cannot draw a tray icon in a user session). Pass -NoTray to skip.
|
||||
[string] $TrayExeName = "shopdb-endpoint-tray.exe",
|
||||
[switch] $NoTray,
|
||||
|
||||
# Add a Microsoft Defender path exclusion for the install directory. The
|
||||
# agent and tray are unsigned single-file self-contained executables, which
|
||||
# Defender heuristics (and the tray's GDI/user32 icon P/Invoke) can flag as
|
||||
# a false positive and quarantine, killing the tray before its icon appears.
|
||||
# Pass -NoDefenderExclusion to skip (e.g. where policy manages exclusions
|
||||
# centrally or the binaries are code-signed).
|
||||
[switch] $NoDefenderExclusion
|
||||
)
|
||||
|
||||
# Refuse cleartext unless it was asked for explicitly. The key is a bearer
|
||||
# credential for the whole fleet's telemetry and command channel.
|
||||
if ($BaseUrl -notmatch '^https://' -and -not $AllowInsecureHttp) {
|
||||
throw "BaseUrl must be https:// (got '$BaseUrl'). Pass -AllowInsecureHttp for a local test instance."
|
||||
}
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
function Assert-Admin {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw "This script must be run from an elevated (Run as administrator) PowerShell."
|
||||
}
|
||||
}
|
||||
|
||||
Assert-Admin
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl)) {
|
||||
throw "BaseUrl is not set. Pass -BaseUrl 'http://<host>:<port>' when running the script."
|
||||
}
|
||||
|
||||
# Write HKLM values through the explicit 64-bit registry view. If this script
|
||||
# runs under 32-bit PowerShell (some management tooling launches x86), the
|
||||
# PSDrive HKLM: silently redirects writes into WOW6432Node. The 64-bit agent
|
||||
# service and 64-bit Explorer (the logon Run key) then read the 64-bit view and
|
||||
# see nothing, so config "goes missing" and the tray never autostarts. Pinning
|
||||
# the view removes that dependency on the launching shell's bitness.
|
||||
function Set-Hklm64Value {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $SubKey,
|
||||
[Parameter(Mandatory)] [string] $Name,
|
||||
[Parameter(Mandatory)] [string] $Value
|
||||
)
|
||||
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
|
||||
[Microsoft.Win32.RegistryHive]::LocalMachine,
|
||||
[Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $base.CreateSubKey($SubKey)
|
||||
try {
|
||||
$key.SetValue($Name, $Value, [Microsoft.Win32.RegistryValueKind]::String)
|
||||
}
|
||||
finally { $key.Dispose() }
|
||||
}
|
||||
finally { $base.Dispose() }
|
||||
}
|
||||
|
||||
# Tear down any previously installed agent BEFORE touching files. The agent and
|
||||
# tray are single-file self-contained exes; while the old service or tray is
|
||||
# running it holds those .exe files open, so Copy-Item over them fails with a
|
||||
# file-in-use error. Stopping the service, deleting it, and killing any lingering
|
||||
# agent/tray processes up front makes the install a clean overwrite. Best-effort
|
||||
# throughout: a missing service or process is the normal fresh-install case.
|
||||
function Uninstall-PreviousAgent {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Service,
|
||||
[Parameter(Mandatory)] [string] $TrayExe,
|
||||
[Parameter(Mandatory)] [string] $TargetDir
|
||||
)
|
||||
|
||||
$existing = Get-Service -Name $Service -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host " stopping and removing existing service $Service ..."
|
||||
if ($existing.Status -ne "Stopped") {
|
||||
try { Stop-Service -Name $Service -Force -ErrorAction Stop }
|
||||
catch { Write-Warning " could not stop service: $($_.Exception.Message)" }
|
||||
}
|
||||
# sc.exe delete only marks the service for deletion; the SCM finishes the
|
||||
# removal once every handle closes. Give it a moment so the subsequent
|
||||
# sc.exe create does not hit a "marked for deletion" (1072) error.
|
||||
sc.exe delete $Service | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
# Kill any lingering worker or tray processes that would keep the exes
|
||||
# locked. Match by the installed exe base names; the service host process is
|
||||
# shopdbagent, the tray is the TrayExe base name.
|
||||
$trayBase = [System.IO.Path]::GetFileNameWithoutExtension($TrayExe)
|
||||
foreach ($name in @("shopdbagent", $trayBase)) {
|
||||
$procs = Get-Process -Name $name -ErrorAction SilentlyContinue
|
||||
foreach ($p in $procs) {
|
||||
Write-Host " killing lingering process $($p.ProcessName) (pid $($p.Id)) ..."
|
||||
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
|
||||
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
# Also kill anything still running out of the target install directory,
|
||||
# regardless of exe name. A tray from an older build (different exe name) or
|
||||
# one launched from a stale path would otherwise survive the name-based sweep
|
||||
# above, keep its exe locked, and leave a duplicate icon once step 5 starts
|
||||
# the fresh tray. Match on the executable path so we only touch our own dir.
|
||||
$targetPrefix = $TargetDir.TrimEnd('\') + '\'
|
||||
foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) {
|
||||
$path = $null
|
||||
try { $path = $p.Path } catch { $path = $null }
|
||||
if ($path -and $path.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Write-Host " killing process from install dir $($p.ProcessName) (pid $($p.Id)) ..."
|
||||
try { Stop-Process -Id $p.Id -Force -ErrorAction Stop }
|
||||
catch { Write-Warning " could not kill $($p.ProcessName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
# Brief settle so file handles are released before the copy overwrites them.
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
|
||||
Write-Host "BaseUrl : $BaseUrl"
|
||||
Write-Host "InstallDir : $InstallDir"
|
||||
Write-Host "ServiceName : $ServiceName"
|
||||
|
||||
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
|
||||
# typically extracts installer.zip into Downloads, so every file carries a
|
||||
# Zone.Identifier ADS. SmartScreen reads that and blocks the unsigned exes with
|
||||
# "Windows protected your PC" the first time they run - including the [3/5]
|
||||
# smoke test below, which launches shopdbagent.exe out of $sourceDir before the
|
||||
# installed copy exists. Unblocking the source folder up front keeps the smoke
|
||||
# test (and any manual run from the extract folder) clean; the installed copies
|
||||
# get unblocked again in step 2a after they land in Program Files. Best-effort:
|
||||
# a file with no MotW is a no-op.
|
||||
Write-Host "`n[0a] Clearing Mark-of-the-Web on the extracted installer files ..."
|
||||
Get-ChildItem -Path $sourceDir -File | ForEach-Object {
|
||||
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
|
||||
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# 0. Uninstall any previous agent first, so the file copy is a clean overwrite
|
||||
# and does not fail on exes locked by a running service or tray.
|
||||
Write-Host "`n[0/5] Removing any previously installed agent ..."
|
||||
Uninstall-PreviousAgent -Service $ServiceName -TrayExe $TrayExeName -TargetDir $InstallDir
|
||||
|
||||
# 1. Provision registry secrets (64-bit view, see Set-Hklm64Value).
|
||||
$regPath = "HKLM:\SOFTWARE\GE\ShopDB"
|
||||
Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
|
||||
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
||||
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
||||
|
||||
# 2. Copy agent files to the install directory. The agent and tray are
|
||||
# single-file self-contained executables, so the .exe plus appsettings.json is
|
||||
# the whole payload; there are no side-by-side runtime DLLs to copy.
|
||||
Write-Host "[2/5] Copying agent files to $InstallDir ..."
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
$agentSource = Join-Path $sourceDir "shopdbagent.exe"
|
||||
if (-not (Test-Path $agentSource)) {
|
||||
throw "shopdbagent.exe not found in $sourceDir. Unzip the full installer package and run from that folder."
|
||||
}
|
||||
Copy-Item -Path $agentSource -Destination $InstallDir -Force
|
||||
$appsettings = Join-Path $sourceDir "appsettings.json"
|
||||
$installedSettings = Join-Path $InstallDir "appsettings.json"
|
||||
if (Test-Path $appsettings) {
|
||||
Copy-Item -Path $appsettings -Destination $InstallDir -Force
|
||||
}
|
||||
|
||||
# Copy the interactive tray app next to the agent so its same-dir resolution
|
||||
# finds shopdbagent.exe for the "Send Status now" action. Optional: absent on a
|
||||
# service-only (headless) build.
|
||||
$trayInstalled = $null
|
||||
if (-not $NoTray) {
|
||||
$traySource = Join-Path $sourceDir $TrayExeName
|
||||
if (Test-Path $traySource) {
|
||||
Copy-Item -Path $traySource -Destination $InstallDir -Force
|
||||
$trayInstalled = Join-Path $InstallDir $TrayExeName
|
||||
Write-Host " copied tray app $TrayExeName"
|
||||
}
|
||||
else {
|
||||
Write-Warning " tray app $TrayExeName not found in $sourceDir; skipping tray (service still installs)"
|
||||
}
|
||||
}
|
||||
|
||||
# Fallback for locked-down endpoints: some hosts (CyberArk/EPM) gate the HKLM
|
||||
# hive so the agent cannot READ the values the registry step just wrote. Stamp
|
||||
# BaseUrl/CollectorKey into the installed appsettings.json too. The agent reads
|
||||
# appsettings first, then lets the registry override it, so this is a safe
|
||||
# no-op where the registry read works and the rescue where it does not.
|
||||
if (Test-Path $installedSettings) {
|
||||
try {
|
||||
$json = Get-Content $installedSettings -Raw | ConvertFrom-Json
|
||||
$json.baseurl = $BaseUrl
|
||||
$json.apikey = $CollectorKey
|
||||
$json | ConvertTo-Json -Depth 10 | Set-Content -Path $installedSettings -Encoding UTF8
|
||||
Write-Host " stamped BaseUrl/CollectorKey into appsettings.json (registry-read fallback)"
|
||||
}
|
||||
catch {
|
||||
Write-Warning " could not stamp appsettings.json: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
$exePath = Join-Path $InstallDir "shopdbagent.exe"
|
||||
|
||||
# 2a. Strip the Mark-of-the-Web from the copied files. Windows tags files
|
||||
# extracted from a downloaded zip with a Zone.Identifier alternate data stream;
|
||||
# SmartScreen reads that and blocks the unsigned exe with "Windows protected
|
||||
# your PC" the first time it runs, and the operator sees only a "Don't run"
|
||||
# button. Unblock-File removes the ADS so the smoke test and the tray launch
|
||||
# start clean. Best-effort: a file with no MotW is a no-op.
|
||||
Write-Host "[2a/5] Clearing Mark-of-the-Web on installed files ..."
|
||||
Get-ChildItem -Path $InstallDir -File | ForEach-Object {
|
||||
try { Unblock-File -Path $_.FullName -ErrorAction Stop }
|
||||
catch { Write-Warning " could not unblock $($_.Name): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# 2b. Defender path exclusion for the install dir. Unsigned single-file
|
||||
# self-contained binaries (and the tray's icon P/Invoke) can trip Defender
|
||||
# heuristics and get quarantined, which is why the tray dies before showing an
|
||||
# icon. Excluding the fixed install directory is the least-privilege fix short
|
||||
# of code-signing. Best-effort: Set-MpPreference is absent on servers without
|
||||
# Defender and can be blocked by tamper protection; a failure is not fatal.
|
||||
if (-not $NoDefenderExclusion) {
|
||||
Write-Host "[2b/5] Adding Defender path exclusion for $InstallDir ..."
|
||||
if (Get-Command Add-MpPreference -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $InstallDir -ErrorAction Stop
|
||||
Write-Host " excluded $InstallDir from Defender scanning."
|
||||
}
|
||||
catch {
|
||||
Write-Warning " could not add Defender exclusion (tamper protection or policy?): $($_.Exception.Message)"
|
||||
Write-Warning " if the tray is quarantined, code-sign the binaries or add the exclusion via policy."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " Defender cmdlets not present; skipping exclusion."
|
||||
}
|
||||
}
|
||||
|
||||
# 3. One-shot smoke test (exact production collection path, then exits).
|
||||
Write-Host "[3/5] Running one-shot smoke test (--once) ..."
|
||||
$proc = Start-Process -FilePath $exePath -ArgumentList "--once" -WorkingDirectory $InstallDir -Wait -PassThru -NoNewWindow
|
||||
Write-Host " smoke test exit code: $($proc.ExitCode)"
|
||||
|
||||
$logDir = Join-Path $env:ProgramData "ShopDB\EndpointAgent\logs"
|
||||
$logFile = Join-Path $logDir ("agent-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
|
||||
if (Test-Path $logFile) {
|
||||
Write-Host " --- last log lines ---" -ForegroundColor DarkGray
|
||||
Get-Content $logFile -Tail 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
}
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
throw "Smoke test failed (exit $($proc.ExitCode)). Check $logFile before installing the service."
|
||||
}
|
||||
|
||||
# 4. Install the Windows service. Any previous instance was already stopped,
|
||||
# deleted, and its processes killed in step 0, so this is a clean create. Guard
|
||||
# against the rare case where the SCM has not finished a pending deletion yet.
|
||||
Write-Host "[4/5] Installing service $ServiceName ..."
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host " service still present (pending deletion); removing again ..."
|
||||
if ($existing.Status -ne "Stopped") { Stop-Service -Name $ServiceName -Force }
|
||||
sc.exe delete $ServiceName | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
$binPath = '"' + $exePath + '"'
|
||||
sc.exe create $ServiceName binPath= $binPath start= auto DisplayName= "ShopDB Endpoint Agent" | Out-Null
|
||||
sc.exe description $ServiceName "Collects endpoint telemetry for ShopDB and runs allowlisted WebUI commands." | Out-Null
|
||||
sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/5000 | Out-Null
|
||||
|
||||
Start-Service -Name $ServiceName
|
||||
Write-Host " service started."
|
||||
|
||||
# 5. Register the interactive tray app to launch at user logon (per-machine).
|
||||
Write-Host "[5/5] Registering tray app for logon ..."
|
||||
if ($trayInstalled -and (Test-Path $trayInstalled)) {
|
||||
# Register in the 64-bit Run key so 64-bit Explorer fires it at logon. A
|
||||
# 32-bit write would land in WOW6432Node\...\Run, which Explorer ignores.
|
||||
Set-Hklm64Value -SubKey "SOFTWARE\Microsoft\Windows\CurrentVersion\Run" `
|
||||
-Name "ShopDbEndpointTray" -Value ('"' + $trayInstalled + '"')
|
||||
Write-Host " tray app registered to start at logon (Registry64): $trayInstalled"
|
||||
|
||||
# Start it now so the icon appears without waiting for the next logon. The
|
||||
# Run key only fires at logon, so a fresh install would otherwise show no
|
||||
# tray until the operator signs out and back in.
|
||||
#
|
||||
# The installer runs elevated, so a direct Start-Process would launch the
|
||||
# tray ELEVATED too. That is wrong for a tray app (it should run at the
|
||||
# user's medium integrity) and, on EPM-managed hosts (CyberArk), elevating an
|
||||
# unsigned exe triggers a broker prompt that comes back as Win32 1223
|
||||
# "The operation was canceled by the user" - which is exactly what we hit.
|
||||
# Launching through explorer.exe hands the exe to the already-running user
|
||||
# shell, which spawns it de-elevated in the interactive session, sidestepping
|
||||
# the elevation broker entirely.
|
||||
$interactiveUser = $env:USERNAME
|
||||
if ($interactiveUser -and $interactiveUser -ne "SYSTEM") {
|
||||
try {
|
||||
# explorer.exe <path> re-parents the launch under the user shell at
|
||||
# medium integrity; it returns immediately regardless of the tray's
|
||||
# own lifetime, so there is nothing to wait on here.
|
||||
Start-Process -FilePath "explorer.exe" -ArgumentList "`"$trayInstalled`"" | Out-Null
|
||||
Write-Host " tray app started for the current session (de-elevated via explorer)."
|
||||
}
|
||||
catch {
|
||||
Write-Warning " could not start tray now: $($_.Exception.Message); it will start at next logon."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " no interactive user session; tray will start at next logon."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " tray skipped (headless install)."
|
||||
}
|
||||
|
||||
Write-Host "`nInstall complete." -ForegroundColor Green
|
||||
Write-Host "Service '$ServiceName' is running and set to auto-start."
|
||||
Write-Host "Logs: $logDir"
|
||||
119
agent/utilities/shopdb-dellupdates.ps1
Normal file
119
agent/utilities/shopdb-dellupdates.ps1
Normal file
@@ -0,0 +1,119 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
ShopDB agent utility: reports pending Dell Command | Update items.
|
||||
|
||||
.DESCRIPTION
|
||||
Discovered and executed by PowerShellUtilityRunner on the telemetry cadence.
|
||||
Emits a single JSON object on stdout, which the agent attaches to the report
|
||||
as `dellupdates`. Never throws and never writes anything but JSON: a failure
|
||||
here must not break a telemetry run, so every error path still emits a valid
|
||||
object with a `status` explaining why the list is empty.
|
||||
|
||||
THIS SCRIPT DOES NOT SCAN. It only reads the log a previous scan wrote. The
|
||||
scan itself is fired by the \ShopDB-DellCommandScan scheduled task, which is
|
||||
what pins dcu-cli.exe's -outputLog to the path below. Keeping the two apart
|
||||
is deliberate: a telemetry collector that shelled out to dcu-cli would block
|
||||
the cadence for minutes and hammer Dell's servers every 15 minutes.
|
||||
|
||||
OUTPUT CONTRACT (keep in sync with plugins/endpoints/plugin.py):
|
||||
status unknown | ok | error - "unknown" means no scan has ever run,
|
||||
which is NOT the same as "ok" with an empty list (known clean).
|
||||
scannedat ISO-8601 UTC timestamp of the scan log's last write, or null.
|
||||
count total pending updates found.
|
||||
updates up to $MaxUpdates entries of { name, severity, version }.
|
||||
truncated true when count exceeded $MaxUpdates.
|
||||
|
||||
Dell's own long description text is deliberately NOT carried: it is
|
||||
sentence-length marketing prose per update, which would add roughly an order
|
||||
of magnitude to every report for no operational value. Name plus severity is
|
||||
what an operator decides on.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Cap the list rather than the payload. A machine that is years behind can have
|
||||
# dozens of pending updates, and an unbounded list would bloat every report on
|
||||
# the cadence. Mirrors the MaxReportedConnections precedent in CommandExecutor:
|
||||
# a truncated answer is more useful than a rejected payload.
|
||||
$MaxUpdates = 25
|
||||
|
||||
# Must match the -outputLog path set in the \ShopDB-DellCommandScan task
|
||||
# definition. ShopDB-initiated scans write HERE rather than to Dell's default
|
||||
# location, which is what keeps them distinguishable from the corporate-managed
|
||||
# DCU tasks that also run on these machines.
|
||||
$ScanLog = Join-Path $env:ProgramData 'ShopDB\EndpointAgent\dellcommand\scan.xml'
|
||||
|
||||
function Write-Result {
|
||||
param($Status, $ScannedAt, $Count, $Updates, $Truncated)
|
||||
# Compress so the payload carries no pretty-printing whitespace. Depth 3
|
||||
# covers the nested updates array.
|
||||
[pscustomobject]@{
|
||||
status = $Status
|
||||
scannedat = $ScannedAt
|
||||
count = $Count
|
||||
updates = @($Updates)
|
||||
truncated = [bool]$Truncated
|
||||
} | ConvertTo-Json -Depth 3 -Compress
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not (Test-Path -LiteralPath $ScanLog)) {
|
||||
# No scan has run yet. This is the normal state for a freshly adopted
|
||||
# endpoint, not an error, but it must stay distinct from "scanned and
|
||||
# found nothing" or the UI would claim a machine is up to date when
|
||||
# nobody has ever checked.
|
||||
Write-Result -Status 'unknown' -ScannedAt $null -Count 0 -Updates @() -Truncated $false
|
||||
return
|
||||
}
|
||||
|
||||
$logFile = Get-Item -LiteralPath $ScanLog
|
||||
$scannedAt = $logFile.LastWriteTimeUtc.ToString('o')
|
||||
|
||||
[xml]$report = Get-Content -LiteralPath $ScanLog -Raw
|
||||
|
||||
# dcu-cli's scan report shape has varied across DCU versions, so select the
|
||||
# update nodes by name anywhere in the tree rather than by a fixed path.
|
||||
$nodes = @($report.SelectNodes('//update'))
|
||||
if ($nodes.Count -eq 0) {
|
||||
$nodes = @($report.SelectNodes('//Update'))
|
||||
}
|
||||
|
||||
$updates = foreach ($node in $nodes) {
|
||||
# Attribute casing also varies by version; take whichever is present and
|
||||
# fall back rather than emitting a null-filled row.
|
||||
$name = $node.name
|
||||
if (-not $name) { $name = $node.Name }
|
||||
if (-not $name) { $name = $node.display }
|
||||
if (-not $name) { continue }
|
||||
|
||||
$severity = $node.urgency
|
||||
if (-not $severity) { $severity = $node.Urgency }
|
||||
if (-not $severity) { $severity = $node.criticality }
|
||||
|
||||
$version = $node.version
|
||||
if (-not $version) { $version = $node.Version }
|
||||
|
||||
[pscustomobject]@{
|
||||
name = [string]$name
|
||||
severity = if ($severity) { [string]$severity } else { 'unknown' }
|
||||
version = if ($version) { [string]$version } else { $null }
|
||||
}
|
||||
}
|
||||
|
||||
$updates = @($updates)
|
||||
$count = $updates.Count
|
||||
$truncated = $count -gt $MaxUpdates
|
||||
if ($truncated) {
|
||||
$updates = $updates[0..($MaxUpdates - 1)]
|
||||
}
|
||||
|
||||
Write-Result -Status 'ok' -ScannedAt $scannedAt -Count $count `
|
||||
-Updates $updates -Truncated $truncated
|
||||
}
|
||||
catch {
|
||||
# Emit a valid object rather than failing: the runner would log a non-JSON
|
||||
# utility and drop it, which looks identical to "never ran" on the server.
|
||||
# An explicit error status tells an operator the scan log exists but could
|
||||
# not be read, which is a different problem from no scan at all.
|
||||
Write-Result -Status 'error' -ScannedAt $null -Count 0 -Updates @() -Truncated $false
|
||||
}
|
||||
Reference in New Issue
Block a user