Report mapped drives from both Windows sources, worse status wins
Windows exposes mapped drives twice and the two views disagree. Captured on one box at one moment, same two mappings: Get-SmbMapping reported OK for both while net use reported Disconnected and Reconnecting. Collecting only Get-SmbMapping - the nicer, more structured one - would have reported a healthy Q: on a machine whose Q: was down, which is the exact failure this telemetry exists to catch. So collect both and keep them apart. The probe emits netuse and smbmapping as separate keys and does not reconcile them; the disagreement is the signal and normalising it in the agent would discard it before anyone could see it. The server merges by drive letter and shows the WORSE of the two. A drive that either source calls broken is broken for the operator, and a drive neither source gave a status for renders as Unknown rather than blank - an unannotated row reads as healthy, so a failed check would present as a working drive. Drives seen by only one source are kept, not dropped: Get-SmbMapping is absent on some builds and session types, and requiring agreement would silently hide every mapped drive on those machines. No active reachability testing. Windows already knows the status, so touching each share would be wasted work and would reintroduce the hang risk the bounded probe exists to avoid. The probe stays unelevated in the operator's session. UAC token separation gives an elevated context a different mapped-drive table, often empty, so elevating this later would quietly empty the card. Adds a commented-out remap_drive seam in the command catalog. Not implemented, but placed so the next person finds the user-session and elevation constraints before writing it rather than after. The merge logic has no test coverage yet; the docstring names the three behaviours that must not be broken.
This commit is contained in:
@@ -2,8 +2,9 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Runs a short PowerShell probe as the interactive user and returns what it
|
||||
// wrote to the spool.
|
||||
// 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
|
||||
@@ -15,15 +16,18 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// 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 probe is bounded well below the telemetry cadence. If it cannot finish
|
||||
// in this time the cycle simply reports no session data; it never delays the
|
||||
// heartbeat.
|
||||
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(45);
|
||||
|
||||
private const string ProbeScript = """
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$result = [ordered]@{}
|
||||
@@ -51,6 +55,30 @@ public sealed class UserContextProbe
|
||||
}
|
||||
$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 = @{}
|
||||
@@ -113,74 +141,72 @@ public sealed class UserContextProbe
|
||||
exit 0
|
||||
""";
|
||||
|
||||
private readonly UserContextSpool spool;
|
||||
// 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);
|
||||
|
||||
private readonly ILogger<UserContextProbe> logger;
|
||||
|
||||
public UserContextProbe(UserContextSpool spool, ILogger<UserContextProbe> logger)
|
||||
public UserContextProbe(ILogger<UserContextProbe> logger)
|
||||
{
|
||||
this.spool = spool;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
// Returns the probe payload, or null when there is no interactive session or
|
||||
// the probe produced nothing usable. Never throws: session-scoped data is a
|
||||
// nice-to-have and must not be able to fail a telemetry cycle.
|
||||
public Dictionary<string, object?>? TryCollect()
|
||||
// Run the probe in THIS process's session and write the spool file.
|
||||
// Returns true when the probe wrote a result.
|
||||
//
|
||||
// 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()
|
||||
{
|
||||
string? scriptPath = null;
|
||||
try
|
||||
{
|
||||
var resultPath = spool.TryGetResultPath(out var reason);
|
||||
if (resultPath is null)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Skipping user-context collection: {Reason}", reason);
|
||||
return null;
|
||||
}
|
||||
|
||||
spool.EnsureSpoolDirectory(resultPath);
|
||||
spool.ClearStaleResult(resultPath);
|
||||
|
||||
scriptPath = Path.Combine(
|
||||
Path.GetTempPath(), $"shopdb-usercontext-{Guid.NewGuid():N}.ps1");
|
||||
File.WriteAllText(scriptPath, ProbeScript);
|
||||
|
||||
var arguments =
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "
|
||||
+ "\"" + scriptPath + "\"";
|
||||
|
||||
var outcome = InteractiveProcessLauncher.LaunchInActiveSession(
|
||||
"powershell.exe", arguments, Path.GetTempPath(), waitForExit: true,
|
||||
waitTimeout: ProbeTimeout);
|
||||
|
||||
if (!outcome.Launched)
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
logger.LogDebug(
|
||||
"The user-context probe did not launch: {Error}", outcome.Error);
|
||||
return null;
|
||||
FileName = "powershell.exe",
|
||||
Arguments =
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "
|
||||
+ "\"" + scriptPath + "\"",
|
||||
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 (outcome.TimedOut)
|
||||
if (!process.WaitForExit((int)ProbeTimeout.TotalMilliseconds))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context probe did not finish within {Seconds} seconds.",
|
||||
ProbeTimeout.TotalSeconds);
|
||||
return null;
|
||||
TryKill(process);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (outcome.ExitCode is int exit && exit != 0)
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context probe exited with code {ExitCode}.", exit);
|
||||
return null;
|
||||
"The user-context probe exited with code {ExitCode}.", process.ExitCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
return spool.TryConsumeResult(resultPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "The user-context probe failed.");
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -193,10 +219,22 @@ public sealed class UserContextProbe
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A leftover temp script is harmless; it is overwritten by
|
||||
// name on the next cycle and cleaned by the OS.
|
||||
// A leftover temp script is harmless; it is uniquely named
|
||||
// and cleaned by the OS.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user