diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 79e6a5ec..c500d260 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -20,30 +20,28 @@ public sealed class EndpointCollector : ICollector // keep the payload bounded on a chatty Security log. private const int MaxEventsPerChannel = 25; - // The user-context probe spawns a process in the operator's session, so it - // runs on its own slower sub-cadence rather than every telemetry tick. - // Mapped drives change at logon, not minute to minute, and this keeps the - // visible-session cost low on a shop floor PC. - private static readonly TimeSpan UserContextInterval = TimeSpan.FromMinutes(60); + // 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 logger; private readonly AgentConfig config; private readonly IAgentUtilityRunner? utilities; private readonly EventWatermarkStore? watermarks; - private readonly UserContextProbe? userContextProbe; + private readonly UserContextSpool? userContextSpool; - private DateTimeOffset lastUserContextAttempt = DateTimeOffset.MinValue; private object? lastUserContext; public EndpointCollector(ILogger logger, AgentConfig config, IAgentUtilityRunner? utilities = null, EventWatermarkStore? watermarks = null, - UserContextProbe? userContextProbe = null) + UserContextSpool? userContextSpool = null) { this.logger = logger; this.config = config; this.utilities = utilities; this.watermarks = watermarks; - this.userContextProbe = userContextProbe; + this.userContextSpool = userContextSpool; } public EndpointReport Collect(string agentVersion, bool adopt) @@ -75,7 +73,7 @@ public sealed class EndpointCollector : ICollector Domain = GetDomain(), Applications = GetApplications(), PendingReboot = GetPendingReboot(), - UserContext = GetUserContext(adopt), + UserContext = GetUserContext(), }; if (report.LastBootTime is DateTimeOffset boot) @@ -251,23 +249,26 @@ public sealed class EndpointCollector : ICollector return names; } - // Session-scoped data on a slow sub-cadence. Adoption forces a fresh probe so - // a newly enrolled machine reports its mapped drives immediately instead of - // looking bare for the first hour. Between probes the previous result is + // 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". - private object? GetUserContext(bool force) + // 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 (userContextProbe is null) + if (userContextSpool is null) return null; - var due = force - || DateTimeOffset.UtcNow - lastUserContextAttempt >= UserContextInterval; - if (!due) + var resultPath = userContextSpool.TryGetResultPath(out var reason); + if (resultPath is null) + { + logger.LogDebug("Skipping the user-context read: {Reason}", reason); return lastUserContext; + } - lastUserContextAttempt = DateTimeOffset.UtcNow; - var collected = userContextProbe.TryCollect(); + var collected = userContextSpool.TryReadResult(resultPath); if (collected is not null) lastUserContext = collected; diff --git a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs index 36dff117..dfe5a257 100644 --- a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs +++ b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs @@ -3,22 +3,23 @@ using Microsoft.Extensions.Logging; namespace ShopDbEndpointAgent.Core; -// Reads the result file that a user-context probe writes into the signed-in -// user's ShopDB spool folder, then removes it. +// 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 has to run -// as the interactive user, and a process launched with CreateProcessAsUser gives -// back an exit code, not stdout. A file in the user's own profile is the -// handoff, and the user can always write there without elevation. +// 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. // -// Ordering matters here. The stale file is deleted BEFORE the probe runs, not -// after it is read: if the probe fails to start, crashes, or is still running -// when we look, an old file left on disk would be indistinguishable from a fresh -// result and we would report yesterday's mapped drives as current. Deleting up -// front means a file that exists at read time was necessarily produced by this -// cycle. +// 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"; @@ -28,6 +29,17 @@ public sealed class UserContextSpool // larger than this is either corrupt or hostile, and we will not parse it. private const long MaxResultBytes = 512 * 1024; + // A result older than this is treated as absent. Sized against the server's + // default quiet-report window (endpoints_quietreporthours, 24h): an endpoint + // that has not reported inside that window is already shown as 'quiet', so + // session data that outlived it would be claiming freshness the rest of the + // page no longer claims. Well clear of the tray's collection interval, so an + // ordinary missed run does not blank the card. + // + // Not read from the server: this is an offline-safe local bound, and the + // agent must retire stale session data even when it cannot reach ShopDB. + private static readonly TimeSpan MaxResultAge = TimeSpan.FromHours(24); + private readonly ILogger logger; public UserContextSpool(ILogger logger) @@ -52,8 +64,8 @@ public sealed class UserContextSpool return Path.Combine(profileDirectory, SpoolFolderName, ResultFileName); } - // Remove any result file left over from a previous cycle. Call this before - // launching the probe so a stale file can never be mistaken for a fresh one. + // 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 @@ -91,10 +103,11 @@ public sealed class UserContextSpool } } - // Read and consume the probe result. Returns null when the probe produced - // nothing, which the caller reports as "no session data this cycle" rather - // than as empty data - absent and empty mean different things here. - public Dictionary? TryConsumeResult(string resultPath) + // 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? TryReadResult(string resultPath) { try { @@ -125,6 +138,9 @@ public sealed class UserContextSpool if (parsed is null || parsed.Count == 0) return null; + if (IsExpired(parsed, resultPath)) + return null; + return parsed; } catch (JsonException exception) @@ -141,9 +157,35 @@ public sealed class UserContextSpool "Could not read the user-context spool file at {Path}", resultPath); return null; } - finally + } + + // 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 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)) { - ClearStaleResult(resultPath); + 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; } } diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 322eb69c..21b6d833 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -8,11 +8,20 @@ true ShopDbEndpointAgent.Tray shopdb-endpoint-tray + - 1.0.0 + 1.1.0 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index f6b80d72..54eb43f3 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -25,6 +25,25 @@ 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); + + private readonly System.Windows.Forms.Timer userContextTimer; + private bool userContextStarted; + private bool collectingUserContext; + public TrayContext() { var menu = new ContextMenuStrip(); @@ -45,6 +64,47 @@ internal sealed class TrayContext : ApplicationContext ContextMenuStrip = menu, }; trayIcon.DoubleClick += OnSendStatus; + + userContextTimer = new System.Windows.Forms.Timer + { + Interval = (int)UserContextStartupDelay.TotalMilliseconds, + }; + userContextTimer.Tick += OnUserContextTimer; + userContextTimer.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; + } + + if (collectingUserContext) + return; + + var agentExe = ResolveAgentExe(); + if (agentExe is null) + return; + + collectingUserContext = true; + try + { + await RunAgentAsync(agentExe, "--collect-usercontext"); + } + catch + { + // Intentionally silent. The agent logs the detail to its own file. + } + finally + { + collectingUserContext = false; + } } private async void OnReportIssue(object? sender, EventArgs e) @@ -316,6 +376,8 @@ internal sealed class TrayContext : ApplicationContext { if (disposing) { + userContextTimer.Stop(); + userContextTimer.Dispose(); trayIcon.Dispose(); brandIcon.Dispose(); } diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index efb1d67c..daa6f249 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -22,6 +22,8 @@ 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() @@ -41,6 +43,20 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath) ? FileLoggerProvider.DefaultDirectory() : config.LogPath; +// Deliberately ahead of the collector config gate. This verb only writes a file +// into the caller's own profile and never talks to the server, so it must still +// work on a machine whose CollectorKey has not been provisioned yet - otherwise +// session data would be missing on exactly the endpoints that are hardest to +// diagnose. +if (collectUserContext) +{ + using var probeLoggerFactory = LoggerFactory.Create(logging => + logging.AddProvider(new FileLoggerProvider(logDirectory))); + var probe = new UserContextProbe( + probeLoggerFactory.CreateLogger()); + return probe.Collect() ? 0 : 1; +} + if (!config.IsUsable()) { bootstrapLogger.LogError( @@ -198,13 +214,12 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config sp.GetRequiredService>())); // Session-scoped collection. The agent is LocalSystem, so mapped drives and - // user-owned listeners are invisible to it; the probe runs a short script in - // the operator's session and hands the result back through a spool file. + // user-owned listeners are invisible to it. The tray, which already runs as + // the operator, writes the spool file; the service only reads it. services.AddSingleton(sp => new UserContextSpool( sp.GetRequiredService>())); services.AddSingleton(sp => new UserContextProbe( - sp.GetRequiredService(), sp.GetRequiredService>())); services.AddSingleton(sp => new EndpointCollector( @@ -212,7 +227,7 @@ static void RegisterCoreServices(IServiceCollection services, AgentConfig config config, sp.GetService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService())); services.AddSingleton(sp => new CollectorClient( config, sp.GetRequiredService>())); diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index a4bb5b59..374c36a7 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,6 +7,18 @@ enable ShopDbEndpointAgent shopdbagent + - 1.7.0 + 1.8.0 true win-x64 true