Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
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
|
||||
|
||||
@@ -13,10 +13,40 @@ public sealed class AgentConfig
|
||||
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
|
||||
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
|
||||
|
||||
// Legacy pin: SHA-1 thumbprint of the server leaf certificate (hex, no
|
||||
// separators). Honoured with serverpins only while the registry ServerPins
|
||||
// value is absent; breaks on every cert renewal, so prefer an SPKI pin.
|
||||
// See AgentHttpFactory.
|
||||
[JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; }
|
||||
|
||||
// Server public key pins: lowercase hex SHA-256 of the leaf certificate's
|
||||
// SubjectPublicKeyInfo. Fallback only: HKLM\SOFTWARE\GE\ShopDB ServerPins
|
||||
// (REG_MULTI_SZ), when it exists in either view, replaces these entirely,
|
||||
// even when empty. Any pin in effect = the leaf key must match one; chain
|
||||
// and name errors are then ignored. None = normal Windows chain validation.
|
||||
[JsonPropertyName("serverpins")] public List<string> ServerPins { get; set; } = new();
|
||||
|
||||
// Jitter spreads a fleet's reports so they do not arrive in lockstep
|
||||
// (thundering herd). Actual delay = cadence +/- up to jitterseconds.
|
||||
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;
|
||||
|
||||
// One-time random delay before FIRST contact, applied by both the telemetry
|
||||
// and command loops at startup. JitterSeconds only spreads the gap BETWEEN
|
||||
// runs, so without this every agent in the fleet still makes its first
|
||||
// request the instant its service starts - and the service is installed
|
||||
// start=auto, so a patch reboot starts them all together.
|
||||
//
|
||||
// Measured on a 700-endpoint burst (devnotes/gevent-sizing-simulation.md):
|
||||
// simultaneous arrival drove TLS handshakes to 1482ms worst case and pinned
|
||||
// CPU at 305% while the database sat idle at 3 active queries. Spreading the
|
||||
// same arrivals over 30s cut worst-case handshake to 63.7ms. The server is
|
||||
// not the constraint; simultaneity is.
|
||||
//
|
||||
// Costs up to this many seconds before a rebooted endpoint picks up a queued
|
||||
// command. That window is on top of a reboot the machine was already
|
||||
// unreachable for. 0 disables the delay.
|
||||
[JsonPropertyName("startupjitterseconds")] public int StartupJitterSeconds { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName("adoptonfirstrun")] public bool AdoptOnFirstRun { get; set; } = true;
|
||||
|
||||
// Directory holding site-provided PowerShell "agent utilities" the runtime
|
||||
@@ -89,42 +119,52 @@ 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)
|
||||
&& !string.IsNullOrWhiteSpace(ApiKey)
|
||||
=> !string.IsNullOrWhiteSpace(BaseUrl) && HasSharedKey();
|
||||
|
||||
// True when a real shared collector key is provisioned (not the shipped
|
||||
// placeholder). An enrolled agent runs without one.
|
||||
public bool HasSharedKey()
|
||||
=> !string.IsNullOrWhiteSpace(ApiKey)
|
||||
&& ApiKey != "REPLACE_WITH_COLLECTOR_KEY";
|
||||
|
||||
// Validates the BaseUrl scheme before any request is made. A plaintext
|
||||
|
||||
269
agent/ShopDbEndpointAgent.Core/AgentCredentials.cs
Normal file
269
agent/ShopDbEndpointAgent.Core/AgentCredentials.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// This PC's own endpoint credential, issued once by POST /api/endpoints/enroll.
|
||||
// Readable only by SYSTEM and Administrators (DPAPI LocalMachine inside a
|
||||
// protected directory). The JSON key names are a contract: SYSTEM PowerShell
|
||||
// (upgrade-shopdbagent.ps1, the reporters) reads the same file with
|
||||
// [System.Security.Cryptography.ProtectedData]::Unprotect(bytes, $null, 'LocalMachine').
|
||||
public sealed class AgentCredential
|
||||
{
|
||||
[JsonPropertyName("credential")] public string Credential { get; set; } = "";
|
||||
[JsonPropertyName("assetid")] public int AssetId { get; set; }
|
||||
[JsonPropertyName("issuedat")] public string IssuedAt { get; set; } = "";
|
||||
[JsonPropertyName("baseurl")] public string BaseUrl { get; set; } = "";
|
||||
|
||||
// First 12 chars, the same prefix the server shows. Never log more.
|
||||
[JsonIgnore]
|
||||
public string Prefix => Credential.Length <= 12 ? Credential : Credential[..12];
|
||||
}
|
||||
|
||||
// Why the server refused an agent request. Parsed from the error envelope.
|
||||
public static class AgentAuthErrorCodes
|
||||
{
|
||||
public const string CredentialInvalid = "CREDENTIAL_INVALID";
|
||||
public const string SharedKeyRefused = "SHARED_KEY_REFUSED";
|
||||
public const string AssetMismatch = "ASSET_MISMATCH";
|
||||
}
|
||||
|
||||
// Current auth material for outbound requests. Swaps at runtime: enrollment
|
||||
// sets a credential, a CREDENTIAL_INVALID clears it, neither needs a restart.
|
||||
public interface ICredentialProvider
|
||||
{
|
||||
// Null = not enrolled (or the stored credential belongs to another server).
|
||||
AgentCredential? Current { get; }
|
||||
|
||||
// Shared fleet collector key, null when not provisioned.
|
||||
string? SharedKey { get; }
|
||||
|
||||
// Raised after Current changes (set or cleared).
|
||||
event EventHandler? Changed;
|
||||
|
||||
// Raised when the enrollment service should run now.
|
||||
event EventHandler? EnrollmentNeeded;
|
||||
|
||||
// Called by the auth handler for every 401/403 carrying an auth code.
|
||||
// sentCredential is the credential the failed request carried, or null
|
||||
// when it went out on the shared key.
|
||||
void ReportRejected(string code, string? sentCredential);
|
||||
}
|
||||
|
||||
public sealed class AgentCredentialProvider : ICredentialProvider
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly CredentialStore? store;
|
||||
private readonly ILogger logger;
|
||||
private AgentCredential? current;
|
||||
|
||||
public AgentCredentialProvider(AgentConfig config, CredentialStore? store, ILogger logger)
|
||||
{
|
||||
this.store = store;
|
||||
this.logger = logger;
|
||||
SharedKey = config.HasSharedKey() ? config.ApiKey : null;
|
||||
|
||||
var loaded = store?.TryLoad();
|
||||
if (loaded is not null && !SameServer(loaded.BaseUrl, config.BaseUrl))
|
||||
{
|
||||
// Never send a credential to a server that did not issue it. Kept on
|
||||
// disk so pointing BaseUrl back restores it; a new enroll overwrites.
|
||||
logger.LogWarning(
|
||||
"Stored credential {Prefix} was issued by {Issuer}, not {BaseUrl}; not using it.",
|
||||
loaded.Prefix, loaded.BaseUrl, config.BaseUrl);
|
||||
loaded = null;
|
||||
}
|
||||
current = loaded;
|
||||
if (current is not null)
|
||||
logger.LogInformation("Using endpoint credential {Prefix} (asset {AssetId}).",
|
||||
current.Prefix, current.AssetId);
|
||||
}
|
||||
|
||||
public AgentCredential? Current
|
||||
{
|
||||
get { lock (gate) return current; }
|
||||
}
|
||||
|
||||
public string? SharedKey { get; }
|
||||
|
||||
public event EventHandler? Changed;
|
||||
public event EventHandler? EnrollmentNeeded;
|
||||
|
||||
// Persist then swap. Throws when the store cannot be written: a credential
|
||||
// held only in memory is lost at restart and the server will not reissue.
|
||||
public void Set(AgentCredential credential)
|
||||
{
|
||||
store?.Save(credential);
|
||||
lock (gate) current = credential;
|
||||
logger.LogInformation("Endpoint credential {Prefix} stored (asset {AssetId}).",
|
||||
credential.Prefix, credential.AssetId);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void ReportRejected(string code, string? sentCredential)
|
||||
{
|
||||
if (code == AgentAuthErrorCodes.CredentialInvalid && sentCredential is not null)
|
||||
{
|
||||
bool cleared = false;
|
||||
lock (gate)
|
||||
{
|
||||
// Only the credential that was refused. A late response for an
|
||||
// old credential must not wipe a freshly issued one.
|
||||
if (current is not null && current.Credential == sentCredential)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Server refused credential {Prefix} (revoked or unknown); deleting it and re-enrolling.",
|
||||
current.Prefix);
|
||||
current = null;
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
if (cleared)
|
||||
{
|
||||
try
|
||||
{
|
||||
store?.Delete();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Could not delete the refused credential file.");
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
EnrollmentNeeded?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
else if (code == AgentAuthErrorCodes.SharedKeyRefused && sentCredential is null && Current is null)
|
||||
{
|
||||
logger.LogWarning("Server refused the shared key for this PC; requesting enrollment.");
|
||||
EnrollmentNeeded?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
else if (code == AgentAuthErrorCodes.AssetMismatch)
|
||||
{
|
||||
logger.LogError(
|
||||
"Server reported ASSET_MISMATCH: this PC's credential belongs to a different asset than the request named.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameServer(string issued, string configured)
|
||||
=> string.Equals(Normalize(issued), Normalize(configured), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string Normalize(string url) => (url ?? "").Trim().TrimEnd('/');
|
||||
}
|
||||
|
||||
// credential.bin and enroll.json under %ProgramData%\ShopDB\EndpointAgent\credential.
|
||||
public sealed class CredentialStore
|
||||
{
|
||||
public const string DirectoryName = "credential";
|
||||
public const string CredentialFileName = "credential.bin";
|
||||
public const string EnrollFileName = "enroll.json";
|
||||
|
||||
private const int MaxFileBytes = 64 * 1024;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly ILogger logger;
|
||||
|
||||
public CredentialStore(ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private static string StoreDirectory => ProtectedFileSystem.EnsureDirectory(DirectoryName);
|
||||
|
||||
public AgentCredential? TryLoad()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(StoreDirectory, CredentialFileName);
|
||||
if (ProtectedFileSystem.TryGetAttributes(path) is null)
|
||||
return null;
|
||||
|
||||
var blob = ReadAllBytesNoFollow(path);
|
||||
var json = ProtectedData.Unprotect(blob, null, DataProtectionScope.LocalMachine);
|
||||
var credential = JsonSerializer.Deserialize<AgentCredential>(json, JsonOptions);
|
||||
if (credential is null || !credential.Credential.StartsWith("sdbe_", StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogWarning("Stored credential file is not a usable credential; ignoring it.");
|
||||
return null;
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Could not read the stored endpoint credential; running unenrolled.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(AgentCredential credential)
|
||||
{
|
||||
var dir = StoreDirectory;
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(credential);
|
||||
var blob = ProtectedData.Protect(json, null, DataProtectionScope.LocalMachine);
|
||||
var target = Path.Combine(dir, CredentialFileName);
|
||||
var temp = Path.Combine(dir, CredentialFileName + ".new");
|
||||
using (var file = ProtectedFileSystem.CreateFile(temp))
|
||||
{
|
||||
file.Write(blob);
|
||||
file.Flush(flushToDisk: true);
|
||||
}
|
||||
// Moved file keeps its protected DACL.
|
||||
File.Move(temp, target, overwrite: true);
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
=> ProtectedFileSystem.DeleteFileNoFollow(Path.Combine(StoreDirectory, CredentialFileName));
|
||||
|
||||
// The enroll nonce is generated once and kept for the life of the PC, so
|
||||
// an approved request can only be claimed by the agent that made it.
|
||||
public string GetOrCreateEnrollNonce()
|
||||
{
|
||||
var path = Path.Combine(StoreDirectory, EnrollFileName);
|
||||
if (ProtectedFileSystem.TryGetAttributes(path) is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(ReadAllBytesNoFollow(path));
|
||||
if (doc.RootElement.TryGetProperty("enrollnonce", out var value)
|
||||
&& value.GetString() is string nonce && nonce.Length >= 32)
|
||||
return nonce;
|
||||
logger.LogWarning("enroll.json has no usable nonce; generating a new one.");
|
||||
}
|
||||
catch (Exception error) when (error is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogWarning(error, "enroll.json unreadable; generating a new nonce.");
|
||||
}
|
||||
}
|
||||
|
||||
var fresh = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var body = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
enrollnonce = fresh,
|
||||
createdat = DateTime.UtcNow.ToString("o"),
|
||||
});
|
||||
using (var file = ProtectedFileSystem.CreateFile(path))
|
||||
{
|
||||
file.Write(body);
|
||||
file.Flush(flushToDisk: true);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
internal static string Base64Url(byte[] bytes)
|
||||
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private static byte[] ReadAllBytesNoFollow(string path)
|
||||
{
|
||||
using var stream = ProtectedFileSystem.OpenReadNoFollow(path);
|
||||
if (stream.Length > MaxFileBytes)
|
||||
throw new InvalidDataException($"{path} is larger than expected");
|
||||
using var buffer = new MemoryStream();
|
||||
stream.CopyTo(buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
}
|
||||
387
agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs
Normal file
387
agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs
Normal file
@@ -0,0 +1,387 @@
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// What an HttpClient is for. Decides pooling, timeout and auth.
|
||||
public enum AgentHttpChannel
|
||||
{
|
||||
Collector,
|
||||
Command,
|
||||
FleetAlert,
|
||||
Download,
|
||||
Enroll,
|
||||
}
|
||||
|
||||
// The ONLY place the agent builds an HttpClient or a handler. Every channel gets
|
||||
// the same server certificate check (ServerPinSet) and the same per-request auth
|
||||
// (AgentAuthHandler), so a new channel cannot forget either.
|
||||
public sealed class AgentHttpFactory
|
||||
{
|
||||
private readonly AgentConfig config;
|
||||
private readonly ICredentialProvider credentials;
|
||||
private readonly ILogger logger;
|
||||
private readonly ServerPinSet pins;
|
||||
|
||||
public AgentHttpFactory(AgentConfig config, ICredentialProvider credentials, ILogger logger)
|
||||
{
|
||||
this.config = config;
|
||||
this.credentials = credentials;
|
||||
this.logger = logger;
|
||||
pins = ServerPinSet.Load(config, logger);
|
||||
}
|
||||
|
||||
public ICredentialProvider Credentials => credentials;
|
||||
|
||||
// Caller owns and disposes the client (it disposes the handler chain).
|
||||
public HttpClient CreateClient(AgentHttpChannel channel, TimeSpan? timeout = null)
|
||||
{
|
||||
// Lifetime cap: pooled TLS sessions were validated against the pins of
|
||||
// their handshake, so recycle them for a pin change to reach new ones.
|
||||
var sockets = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (_, cert, _, errors)
|
||||
=> pins.Validate(cert, errors, channel),
|
||||
},
|
||||
};
|
||||
|
||||
switch (channel)
|
||||
{
|
||||
case AgentHttpChannel.Command:
|
||||
// Long-poll: retire pooled TLS connections faster than the
|
||||
// server keep-alive, or a reused dead socket yields "unexpected
|
||||
// EOF or 0 bytes from the transport stream" (1.6.2).
|
||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1);
|
||||
break;
|
||||
case AgentHttpChannel.FleetAlert:
|
||||
sockets.PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90);
|
||||
break;
|
||||
}
|
||||
|
||||
var auth = new AgentAuthHandler(credentials, logger,
|
||||
sharedKeyOnly: channel == AgentHttpChannel.Enroll)
|
||||
{
|
||||
InnerHandler = sockets,
|
||||
};
|
||||
|
||||
return new HttpClient(auth, disposeHandler: true)
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = timeout ?? DefaultTimeout(channel),
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeSpan DefaultTimeout(AgentHttpChannel channel) => channel switch
|
||||
{
|
||||
// Claim is held open server-side (~25s); clear that plus slack.
|
||||
AgentHttpChannel.Command => TimeSpan.FromSeconds(60),
|
||||
AgentHttpChannel.Download => TimeSpan.FromMinutes(10),
|
||||
_ => TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
|
||||
// Adds exactly one auth header per request, read at send time so a credential
|
||||
// issued or revoked mid-run takes effect on the next request. Watches 401/403
|
||||
// replies for the auth error codes and reports them to the provider.
|
||||
public sealed class AgentAuthHandler : DelegatingHandler
|
||||
{
|
||||
public const string CredentialHeader = "X-Endpoint-Credential";
|
||||
public const string SharedKeyHeader = "X-API-Key";
|
||||
|
||||
private readonly ICredentialProvider credentials;
|
||||
private readonly ILogger logger;
|
||||
private readonly bool sharedKeyOnly;
|
||||
|
||||
public AgentAuthHandler(ICredentialProvider credentials, ILogger logger, bool sharedKeyOnly = false)
|
||||
{
|
||||
this.credentials = credentials;
|
||||
this.logger = logger;
|
||||
this.sharedKeyOnly = sharedKeyOnly;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.Headers.Remove(CredentialHeader);
|
||||
request.Headers.Remove(SharedKeyHeader);
|
||||
|
||||
var credential = sharedKeyOnly ? null : credentials.Current?.Credential;
|
||||
if (credential is not null)
|
||||
request.Headers.TryAddWithoutValidation(CredentialHeader, credential);
|
||||
else if (credentials.SharedKey is string sharedKey)
|
||||
request.Headers.TryAddWithoutValidation(SharedKeyHeader, sharedKey);
|
||||
|
||||
var response = await base.SendAsync(request, cancellationToken);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Buffered so the caller can still read the body.
|
||||
await response.Content.LoadIntoBufferAsync();
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var code = AgentServerError.TryParse(body)?.Code;
|
||||
if (!string.IsNullOrEmpty(code))
|
||||
credentials.ReportRejected(code, credential);
|
||||
}
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogDebug(error, "Could not read an auth failure body");
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// The server's error envelope: {"status":"error","data":{"error":{"code","message"}}}.
|
||||
public sealed record AgentServerError(string Code, string Message)
|
||||
{
|
||||
public static AgentServerError? TryParse(string? body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
|
||||
root = data;
|
||||
if (!root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
var code = error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String
|
||||
? c.GetString() ?? "" : "";
|
||||
var message = error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String
|
||||
? m.GetString() ?? "" : "";
|
||||
return new AgentServerError(code, message);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server certificate trust. Pins are hex SHA-256 of the leaf SubjectPublicKeyInfo.
|
||||
// HKLM\SOFTWARE\GE\ShopDB ServerPins is AUTHORITATIVE whenever the value exists
|
||||
// in either view, even empty (empty = no pins, Windows chain validation), so a
|
||||
// removed or rotated-out pin really goes away. Only when the value is absent in
|
||||
// both views do appsettings serverpins and the legacy SHA-1 servercertthumbprint
|
||||
// apply. With any pin in effect only a matching leaf is accepted and chain/name
|
||||
// errors are ignored (self-signed servers on the air gap). Without pins:
|
||||
// SslPolicyErrors.None. Registry re-read at most every RefreshInterval on the
|
||||
// handshake path, so a GE-Enforce fix or a rotation lands without a restart.
|
||||
public sealed class ServerPinSet
|
||||
{
|
||||
public const string RegistryValue = "ServerPins";
|
||||
public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
// Present = the value exists in at least one view.
|
||||
private sealed record RegistryRead(bool Present, List<string> Values)
|
||||
{
|
||||
public string Raw => Present ? "registry:" + string.Join("|", Values) : "absent";
|
||||
}
|
||||
|
||||
private sealed record Snapshot(HashSet<string> SpkiPins, string? LegacyThumbprint, bool FromRegistry, string RawRegistry)
|
||||
{
|
||||
public string Signature => (FromRegistry ? "registry:" : "appsettings:")
|
||||
+ string.Join(",", SpkiPins.OrderBy(p => p, StringComparer.Ordinal))
|
||||
+ (LegacyThumbprint is null ? "" : "+legacy");
|
||||
public bool Pinned => SpkiPins.Count > 0 || LegacyThumbprint is not null;
|
||||
public string Source => FromRegistry ? "registry" : "appsettings.json";
|
||||
}
|
||||
|
||||
private readonly HashSet<string> configPins;
|
||||
private readonly string? legacyThumbprint;
|
||||
private readonly ILogger logger;
|
||||
private readonly Func<bool, RegistryRead?> readRegistry;
|
||||
private readonly object refreshLock = new();
|
||||
private volatile Snapshot current;
|
||||
private long nextRefreshTicks;
|
||||
|
||||
private ServerPinSet(HashSet<string> configPins, string? legacyThumbprint, ILogger logger,
|
||||
Func<bool, RegistryRead?> readRegistry)
|
||||
{
|
||||
this.configPins = configPins;
|
||||
this.legacyThumbprint = legacyThumbprint;
|
||||
this.logger = logger;
|
||||
this.readRegistry = readRegistry;
|
||||
current = Build(readRegistry(false) ?? new RegistryRead(false, new List<string>()));
|
||||
nextRefreshTicks = Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds;
|
||||
}
|
||||
|
||||
public bool IsPinned => Current().Pinned;
|
||||
|
||||
public static ServerPinSet Load(AgentConfig config, ILogger logger)
|
||||
{
|
||||
var pins = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var pin in config.ServerPins ?? new List<string>())
|
||||
AddPin(pins, pin, "appsettings.json", logger);
|
||||
|
||||
string? thumbprint = null;
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
thumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "").Trim().ToUpperInvariant();
|
||||
|
||||
var set = new ServerPinSet(pins, thumbprint, logger, refresh => ReadRegistryPins(logger, refresh));
|
||||
var snapshot = set.current;
|
||||
logger.LogInformation(
|
||||
"Server certificate trust from {Source}: {Pins} SPKI pin(s){Legacy}; {Mode}.",
|
||||
snapshot.Source, snapshot.SpkiPins.Count,
|
||||
snapshot.LegacyThumbprint is null ? "" : " + legacy thumbprint",
|
||||
snapshot.Pinned ? "pinned" : "Windows chain validation");
|
||||
if (snapshot.FromRegistry && (pins.Count > 0 || thumbprint is not null))
|
||||
logger.LogInformation(
|
||||
"Registry {Value} is set; ignoring appsettings.json serverpins/servercertthumbprint.", RegistryValue);
|
||||
return set;
|
||||
}
|
||||
|
||||
// Cheap tick compare per handshake; registry read only when the window lapsed.
|
||||
private Snapshot Current()
|
||||
{
|
||||
if (Environment.TickCount64 < Interlocked.Read(ref nextRefreshTicks))
|
||||
return current;
|
||||
lock (refreshLock)
|
||||
{
|
||||
if (Environment.TickCount64 < nextRefreshTicks)
|
||||
return current;
|
||||
var read = readRegistry(true);
|
||||
Interlocked.Exchange(ref nextRefreshTicks,
|
||||
Environment.TickCount64 + (long)RefreshInterval.TotalMilliseconds);
|
||||
var previous = current;
|
||||
if (read is null || read.Raw == previous.RawRegistry)
|
||||
return previous;
|
||||
|
||||
var next = Build(read);
|
||||
current = next;
|
||||
if (next.Signature != previous.Signature)
|
||||
logger.LogInformation(
|
||||
"Server pins changed, source {Source}: {Count} SPKI pin(s) [{Pins}]{Legacy}; {Mode}.",
|
||||
next.Source,
|
||||
next.SpkiPins.Count,
|
||||
string.Join(", ", next.SpkiPins.OrderBy(p => p, StringComparer.Ordinal).Select(p => p[..12])),
|
||||
next.LegacyThumbprint is null ? "" : " + legacy thumbprint",
|
||||
next.Pinned ? "pinned" : "Windows chain validation");
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
// Malformed-pin warnings only fire when the raw registry text changes.
|
||||
private Snapshot Build(RegistryRead read)
|
||||
{
|
||||
if (!read.Present)
|
||||
return new Snapshot(new HashSet<string>(configPins, StringComparer.Ordinal),
|
||||
legacyThumbprint, FromRegistry: false, read.Raw);
|
||||
|
||||
var pins = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var pin in read.Values)
|
||||
AddPin(pins, pin, "registry", logger);
|
||||
return new Snapshot(pins, LegacyThumbprint: null, FromRegistry: true, read.Raw);
|
||||
}
|
||||
|
||||
public bool Validate(X509Certificate? certificate, SslPolicyErrors errors, AgentHttpChannel channel)
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
logger.LogWarning("Server provided no certificate ({Channel})", channel);
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshot = Current();
|
||||
if (!snapshot.Pinned)
|
||||
{
|
||||
if (errors == SslPolicyErrors.None)
|
||||
return true;
|
||||
logger.LogWarning(
|
||||
"Server certificate rejected on {Channel} channel: {Errors} ({Subject})",
|
||||
channel, errors, certificate.Subject);
|
||||
return false;
|
||||
}
|
||||
|
||||
var owned = certificate as X509Certificate2;
|
||||
var leaf = owned ?? new X509Certificate2(certificate);
|
||||
try
|
||||
{
|
||||
var spki = SpkiSha256(leaf);
|
||||
if (snapshot.SpkiPins.Contains(spki))
|
||||
return true;
|
||||
if (snapshot.LegacyThumbprint is not null
|
||||
&& string.Equals(leaf.GetCertHashString(), snapshot.LegacyThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
logger.LogError(
|
||||
"Server certificate does not match any pin on {Channel} channel: spki {Spki} ({Subject})",
|
||||
channel, spki, leaf.Subject);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (owned is null)
|
||||
leaf.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static string SpkiSha256(X509Certificate2 certificate)
|
||||
=> Convert.ToHexString(SHA256.HashData(certificate.PublicKey.ExportSubjectPublicKeyInfo()))
|
||||
.ToLowerInvariant();
|
||||
|
||||
private static void AddPin(HashSet<string> pins, string? raw, string source, ILogger logger)
|
||||
{
|
||||
var pin = (raw ?? "").Replace(":", "").Replace(" ", "").Trim().ToLowerInvariant();
|
||||
if (pin.Length == 0)
|
||||
return;
|
||||
if (pin.Length != 64 || !pin.All(Uri.IsHexDigit))
|
||||
{
|
||||
logger.LogWarning("Ignoring malformed server pin from {Source}: not 64 hex chars", source);
|
||||
return;
|
||||
}
|
||||
pins.Add(pin);
|
||||
}
|
||||
|
||||
// Startup keeps whatever views read. A refresh returns null on any failed view
|
||||
// so a transient denial cannot silently swap the pin source (and the server with it).
|
||||
private static RegistryRead? ReadRegistryPins(ILogger logger, bool refresh)
|
||||
{
|
||||
var present = false;
|
||||
var found = new List<string>();
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
|
||||
using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath);
|
||||
var value = key?.GetValue(RegistryValue);
|
||||
if (value is not null)
|
||||
present = true;
|
||||
switch (value)
|
||||
{
|
||||
case string[] many:
|
||||
found.AddRange(many);
|
||||
break;
|
||||
case string one:
|
||||
found.AddRange(one.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
if (refresh)
|
||||
{
|
||||
logger.LogDebug(error, "Registry re-read of {Value} in {View} view failed; keeping pins", RegistryValue, view);
|
||||
return null;
|
||||
}
|
||||
logger.LogWarning(error, "Registry read of {Value} in {View} view failed", RegistryValue, view);
|
||||
}
|
||||
}
|
||||
return new RegistryRead(present, found);
|
||||
}
|
||||
}
|
||||
180
agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs
Normal file
180
agent/ShopDbEndpointAgent.Core/AgentPipeClient.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Wire constants for the local agent pipe. One UTF-8 JSON object per line:
|
||||
// request, response, close.
|
||||
public static class AgentPipeProtocol
|
||||
{
|
||||
public const string PipeName = "ShopDB.EndpointAgent.v1";
|
||||
public const int MaxRequestBytes = 64 * 1024;
|
||||
public const int MaxResponseBytes = 4 * 1024 * 1024;
|
||||
public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(3);
|
||||
public static readonly TimeSpan RequestReadTimeout = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
// Ok/Error as the agent answered. Data is the whole response object (ping
|
||||
// fields such as agentversion/enrolled/assetid live there). Data is null when
|
||||
// the agent could not be reached or answered garbage; Error then says why.
|
||||
public sealed record PipeResponse(bool Ok, string? Error, JsonElement? Data);
|
||||
|
||||
public sealed class AgentPipeException : Exception
|
||||
{
|
||||
public AgentPipeException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AgentPipeException(string message, Exception inner) : base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Client for the SYSTEM agent's local pipe. Used by the tray (which holds no
|
||||
// secret) and by shopdbagent.exe --once/--report when not running as SYSTEM.
|
||||
// Only GetAlertsAsync throws; the others report failure through PipeResponse.
|
||||
public sealed class AgentPipeClient
|
||||
{
|
||||
// Send Status waits on the user-context refresh plus the upload retries.
|
||||
private static readonly TimeSpan LongOperationTimeout = TimeSpan.FromMinutes(3);
|
||||
private static readonly TimeSpan ShortOperationTimeout = TimeSpan.FromSeconds(45);
|
||||
|
||||
private static readonly JsonSerializerOptions AlertOptions = new()
|
||||
{
|
||||
Converters = { new TolerantFleetAlertListConverter() },
|
||||
};
|
||||
|
||||
public Task<PipeResponse> PingAsync(CancellationToken cancellationToken)
|
||||
=> SendSafeAsync(new { op = "ping" }, ShortOperationTimeout, cancellationToken);
|
||||
|
||||
public Task<PipeResponse> SendStatusAsync(CancellationToken cancellationToken)
|
||||
=> SendSafeAsync(new { op = "sendstatus" }, LongOperationTimeout, cancellationToken);
|
||||
|
||||
public Task<PipeResponse> SendReportAsync(string spoolPath, CancellationToken cancellationToken)
|
||||
=> SendSafeAsync(new { op = "sendreport", spoolpath = spoolPath }, LongOperationTimeout,
|
||||
cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<FleetAlert>> GetAlertsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await SendAsync(new { op = "alerts" }, ShortOperationTimeout, cancellationToken);
|
||||
if (!response.Ok)
|
||||
throw new AgentPipeException(response.Error ?? "agent refused the alerts request");
|
||||
|
||||
if (response.Data is not JsonElement data
|
||||
|| !data.TryGetProperty("alerts", out var alerts)
|
||||
|| alerts.ValueKind != JsonValueKind.Array)
|
||||
throw new AgentPipeException("agent response has no alerts array");
|
||||
|
||||
try
|
||||
{
|
||||
return alerts.Deserialize<List<FleetAlert>>(AlertOptions) ?? new List<FleetAlert>();
|
||||
}
|
||||
catch (JsonException error)
|
||||
{
|
||||
throw new AgentPipeException("agent alerts could not be parsed", error);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PipeResponse> AckAlertAsync(int notificationId, CancellationToken cancellationToken)
|
||||
=> SendSafeAsync(new { op = "ackalert", notificationid = notificationId },
|
||||
ShortOperationTimeout, cancellationToken);
|
||||
|
||||
private static async Task<PipeResponse> SendSafeAsync(
|
||||
object request, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await SendAsync(request, timeout, cancellationToken);
|
||||
}
|
||||
catch (AgentPipeException error)
|
||||
{
|
||||
return new PipeResponse(false, error.Message, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Throws AgentPipeException on any transport or framing failure, and
|
||||
// OperationCanceledException only when the caller cancelled.
|
||||
private static async Task<PipeResponse> SendAsync(
|
||||
object request, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
var token = timeoutSource.Token;
|
||||
|
||||
try
|
||||
{
|
||||
// Identification: the agent may learn who we are, never act as us.
|
||||
await using var pipe = new NamedPipeClientStream(".", AgentPipeProtocol.PipeName,
|
||||
PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.Identification);
|
||||
|
||||
try
|
||||
{
|
||||
await pipe.ConnectAsync((int)AgentPipeProtocol.ConnectTimeout.TotalMilliseconds, token);
|
||||
}
|
||||
catch (TimeoutException error)
|
||||
{
|
||||
throw new AgentPipeException("agent not reachable (is the ShopDB Endpoint Agent service running?)", error);
|
||||
}
|
||||
|
||||
var line = JsonSerializer.SerializeToUtf8Bytes(request);
|
||||
await pipe.WriteAsync(line, token);
|
||||
await pipe.WriteAsync(new[] { (byte)'\n' }, token);
|
||||
await pipe.FlushAsync(token);
|
||||
|
||||
var body = await ReadLineAsync(pipe, AgentPipeProtocol.MaxResponseBytes, token)
|
||||
?? throw new AgentPipeException("agent closed the pipe without answering");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
throw new AgentPipeException("agent response is not a JSON object");
|
||||
|
||||
var ok = root.TryGetProperty("ok", out var okValue) && okValue.ValueKind == JsonValueKind.True;
|
||||
var errorText = root.TryGetProperty("error", out var errorValue)
|
||||
&& errorValue.ValueKind == JsonValueKind.String
|
||||
? errorValue.GetString() : null;
|
||||
return new PipeResponse(ok, ok ? null : errorText ?? "agent reported failure", root.Clone());
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException error)
|
||||
{
|
||||
throw new AgentPipeException("agent did not answer in time", error);
|
||||
}
|
||||
catch (AgentPipeException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException
|
||||
or InvalidDataException)
|
||||
{
|
||||
throw new AgentPipeException("agent pipe failed: " + error.Message, error);
|
||||
}
|
||||
}
|
||||
|
||||
// One newline-terminated UTF-8 line, or null on EOF before any byte.
|
||||
// Throws InvalidDataException past maxBytes. Shared with the server.
|
||||
internal static async Task<string?> ReadLineAsync(Stream stream, int maxBytes, CancellationToken token)
|
||||
{
|
||||
var buffer = new MemoryStream();
|
||||
var chunk = new byte[4096];
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(chunk, token);
|
||||
if (read == 0)
|
||||
return buffer.Length == 0 ? null : Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
|
||||
|
||||
var newline = Array.IndexOf(chunk, (byte)'\n', 0, read);
|
||||
var take = newline >= 0 ? newline : read;
|
||||
if (buffer.Length + take > maxBytes)
|
||||
throw new InvalidDataException($"pipe message exceeds {maxBytes} bytes");
|
||||
buffer.Write(chunk, 0, take);
|
||||
if (newline >= 0)
|
||||
return Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
562
agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs
Normal file
562
agent/ShopDbEndpointAgent.Core/AgentPipeServer.cs
Normal file
@@ -0,0 +1,562 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// \\.\pipe\ShopDB.EndpointAgent.v1: the tray's only way to reach the server.
|
||||
// The tray holds no secret; it asks the SYSTEM agent to send status, upload a
|
||||
// report, list alerts or acknowledge one, and the agent does it with its own
|
||||
// credential. Protocol in AgentPipeProtocol / AgentPipeClient.
|
||||
//
|
||||
// ACL: SYSTEM and Administrators full control, INTERACTIVE read/write (no
|
||||
// create-instance, so a user cannot add instances to our pipe). The first
|
||||
// instance is created with FirstPipeInstance so a process that squatted the
|
||||
// name before the service started is refused rather than joined.
|
||||
public sealed class AgentPipeServer : BackgroundService
|
||||
{
|
||||
public const int MaxInstances = 8;
|
||||
|
||||
private const int MaxScreenshotBytes = 25 * 1024 * 1024;
|
||||
private static readonly TimeSpan LongOperationTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan ShortOperationTimeout = TimeSpan.FromSeconds(40);
|
||||
|
||||
private static readonly JsonSerializerOptions PayloadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
private readonly AgentConfig config;
|
||||
private readonly TelemetryWorker telemetry;
|
||||
private readonly CollectorClient collector;
|
||||
private readonly FleetAlertClient alerts;
|
||||
private readonly ICredentialProvider credentials;
|
||||
private readonly string agentVersion;
|
||||
private readonly ILogger<AgentPipeServer> logger;
|
||||
private readonly SemaphoreSlim statusGate = new(1, 1);
|
||||
private readonly SemaphoreSlim reportGate = new(1, 1);
|
||||
|
||||
public AgentPipeServer(AgentConfig config, TelemetryWorker telemetry, CollectorClient collector,
|
||||
FleetAlertClient alerts, ICredentialProvider credentials, string agentVersion,
|
||||
ILogger<AgentPipeServer> logger)
|
||||
{
|
||||
this.config = config;
|
||||
this.telemetry = telemetry;
|
||||
this.collector = collector;
|
||||
this.alerts = alerts;
|
||||
this.credentials = credentials;
|
||||
this.agentVersion = agentVersion;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var slots = new SemaphoreSlim(MaxInstances, MaxInstances);
|
||||
bool first = true;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await slots.WaitAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
NamedPipeServerStream pipe;
|
||||
try
|
||||
{
|
||||
pipe = CreateInstance(first);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
slots.Release();
|
||||
logger.LogError(error,
|
||||
first
|
||||
? "Could not create the agent pipe; another process may own the name. Retrying in 30s."
|
||||
: "Could not create an agent pipe instance. Retrying in 30s.");
|
||||
if (!await SafeDelay(TimeSpan.FromSeconds(30), stoppingToken))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (first)
|
||||
{
|
||||
first = false;
|
||||
logger.LogInformation("Agent pipe listening on {Pipe}", AgentPipeProtocol.PipeName);
|
||||
RestrictAppSettingsAcl();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await pipe.WaitForConnectionAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
pipe.Dispose();
|
||||
slots.Release();
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
logger.LogWarning(error, "Agent pipe connection failed");
|
||||
if (!await SafeDelay(TimeSpan.FromSeconds(1), stoppingToken))
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandleConnectionAsync(pipe, stoppingToken);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Agent pipe request ended with an error");
|
||||
}
|
||||
finally
|
||||
{
|
||||
pipe.Dispose();
|
||||
slots.Release();
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static NamedPipeServerStream CreateInstance(bool first)
|
||||
{
|
||||
var security = new PipeSecurity();
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
|
||||
PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
|
||||
PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.InteractiveSid, null),
|
||||
PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||
|
||||
var options = PipeOptions.Asynchronous;
|
||||
if (first)
|
||||
options |= PipeOptions.FirstPipeInstance;
|
||||
|
||||
return NamedPipeServerStreamAcl.Create(AgentPipeProtocol.PipeName, PipeDirection.InOut,
|
||||
MaxInstances, PipeTransmissionMode.Byte, options, 4096, 4096, security);
|
||||
}
|
||||
|
||||
private async Task HandleConnectionAsync(NamedPipeServerStream pipe, CancellationToken stoppingToken)
|
||||
{
|
||||
string? line;
|
||||
using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken))
|
||||
{
|
||||
readTimeout.CancelAfter(AgentPipeProtocol.RequestReadTimeout);
|
||||
try
|
||||
{
|
||||
line = await AgentPipeClient.ReadLineAsync(pipe, AgentPipeProtocol.MaxRequestBytes,
|
||||
readTimeout.Token);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
await WriteAsync(pipe, Failure("request too large"), stoppingToken);
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogDebug("Agent pipe client sent no request within {Timeout}",
|
||||
AgentPipeProtocol.RequestReadTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (line is null)
|
||||
return;
|
||||
|
||||
// Impersonation needs a completed read first, which we just did.
|
||||
var (callerName, callerSid) = IdentifyCaller(pipe);
|
||||
|
||||
string op;
|
||||
JsonElement request;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
request = doc.RootElement.Clone();
|
||||
op = request.ValueKind == JsonValueKind.Object
|
||||
&& request.TryGetProperty("op", out var opValue) && opValue.ValueKind == JsonValueKind.String
|
||||
? opValue.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
await WriteAsync(pipe, Failure("request is not valid JSON"), stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
object response;
|
||||
try
|
||||
{
|
||||
response = op switch
|
||||
{
|
||||
"ping" => Ping(),
|
||||
"sendstatus" => await SendStatusAsync(callerName, stoppingToken),
|
||||
"sendreport" => await SendReportAsync(request, callerName, callerSid, stoppingToken),
|
||||
"alerts" => await ListAlertsAsync(stoppingToken),
|
||||
"ackalert" => await AckAlertAsync(request, callerName, stoppingToken),
|
||||
_ => Failure("unknown op"),
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
response = Failure("the agent timed out talking to ShopDB");
|
||||
}
|
||||
|
||||
await WriteAsync(pipe, response, stoppingToken);
|
||||
}
|
||||
|
||||
private (string? Name, SecurityIdentifier? Sid) IdentifyCaller(NamedPipeServerStream pipe)
|
||||
{
|
||||
string? name = null;
|
||||
SecurityIdentifier? sid = null;
|
||||
try
|
||||
{
|
||||
name = pipe.GetImpersonationUserName();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "GetImpersonationUserName failed");
|
||||
}
|
||||
try
|
||||
{
|
||||
pipe.RunAsClient(() =>
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent(TokenAccessLevels.Query);
|
||||
sid = identity.User;
|
||||
});
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Could not read the pipe caller's SID");
|
||||
}
|
||||
return (name, sid);
|
||||
}
|
||||
|
||||
private object Ping()
|
||||
{
|
||||
var current = credentials.Current;
|
||||
return new
|
||||
{
|
||||
ok = true,
|
||||
agentversion = agentVersion,
|
||||
enrolled = current is not null,
|
||||
assetid = current?.AssetId,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<object> SendStatusAsync(string? caller, CancellationToken stoppingToken)
|
||||
{
|
||||
if (!await statusGate.WaitAsync(0, stoppingToken))
|
||||
return Failure("a status send is already running");
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(LongOperationTimeout);
|
||||
logger.LogInformation("Status send requested over the pipe by {Caller}", caller);
|
||||
var result = await telemetry.RunOnceAsync(config.AdoptOnFirstRun, timeout.Token);
|
||||
logger.LogInformation("Pipe status send complete: action={Action}", result.Action);
|
||||
return Success();
|
||||
}
|
||||
catch (CollectorException error)
|
||||
{
|
||||
logger.LogWarning("Pipe status send failed: HTTP {Status}", error.StatusCode);
|
||||
return Failure(ServerFailure(error));
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
|
||||
&& !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(error, "Pipe status send failed");
|
||||
return Failure("could not reach ShopDB");
|
||||
}
|
||||
finally
|
||||
{
|
||||
statusGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// Does what --report does, but only with files inside the CALLER's own
|
||||
// profile: the pipe is open to every interactive user and this process is
|
||||
// SYSTEM, so an unchecked path would upload any file SYSTEM can read. The
|
||||
// sidecar is not deleted here (the tray removes it); deleting in a user-
|
||||
// writable tree as SYSTEM is a redirection target.
|
||||
private async Task<object> SendReportAsync(JsonElement request, string? caller,
|
||||
SecurityIdentifier? callerSid, CancellationToken stoppingToken)
|
||||
{
|
||||
if (callerSid is null)
|
||||
return Failure("could not identify the calling user");
|
||||
|
||||
var spoolPath = request.TryGetProperty("spoolpath", out var pathValue)
|
||||
&& pathValue.ValueKind == JsonValueKind.String
|
||||
? pathValue.GetString()
|
||||
: null;
|
||||
if (string.IsNullOrWhiteSpace(spoolPath))
|
||||
return Failure("spoolpath is required");
|
||||
|
||||
var profile = ProfilePathFor(callerSid);
|
||||
if (profile is null)
|
||||
return Failure("no local profile for the calling user");
|
||||
|
||||
if (!await reportGate.WaitAsync(0, stoppingToken))
|
||||
return Failure("a report upload is already running");
|
||||
try
|
||||
{
|
||||
OperatorReportPayload? payload;
|
||||
try
|
||||
{
|
||||
using var sidecar = OpenCallerFile(spoolPath, profile, AgentPipeProtocol.MaxRequestBytes);
|
||||
payload = await JsonSerializer.DeserializeAsync<OperatorReportPayload>(
|
||||
sidecar, PayloadOptions, stoppingToken);
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException
|
||||
or JsonException or ArgumentException
|
||||
or NotSupportedException)
|
||||
{
|
||||
logger.LogWarning("Refused report sidecar from {Caller}: {Reason}", caller, error.Message);
|
||||
return Failure("report file refused: " + error.Message);
|
||||
}
|
||||
if (payload is null)
|
||||
return Failure("report file is empty");
|
||||
|
||||
FileStream? screenshot = null;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(payload.ScreenshotPath))
|
||||
{
|
||||
if (!payload.ScreenshotPath.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
|
||||
return Failure("screenshot must be a .png file");
|
||||
try
|
||||
{
|
||||
screenshot = OpenCallerFile(payload.ScreenshotPath, profile, MaxScreenshotBytes);
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException)
|
||||
{
|
||||
logger.LogWarning("Refused report screenshot from {Caller}: {Reason}",
|
||||
caller, error.Message);
|
||||
return Failure("screenshot refused: " + error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(LongOperationTimeout);
|
||||
var uploaded = await collector.UploadOperatorReportAsync(
|
||||
payload.Reason, payload.Details, screenshot,
|
||||
payload.ScreenshotPath is null ? null : Path.GetFileName(payload.ScreenshotPath),
|
||||
Environment.MachineName, timeout.Token);
|
||||
logger.LogInformation("Operator report from {Caller} uploaded: id={ReportId}",
|
||||
caller, uploaded?.OperatorReportId);
|
||||
return Success();
|
||||
}
|
||||
catch (CollectorException error)
|
||||
{
|
||||
logger.LogWarning("Operator report upload failed: HTTP {Status}", error.StatusCode);
|
||||
return Failure(ServerFailure(error));
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
|
||||
&& !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(error, "Operator report upload failed");
|
||||
return Failure("could not reach ShopDB");
|
||||
}
|
||||
finally
|
||||
{
|
||||
screenshot?.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
reportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object> ListAlertsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(ShortOperationTimeout);
|
||||
var list = await alerts.FetchAlertsAsync(Environment.MachineName, timeout.Token);
|
||||
return new { ok = true, alerts = list.ToArray() };
|
||||
}
|
||||
catch (CollectorException error)
|
||||
{
|
||||
logger.LogDebug("Alert fetch for the tray failed: HTTP {Status}", error.StatusCode);
|
||||
return Failure(ServerFailure(error));
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or TaskCanceledException or JsonException
|
||||
&& !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogDebug(error, "Alert fetch for the tray failed");
|
||||
return Failure("could not reach ShopDB");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object> AckAlertAsync(JsonElement request, string? caller, CancellationToken stoppingToken)
|
||||
{
|
||||
if (!request.TryGetProperty("notificationid", out var idValue)
|
||||
|| idValue.ValueKind != JsonValueKind.Number
|
||||
|| !idValue.TryGetInt32(out var notificationId)
|
||||
|| notificationId <= 0)
|
||||
return Failure("notificationid must be a positive integer");
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
timeout.CancelAfter(ShortOperationTimeout);
|
||||
// 0 = "the caller": the server resolves it from credential or hostname.
|
||||
var assetId = credentials.Current?.AssetId ?? 0;
|
||||
await alerts.AcknowledgeAlertAsync(notificationId, assetId, Environment.MachineName, timeout.Token);
|
||||
logger.LogInformation("Alert {NotificationId} acknowledged by {Caller}", notificationId, caller);
|
||||
return Success();
|
||||
}
|
||||
catch (CollectorException error)
|
||||
{
|
||||
logger.LogWarning("Alert {NotificationId} acknowledge failed: HTTP {Status}",
|
||||
notificationId, error.StatusCode);
|
||||
return Failure(ServerFailure(error));
|
||||
}
|
||||
catch (Exception error) when (error is HttpRequestException or TaskCanceledException
|
||||
&& !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(error, "Alert {NotificationId} acknowledge failed", notificationId);
|
||||
return Failure("could not reach ShopDB");
|
||||
}
|
||||
}
|
||||
|
||||
// Profile root for a SID from ProfileList. Null when absent.
|
||||
private static string? ProfilePathFor(SecurityIdentifier sid)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
using var key = machine.OpenSubKey(
|
||||
@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\" + sid.Value);
|
||||
if (key?.GetValue("ProfileImagePath") is not string raw || string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
return Path.GetFullPath(Environment.ExpandEnvironmentVariables(raw)).TrimEnd('\\');
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Open a caller-named file only if it sits inside the caller's profile, no
|
||||
// directory from the profile down is a reparse point, the file itself is
|
||||
// not one, and the opened handle's final path is still inside the profile.
|
||||
private static FileStream OpenCallerFile(string path, string profile, int maxBytes)
|
||||
{
|
||||
if (!Path.IsPathFullyQualified(path) || path.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
throw new UnauthorizedAccessException("path must be a local absolute path");
|
||||
|
||||
var full = Path.GetFullPath(path);
|
||||
var root = profile + @"\";
|
||||
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
throw new UnauthorizedAccessException("path is outside the caller's profile");
|
||||
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
while (directory is not null && directory.Length >= profile.Length)
|
||||
{
|
||||
if (ProtectedFileSystem.IsReparsePoint(directory))
|
||||
throw new UnauthorizedAccessException("path crosses a reparse point");
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
var stream = ProtectedFileSystem.OpenReadNoFollow(full);
|
||||
try
|
||||
{
|
||||
var final = ProtectedFileSystem.FinalPath(stream.SafeFileHandle);
|
||||
if (final is null || !final.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
throw new UnauthorizedAccessException("path resolves outside the caller's profile");
|
||||
if (stream.Length > maxBytes)
|
||||
throw new IOException($"file is larger than {maxBytes} bytes");
|
||||
return stream;
|
||||
}
|
||||
catch
|
||||
{
|
||||
stream.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Only SYSTEM (the service) and Administrators read appsettings.json now:
|
||||
// no user-context process needs it once the tray talks through this pipe.
|
||||
private void RestrictAppSettingsAcl()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
if (!identity.IsSystem)
|
||||
return;
|
||||
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
if (ProtectedFileSystem.TryGetAttributes(path) is not FileAttributes attributes
|
||||
|| attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
return;
|
||||
|
||||
var security = new FileSecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
|
||||
FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
|
||||
FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
new FileInfo(path).SetAccessControl(security);
|
||||
logger.LogInformation("appsettings.json restricted to SYSTEM and Administrators.");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "Could not restrict the appsettings.json ACL");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ServerFailure(CollectorException error)
|
||||
=> string.IsNullOrEmpty(error.ServerCode)
|
||||
? $"ShopDB returned HTTP {error.StatusCode}"
|
||||
: $"ShopDB returned HTTP {error.StatusCode} {error.ServerCode}";
|
||||
|
||||
private static object Success() => new { ok = true };
|
||||
|
||||
private static object Failure(string error) => new { ok = false, error };
|
||||
|
||||
private static async Task WriteAsync(Stream pipe, object response, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(response);
|
||||
await pipe.WriteAsync(bytes, token);
|
||||
await pipe.WriteAsync(new[] { (byte)'\n' }, token);
|
||||
await pipe.FlushAsync(token);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Client went away.
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> SafeDelay(TimeSpan delay, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, token);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Posts one EndpointReport to POST {baseurl}/api/collector/endpoints with the
|
||||
// X-API-Key header (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping
|
||||
// Posts one EndpointReport to POST {baseurl}/api/endpoints/collect (per-PC
|
||||
// credential) or /api/collector/endpoints (shared key), auth added by
|
||||
// AgentHttpFactory (plugins/endpoints/AGENT-CONTRACT.md section 1), unwrapping
|
||||
// the ShopDB envelope { status, data, meta } to return the CollectorResponse.
|
||||
//
|
||||
// Resilience: transient failures (network errors, timeouts, 5xx, 429) are
|
||||
@@ -27,17 +28,15 @@ public sealed class CollectorClient : IDisposable
|
||||
private readonly int maxAttempts;
|
||||
private readonly Random jitter = new();
|
||||
|
||||
public CollectorClient(AgentConfig config, ILogger<CollectorClient> logger,
|
||||
private readonly ICredentialProvider credentials;
|
||||
|
||||
public CollectorClient(AgentHttpFactory httpFactory, ILogger<CollectorClient> logger,
|
||||
int maxAttempts = 4)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.maxAttempts = Math.Max(1, maxAttempts);
|
||||
http = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
credentials = httpFactory.Credentials;
|
||||
http = httpFactory.CreateClient(AgentHttpChannel.Collector);
|
||||
}
|
||||
|
||||
public async Task<CollectorResponse> SendAsync(
|
||||
@@ -76,8 +75,12 @@ public sealed class CollectorClient : IDisposable
|
||||
private async Task<CollectorResponse> SendOnceAsync(
|
||||
EndpointReport report, CancellationToken token)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
"api/collector/endpoints", report, JsonOptions, token);
|
||||
// Enrolled agents post to the credential route; the shared key keeps
|
||||
// the core collector route so this build still reports to an older server.
|
||||
var route = credentials.Current is not null
|
||||
? "api/endpoints/collect"
|
||||
: "api/collector/endpoints";
|
||||
using var response = await http.PostAsJsonAsync(route, report, JsonOptions, token);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
@@ -91,7 +94,7 @@ public sealed class CollectorClient : IDisposable
|
||||
|
||||
// Fetch the server-resolved watch list (policy-attached processes/services)
|
||||
// for this hostname, GET /api/endpoints/monitors/resolved. Shares the same
|
||||
// X-API-Key credential as telemetry. Best-effort: returns null on any
|
||||
// auth as telemetry. Best-effort: returns null on any
|
||||
// failure so the caller falls back to the last-known/local list rather than
|
||||
// blanking monitoring on a transient blip. A single attempt, no retry - the
|
||||
// next telemetry cycle retries naturally, and a stale-but-usable watch list
|
||||
@@ -134,49 +137,77 @@ public sealed class CollectorClient : IDisposable
|
||||
|
||||
// Uploads one operator issue report (free-text reason/details plus an
|
||||
// optional PNG screenshot) to POST /api/endpoints/operatorreports as
|
||||
// multipart/form-data, authorized by the same X-API-Key collector key as
|
||||
// telemetry. The server resolves this hostname to its asset. A single
|
||||
// multipart/form-data, authorized the same way as telemetry. The server
|
||||
// resolves this hostname (or the credential) to its asset. A single
|
||||
// attempt: the tray shows the operator the outcome and can retry by hand,
|
||||
// and a stuck retry loop holding a screenshot in memory is worse than a
|
||||
// clear failure. Returns the parsed upload response (new row id) or throws.
|
||||
public async Task<OperatorReportUploadResponse?> UploadOperatorReportAsync(
|
||||
string reason, string details, string? screenshotPath, string hostname,
|
||||
CancellationToken token)
|
||||
{
|
||||
FileStream? imageStream = null;
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath))
|
||||
imageStream = File.OpenRead(screenshotPath);
|
||||
return await UploadOperatorReportAsync(reason, details, imageStream,
|
||||
screenshotPath is null ? null : Path.GetFileName(screenshotPath),
|
||||
hostname, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
imageStream?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Same upload from an already opened screenshot stream. The pipe path opens
|
||||
// the file itself (no reparse follow, final path checked) and hands it here.
|
||||
public async Task<OperatorReportUploadResponse?> UploadOperatorReportAsync(
|
||||
string reason, string details, Stream? screenshot, string? screenshotName,
|
||||
string hostname, CancellationToken token)
|
||||
{
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent(reason ?? string.Empty), "reason");
|
||||
form.Add(new StringContent(details ?? string.Empty), "details");
|
||||
form.Add(new StringContent(hostname ?? string.Empty), "hostname");
|
||||
|
||||
StreamContent? imageContent = null;
|
||||
FileStream? imageStream = null;
|
||||
try
|
||||
if (screenshot is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(screenshotPath) && File.Exists(screenshotPath))
|
||||
{
|
||||
imageStream = File.OpenRead(screenshotPath);
|
||||
imageContent = new StreamContent(imageStream);
|
||||
imageContent.Headers.ContentType =
|
||||
new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
|
||||
form.Add(imageContent, "screenshot",
|
||||
Path.GetFileName(screenshotPath));
|
||||
}
|
||||
|
||||
using var response = await http.PostAsync(
|
||||
"api/endpoints/operatorreports", form, token);
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<OperatorReportUploadEnvelope>(
|
||||
body, JsonOptions);
|
||||
return envelope?.Data;
|
||||
}
|
||||
finally
|
||||
{
|
||||
imageContent?.Dispose();
|
||||
imageStream?.Dispose();
|
||||
// Not disposed with the form: the caller owns the stream.
|
||||
var imageContent = new StreamContent(new NonClosingStream(screenshot));
|
||||
imageContent.Headers.ContentType =
|
||||
new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
|
||||
form.Add(imageContent, "screenshot", screenshotName ?? "screenshot.png");
|
||||
}
|
||||
|
||||
using var response = await http.PostAsync(
|
||||
"api/endpoints/operatorreports", form, token);
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<OperatorReportUploadEnvelope>(
|
||||
body, JsonOptions);
|
||||
return envelope?.Data;
|
||||
}
|
||||
|
||||
private sealed class NonClosingStream : Stream
|
||||
{
|
||||
private readonly Stream inner;
|
||||
public NonClosingStream(Stream inner) => this.inner = inner;
|
||||
public override bool CanRead => inner.CanRead;
|
||||
public override bool CanSeek => inner.CanSeek;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => inner.Length;
|
||||
public override long Position { get => inner.Position; set => inner.Position = value; }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
|
||||
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
=> inner.ReadAsync(buffer, cancellationToken);
|
||||
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void Dispose() => http.Dispose();
|
||||
@@ -193,9 +224,15 @@ public sealed class CollectorException : Exception
|
||||
StatusCode >= 400 && StatusCode < 500
|
||||
&& StatusCode != (int)HttpStatusCode.TooManyRequests;
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
// Error code from the server's JSON envelope (e.g. CREDENTIAL_INVALID), or null.
|
||||
public string? ServerCode => AgentServerError.TryParse(Body)?.Code;
|
||||
|
||||
public CollectorException(int statusCode, string body)
|
||||
: base($"collector returned HTTP {statusCode}: {body}")
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
Body = body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@ namespace ShopDbEndpointAgent.Core;
|
||||
// GET /api/endpoints/commands/next, which the server holds open until a command
|
||||
// is queued (HTTP 200) or the hold window elapses (HTTP 204). After running the
|
||||
// claimed command locally the agent posts the outcome to
|
||||
// POST /api/endpoints/commands/{id}/result. Both share the endpoints collector
|
||||
// key via X-API-Key, so the command channel rides the same kill switch as
|
||||
// telemetry and adds no second secret.
|
||||
// POST /api/endpoints/commands/{id}/result. Both use the same auth as telemetry
|
||||
// (per-PC credential, else the shared collector key), added by AgentHttpFactory.
|
||||
public sealed class CommandClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@@ -22,33 +21,13 @@ public sealed class CommandClient : IDisposable
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly SocketsHttpHandler handler;
|
||||
private readonly ILogger<CommandClient> logger;
|
||||
|
||||
public CommandClient(AgentConfig config, ILogger<CommandClient> logger)
|
||||
public CommandClient(AgentHttpFactory httpFactory, ILogger<CommandClient> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
// The command channel long-polls: the server holds each claim open for a
|
||||
// hold window and then, between windows, the pooled TLS connection sits
|
||||
// idle. Gunicorn's keep-alive closes an idle socket after a couple of
|
||||
// seconds, so a connection reused after that yields "unexpected EOF or 0
|
||||
// bytes from the transport stream" on the next TLS handshake. Retire
|
||||
// pooled connections faster than the server's keep-alive so a socket the
|
||||
// server has already dropped is never handed back out; the lifetime cap
|
||||
// is a hard backstop so no connection lingers indefinitely.
|
||||
handler = new SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
};
|
||||
http = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
// Longer than the collector client: the claim request is held open
|
||||
// server-side (~25s), so the read timeout must clear that plus slack.
|
||||
Timeout = TimeSpan.FromSeconds(60),
|
||||
};
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
// Pooling tuned for the long-poll lives in AgentHttpFactory (Command).
|
||||
http = httpFactory.CreateClient(AgentHttpChannel.Command);
|
||||
}
|
||||
|
||||
// Claim the next pending command for this hostname, or null when the server
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,15 +41,20 @@ public sealed class CommandWorker : BackgroundService
|
||||
private readonly CommandExecutor executor;
|
||||
private readonly ILogger<CommandWorker> logger;
|
||||
private readonly CollectSignal? collectSignal;
|
||||
private readonly AgentConfig? config;
|
||||
private readonly string hostname;
|
||||
private readonly EnrollmentService? enrollment;
|
||||
|
||||
public CommandWorker(CommandClient client, CommandExecutor executor,
|
||||
ILogger<CommandWorker> logger, CollectSignal? collectSignal = null)
|
||||
ILogger<CommandWorker> logger, CollectSignal? collectSignal = null,
|
||||
AgentConfig? config = null, EnrollmentService? enrollment = null)
|
||||
{
|
||||
this.enrollment = enrollment;
|
||||
this.client = client;
|
||||
this.executor = executor;
|
||||
this.logger = logger;
|
||||
this.collectSignal = collectSignal;
|
||||
this.config = config;
|
||||
hostname = Environment.MachineName;
|
||||
}
|
||||
|
||||
@@ -57,6 +62,30 @@ public sealed class CommandWorker : BackgroundService
|
||||
{
|
||||
logger.LogInformation("Command channel started for host {Host}", hostname);
|
||||
|
||||
// Spread first contact across the fleet. Every agent otherwise polls the
|
||||
// instant its service starts, and the service is start=auto, so a patch
|
||||
// reboot puts the whole fleet on the wire together. See StartupJitter.
|
||||
var startupDelay = StartupJitter.Next(config?.StartupJitterSeconds ?? 0, logger);
|
||||
if (startupDelay > TimeSpan.Zero)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Delaying first command poll by {Delay}ms (startup jitter)",
|
||||
(int)startupDelay.TotalMilliseconds);
|
||||
await SafeDelay(startupDelay, stoppingToken);
|
||||
}
|
||||
|
||||
if (enrollment is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await enrollment.WaitForFirstAttemptAsync(TimeSpan.FromSeconds(20), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var pollStarted = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
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; }
|
||||
@@ -136,6 +182,28 @@ public sealed class AppDeliveryClaim
|
||||
// downloads with its collector credential; Sha256 pins the exact bytes;
|
||||
// RunContext is 'system' (the agent's own service account) or 'user' (the
|
||||
// interactive logged-on session), decided per script in the ShopDB library.
|
||||
// WorkingDirectory is the optional folder to run the script IN. Empty means the
|
||||
// agent's staging directory, which is what every script did before the field
|
||||
// existed. It is there so a script can sit beside a corporate tool already
|
||||
// installed on the endpoint and invoke it by relative path; it confers no
|
||||
// rights, since the body is already arbitrary PowerShell that could change its
|
||||
// own directory.
|
||||
// ErrorMode is 'default' or 'strict'. Default is what PowerShell does unaided:
|
||||
// a non-terminating error is written and the script continues, so powershell.exe
|
||||
// still exits 0 and the command reports SUCCEEDED even when the body did none of
|
||||
// its work. Strict makes the agent prepend $ErrorActionPreference = 'Stop' so the
|
||||
// first error terminates and the exit code means what an operator reads it to
|
||||
// mean. Per script, never global: strict changes execution semantics, and a body
|
||||
// written to tolerate a failing cleanup or to probe with a cmdlet that errors on
|
||||
// absence would start aborting halfway. An absent or unrecognised value is
|
||||
// treated as default, so an old server that never sends the field is safe.
|
||||
// CaptureOutput decides whether a SUCCESSFUL run returns its output to the
|
||||
// server. Failures always return it, because a failure has to be diagnosable
|
||||
// without walking to the machine. Success is opt-in per script: output carries
|
||||
// whatever the body printed, and that is not worth persisting server-side for a
|
||||
// routine green run. The endpoint log keeps the full copy regardless, so this
|
||||
// only governs what crosses the wire. Absent means false, which is the same
|
||||
// behaviour as before the field existed.
|
||||
public sealed class ScriptDeliveryClaim
|
||||
{
|
||||
[JsonPropertyName("scriptid")] public int ScriptId { get; set; }
|
||||
@@ -143,6 +211,9 @@ public sealed class ScriptDeliveryClaim
|
||||
[JsonPropertyName("installpath")] public string? InstallPath { get; set; }
|
||||
[JsonPropertyName("sha256")] public string? Sha256 { get; set; }
|
||||
[JsonPropertyName("runcontext")] public string? RunContext { get; set; }
|
||||
[JsonPropertyName("workingdirectory")] public string? WorkingDirectory { get; set; }
|
||||
[JsonPropertyName("errormode")] public string? ErrorMode { get; set; }
|
||||
[JsonPropertyName("captureoutput")] public bool CaptureOutput { get; set; }
|
||||
}
|
||||
|
||||
// Envelope for the claim long-poll: { status, data: { endpointcommandid, action } }.
|
||||
|
||||
@@ -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,5 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Management;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
@@ -19,16 +22,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 +64,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 +520,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 +546,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 +561,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 +684,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 +719,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
|
||||
@@ -547,11 +936,15 @@ public sealed class EndpointCollector : ICollector
|
||||
if (!seen.Add(displayName + "\u0000" + displayVersion))
|
||||
continue;
|
||||
|
||||
var fingerprint = ComputeApplicationFingerprint(
|
||||
displayName, displayVersion, publisher);
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
displayname = displayName,
|
||||
displayversion = displayVersion,
|
||||
publisher,
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
catch (Exception error)
|
||||
@@ -566,6 +959,256 @@ public sealed class EndpointCollector : ICollector
|
||||
}
|
||||
}
|
||||
|
||||
// Compute SHA256 fingerprint for application inventory delta optimization.
|
||||
// Format: SHA256(displayname|displayversion|publisher) as lowercase hex string.
|
||||
// Matches backend algorithm in plugins/endpoints/plugin.py and backfill script.
|
||||
// Empty strings are preserved (not converted to null) to match backend behavior.
|
||||
private static string ComputeApplicationFingerprint(
|
||||
string displayName, string displayVersion, string publisher)
|
||||
{
|
||||
var normalized = $"{displayName}|{displayVersion}|{publisher}";
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
// 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 +1234,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 +1252,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 +1511,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)
|
||||
|
||||
357
agent/ShopDbEndpointAgent.Core/EnrollmentService.cs
Normal file
357
agent/ShopDbEndpointAgent.Core/EnrollmentService.cs
Normal file
@@ -0,0 +1,357 @@
|
||||
using System.Management;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Gets this PC its own endpoint credential (POST /api/endpoints/enroll) and
|
||||
// keeps trying until it has one. Workers run on the shared key meanwhile, so an
|
||||
// unenrolled or pending PC keeps reporting.
|
||||
//
|
||||
// 201 enrolled store credential, swap provider, idle until woken
|
||||
// 202 pending PendingSchedule: 1 min, then 5 min, then 15 min
|
||||
// 404 server predates enrollment: idle, retry every 6 h
|
||||
// other back off 1, 5, 15 min (cap); a 429 or 5xx lands here, so a
|
||||
// rate limit drops out of the fast pending phase for that try
|
||||
//
|
||||
// Woken early by the provider on CREDENTIAL_INVALID (credential deleted) and on
|
||||
// SHARED_KEY_REFUSED while unenrolled, floored at one attempt a minute.
|
||||
public sealed class EnrollmentService : BackgroundService
|
||||
{
|
||||
public const string DeviceIdValue = "DeviceId";
|
||||
|
||||
// Retry while pending, by how long this request has been pending. An admin
|
||||
// usually approves within a couple of minutes and a tech is standing there,
|
||||
// so ask often at first. Jitter is added, never subtracted, so MinWakeInterval
|
||||
// cannot clip it back into lockstep across a room of PCs.
|
||||
private static readonly (TimeSpan Waited, TimeSpan Retry, TimeSpan Jitter)[] PendingSchedule =
|
||||
{
|
||||
(TimeSpan.FromMinutes(10), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(8)),
|
||||
(TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(20)),
|
||||
(TimeSpan.MaxValue, TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(2)),
|
||||
};
|
||||
private static readonly TimeSpan UnsupportedRetry = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan[] ErrorBackoff =
|
||||
{
|
||||
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15),
|
||||
};
|
||||
private static readonly TimeSpan MinWakeInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly AgentConfig config;
|
||||
private readonly AgentHttpFactory httpFactory;
|
||||
private readonly AgentCredentialProvider credentials;
|
||||
private readonly CredentialStore store;
|
||||
private readonly string agentVersion;
|
||||
private readonly ILogger<EnrollmentService> logger;
|
||||
private readonly SemaphoreSlim wake = new(0, 1);
|
||||
private readonly TaskCompletionSource firstAttempt =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly Random jitter = new();
|
||||
|
||||
public EnrollmentService(AgentConfig config, AgentHttpFactory httpFactory,
|
||||
AgentCredentialProvider credentials, CredentialStore store, string agentVersion,
|
||||
ILogger<EnrollmentService> logger)
|
||||
{
|
||||
this.config = config;
|
||||
this.httpFactory = httpFactory;
|
||||
this.credentials = credentials;
|
||||
this.store = store;
|
||||
this.agentVersion = agentVersion;
|
||||
this.logger = logger;
|
||||
credentials.EnrollmentNeeded += (_, _) => Wake();
|
||||
}
|
||||
|
||||
// Workers await this (bounded) before first contact so a PC that can enroll
|
||||
// right away does its first report on its own credential.
|
||||
public async Task WaitForFirstAttemptAsync(TimeSpan limit, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await firstAttempt.Task.WaitAsync(limit, token);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void Wake()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (wake.CurrentCount == 0)
|
||||
wake.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
int failures = 0;
|
||||
var lastAttempt = DateTime.MinValue;
|
||||
// Start of the current pending run; reset on a new request id so a
|
||||
// reject-and-ask-again (or a revoke) gets the fast phase over again.
|
||||
DateTime? pendingSince = null;
|
||||
int? pendingRequest = null;
|
||||
using var client = httpFactory.CreateClient(AgentHttpChannel.Enroll);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (credentials.Current is not null)
|
||||
{
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
firstAttempt.TrySetResult();
|
||||
await wake.WaitAsync(stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sinceLast = DateTime.UtcNow - lastAttempt;
|
||||
if (sinceLast < MinWakeInterval)
|
||||
await Task.Delay(MinWakeInterval - sinceLast, stoppingToken);
|
||||
|
||||
lastAttempt = DateTime.UtcNow;
|
||||
var (outcome, requestId) = await TryEnrollAsync(client, stoppingToken);
|
||||
firstAttempt.TrySetResult();
|
||||
|
||||
TimeSpan delay;
|
||||
switch (outcome)
|
||||
{
|
||||
case EnrollOutcome.Enrolled:
|
||||
failures = 0;
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
continue;
|
||||
case EnrollOutcome.Pending:
|
||||
failures = 0;
|
||||
if (pendingSince is null
|
||||
|| (requestId is not null && requestId != pendingRequest))
|
||||
{
|
||||
pendingSince = DateTime.UtcNow;
|
||||
pendingRequest = requestId;
|
||||
}
|
||||
delay = PendingDelay(DateTime.UtcNow - pendingSince.Value);
|
||||
logger.LogDebug("Next enrollment attempt in {Delay}.", delay);
|
||||
break;
|
||||
case EnrollOutcome.Unsupported:
|
||||
failures = 0;
|
||||
pendingSince = null;
|
||||
pendingRequest = null;
|
||||
delay = UnsupportedRetry;
|
||||
break;
|
||||
default:
|
||||
delay = ErrorBackoff[Math.Min(failures, ErrorBackoff.Length - 1)];
|
||||
failures++;
|
||||
break;
|
||||
}
|
||||
|
||||
// A wake cuts the wait short (credential refused, shared key refused).
|
||||
await wake.WaitAsync(delay, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
firstAttempt.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private enum EnrollOutcome { Enrolled, Pending, Unsupported, Failed }
|
||||
|
||||
private TimeSpan PendingDelay(TimeSpan waited)
|
||||
{
|
||||
foreach (var step in PendingSchedule)
|
||||
{
|
||||
if (waited < step.Waited)
|
||||
return step.Retry
|
||||
+ TimeSpan.FromSeconds(jitter.NextDouble() * step.Jitter.TotalSeconds);
|
||||
}
|
||||
return PendingSchedule[^1].Retry;
|
||||
}
|
||||
|
||||
private async Task<(EnrollOutcome Outcome, int? RequestId)> TryEnrollAsync(
|
||||
HttpClient client, CancellationToken token)
|
||||
{
|
||||
var hostname = Environment.MachineName;
|
||||
var serial = ReadBiosSerial();
|
||||
if (string.IsNullOrWhiteSpace(serial))
|
||||
{
|
||||
logger.LogError("Enrollment skipped: no BIOS serial number from WMI Win32_BIOS.");
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
string nonce;
|
||||
try
|
||||
{
|
||||
nonce = store.GetOrCreateEnrollNonce();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogError(error, "Enrollment skipped: could not read or create enroll.json.");
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
var request = new
|
||||
{
|
||||
hostname,
|
||||
serialnumber = serial,
|
||||
deviceid = ReadDeviceId(),
|
||||
enrollnonce = nonce,
|
||||
agentversion = agentVersion,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.PostAsJsonAsync("api/endpoints/enroll", request, token);
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound
|
||||
&& AgentServerError.TryParse(body)?.Code is null or "" or "NOT_FOUND")
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Server has no enrollment endpoint (HTTP 404); staying on the shared key, retry in {Delay}.",
|
||||
UnsupportedRetry);
|
||||
return (EnrollOutcome.Unsupported, null);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Created)
|
||||
{
|
||||
var result = ParseResult(body);
|
||||
var secret = result.GetString("credential");
|
||||
if (secret is null || !secret.StartsWith("sdbe_", StringComparison.Ordinal))
|
||||
{
|
||||
logger.LogError("Enrollment returned 201 without a usable credential.");
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
|
||||
var credential = new AgentCredential
|
||||
{
|
||||
Credential = secret,
|
||||
AssetId = result.GetInt("assetid") ?? 0,
|
||||
IssuedAt = DateTime.UtcNow.ToString("o"),
|
||||
BaseUrl = config.BaseUrl.Trim().TrimEnd('/'),
|
||||
};
|
||||
try
|
||||
{
|
||||
credentials.Set(credential);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Shown once by the server; an admin must revoke and let it re-enroll.
|
||||
logger.LogError(error,
|
||||
"Enrolled as asset {AssetId} but could not store credential {Prefix}. " +
|
||||
"Revoke it in ShopDB so this PC can enroll again.",
|
||||
credential.AssetId, credential.Prefix);
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
return (EnrollOutcome.Enrolled, null);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Accepted)
|
||||
{
|
||||
var result = ParseResult(body);
|
||||
var requestId = result.GetInt("requestid");
|
||||
logger.LogInformation(
|
||||
"Enrollment pending (request {RequestId}): {Reason}.",
|
||||
requestId, result.GetString("reason") ?? "no reason given");
|
||||
return (EnrollOutcome.Pending, requestId);
|
||||
}
|
||||
|
||||
var refusal = AgentServerError.TryParse(body);
|
||||
if (refusal?.Code == "ENROLL_NONCE_MISMATCH")
|
||||
{
|
||||
logger.LogError(
|
||||
"Enrollment refused: the approved request belongs to a different enroll nonce. " +
|
||||
"An admin must reject it so this PC can request again.");
|
||||
// No request id: keeps the current pending run and its schedule.
|
||||
return (EnrollOutcome.Pending, null);
|
||||
}
|
||||
|
||||
logger.LogWarning("Enrollment failed: HTTP {Status} {Code} {Message}",
|
||||
(int)response.StatusCode, refusal?.Code, refusal?.Message);
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is HttpRequestException or TaskCanceledException or JsonException
|
||||
&& !token.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning("Enrollment request failed: {Message}", error.Message);
|
||||
return (EnrollOutcome.Failed, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts the bare body or the success envelope's data object.
|
||||
private static ResultReader ParseResult(string body)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object)
|
||||
root = data;
|
||||
return new ResultReader(root.Clone());
|
||||
}
|
||||
|
||||
private readonly struct ResultReader
|
||||
{
|
||||
private readonly JsonElement root;
|
||||
public ResultReader(JsonElement root) => this.root = root;
|
||||
|
||||
public string? GetString(string name)
|
||||
=> root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString() : null;
|
||||
|
||||
public int? GetInt(string name)
|
||||
=> root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
&& value.TryGetInt32(out var number)
|
||||
? number : null;
|
||||
}
|
||||
|
||||
private string? ReadBiosSerial()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS");
|
||||
foreach (var item in searcher.Get())
|
||||
{
|
||||
var value = item["SerialNumber"]?.ToString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value.Length > 128 ? value[..128] : value;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogWarning(error, "WMI Win32_BIOS query failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? ReadDeviceId()
|
||||
{
|
||||
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
|
||||
{
|
||||
try
|
||||
{
|
||||
using var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view);
|
||||
using var key = machine.OpenSubKey(AgentConfigProvider.RegistryPath);
|
||||
if (key?.GetValue(DeviceIdValue) is string value && !string.IsNullOrWhiteSpace(value))
|
||||
return value.Trim();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.LogDebug(error, "Registry read of {Value} in {View} view failed", DeviceIdValue, view);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
167
agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs
Normal file
167
agent/ShopDbEndpointAgent.Core/FleetAlertClient.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Polls the fleet alert channel to fetch active notifications for this endpoint.
|
||||
// The server may publish site-wide alerts (maintenance windows, policy changes)
|
||||
// or endpoint-specific warnings (outdated agent version, failed telemetry push).
|
||||
// The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle
|
||||
// and deserializes the JSON response into FleetAlert DTOs. Same auth as
|
||||
// telemetry and commands (AgentHttpFactory). In 1.20 the service calls this and
|
||||
// serves the tray over the local pipe (AgentPipeServer).
|
||||
public sealed class FleetAlertClient : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly ILogger logger;
|
||||
|
||||
public FleetAlertClient(AgentHttpFactory httpFactory, ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
http = httpFactory.CreateClient(AgentHttpChannel.FleetAlert);
|
||||
}
|
||||
|
||||
// Tray compatibility until tray 1.7.0 moves to AgentPipeClient: shared key
|
||||
// only, no credential. Remove with the last tray that calls it.
|
||||
public FleetAlertClient(AgentConfig config, ILogger logger)
|
||||
: this(new AgentHttpFactory(config, new SharedKeyOnlyProvider(config), logger), logger)
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class SharedKeyOnlyProvider : ICredentialProvider
|
||||
{
|
||||
public SharedKeyOnlyProvider(AgentConfig config)
|
||||
=> SharedKey = config.HasSharedKey() ? config.ApiKey : null;
|
||||
public AgentCredential? Current => null;
|
||||
public string? SharedKey { get; }
|
||||
public event EventHandler? Changed { add { } remove { } }
|
||||
public event EventHandler? EnrollmentNeeded { add { } remove { } }
|
||||
public void ReportRejected(string code, string? sentCredential) { }
|
||||
}
|
||||
|
||||
public async Task<List<FleetAlert>> FetchAlertsAsync(
|
||||
string hostname, CancellationToken token)
|
||||
{
|
||||
var url = "api/endpoints/alerts?hostname="
|
||||
+ Uri.EscapeDataString(hostname);
|
||||
using var response = await http.GetAsync(url, token);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
return new List<FleetAlert>();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<FleetAlertEnvelope>(
|
||||
body, JsonOptions);
|
||||
if (envelope?.Data is FleetAlertList parsed)
|
||||
foreach (var skipped in parsed.Skipped)
|
||||
logger.LogWarning("Skipped malformed fleet alert: {Reason}", skipped);
|
||||
return envelope?.Data ?? new List<FleetAlert>();
|
||||
}
|
||||
|
||||
public async Task AcknowledgeAlertAsync(
|
||||
int notificationId, int assetId, string hostname, CancellationToken token)
|
||||
{
|
||||
var url = $"api/endpoints/alerts/{notificationId}/acknowledge";
|
||||
var payload = new
|
||||
{
|
||||
assetid = assetId,
|
||||
hostname
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await http.PostAsync(url, content, token);
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new CollectorException((int)response.StatusCode, body);
|
||||
}
|
||||
|
||||
public void Dispose() => http.Dispose();
|
||||
}
|
||||
|
||||
// Server-sent strings may be JSON null; setters coerce to "" so consumers
|
||||
// never see null. Timeout null or <= 0 means no timeout.
|
||||
public sealed class FleetAlert
|
||||
{
|
||||
private string typeName = "";
|
||||
private string typeColor = "";
|
||||
private string title = "";
|
||||
private string message = "";
|
||||
|
||||
[JsonPropertyName("notificationid")] public int NotificationId { get; set; }
|
||||
[JsonPropertyName("type")] public string TypeName { get => typeName; set => typeName = value ?? ""; }
|
||||
[JsonPropertyName("typecolor")] public string TypeColor { get => typeColor; set => typeColor = value ?? ""; }
|
||||
[JsonPropertyName("title")] public string Title { get => title; set => title = value ?? ""; }
|
||||
[JsonPropertyName("message")] public string Message { get => message; set => message = value ?? ""; }
|
||||
[JsonPropertyName("timeout")] public int? Timeout { get; set; }
|
||||
|
||||
[JsonIgnore] public int TimeoutMinutes => Timeout is > 0 ? Timeout.Value : 0;
|
||||
}
|
||||
|
||||
public sealed class FleetAlertEnvelope
|
||||
{
|
||||
[JsonPropertyName("status")] public string Status { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
[JsonConverter(typeof(TolerantFleetAlertListConverter))]
|
||||
public List<FleetAlert>? Data { get; set; }
|
||||
}
|
||||
|
||||
// List of alerts that parsed, plus a note per element that did not. The
|
||||
// caller logs Skipped; one malformed alert must not drop the rest.
|
||||
public sealed class FleetAlertList : List<FleetAlert>
|
||||
{
|
||||
public List<string> Skipped { get; } = new();
|
||||
}
|
||||
|
||||
// Deserializes the data array one element at a time. An element that fails
|
||||
// to bind, or has no usable notificationid, is recorded and skipped.
|
||||
public sealed class TolerantFleetAlertListConverter : JsonConverter<List<FleetAlert>>
|
||||
{
|
||||
public override List<FleetAlert>? Read(
|
||||
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
return null;
|
||||
if (reader.TokenType != JsonTokenType.StartArray)
|
||||
throw new JsonException("Fleet alert data is not an array.");
|
||||
|
||||
var list = new FleetAlertList();
|
||||
var index = 0;
|
||||
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
|
||||
{
|
||||
using var element = JsonDocument.ParseValue(ref reader);
|
||||
try
|
||||
{
|
||||
var alert = element.RootElement.Deserialize<FleetAlert>(options);
|
||||
if (alert is null || alert.NotificationId <= 0)
|
||||
list.Skipped.Add($"element {index}: missing notificationid");
|
||||
else
|
||||
list.Add(alert);
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or InvalidOperationException)
|
||||
{
|
||||
list.Skipped.Add($"element {index}: {exception.Message}");
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer, List<FleetAlert> value, JsonSerializerOptions options)
|
||||
{
|
||||
JsonSerializer.Serialize(writer, value.ToArray(), options);
|
||||
}
|
||||
}
|
||||
190
agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs
Normal file
190
agent/ShopDbEndpointAgent.Core/FleetAlertStore.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Local JSON file store for tracking acknowledged fleet alerts.
|
||||
// Stores notification IDs that have been acknowledged so they don't
|
||||
// reappear in future polls. Lives under %LOCALAPPDATA% because the tray runs
|
||||
// as the standard operator, who cannot write to %ProgramData%. Read-modify-write
|
||||
// is serialized across processes by a named mutex.
|
||||
public sealed class FleetAlertStore
|
||||
{
|
||||
private const int MaxIds = 1000;
|
||||
private const string MutexName = @"Local\ShopDB-FleetAlertStore";
|
||||
private static readonly TimeSpan MutexWait = TimeSpan.FromSeconds(5);
|
||||
|
||||
private static readonly string DataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"ShopDB",
|
||||
"FleetAlerts");
|
||||
|
||||
private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json");
|
||||
|
||||
// Pre-1.6.11 location. Standard users could not write it, but an elevated
|
||||
// run may have left IDs there; merged once.
|
||||
private static readonly string LegacyStoreFile = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ShopDB",
|
||||
"FleetAlerts",
|
||||
"acknowledged-alerts.json");
|
||||
|
||||
private readonly object fileLock = new();
|
||||
private readonly ILogger logger;
|
||||
private HashSet<int> acknowledged = new();
|
||||
|
||||
public FleetAlertStore(ILogger logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
Load();
|
||||
}
|
||||
|
||||
public bool IsAcknowledged(int notificationId)
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
return acknowledged.Contains(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkAcknowledged(int notificationId)
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
WithFileMutex(() =>
|
||||
{
|
||||
// Merge what another instance may have written since our load.
|
||||
var onDisk = ReadFile(StoreFile);
|
||||
if (onDisk?.AcknowledgedIds is not null)
|
||||
acknowledged.UnionWith(onDisk.AcknowledgedIds);
|
||||
|
||||
acknowledged.Add(notificationId);
|
||||
Prune();
|
||||
Save(onDisk?.MigratedLegacy ?? true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
WithFileMutex(() =>
|
||||
{
|
||||
var stored = ReadFile(StoreFile);
|
||||
acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet<int>();
|
||||
|
||||
if (stored?.MigratedLegacy == true)
|
||||
return;
|
||||
|
||||
var legacy = ReadFile(LegacyStoreFile);
|
||||
var merged = legacy?.AcknowledgedIds?.Count(id => acknowledged.Add(id)) ?? 0;
|
||||
if (merged > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Merged {Count} acknowledged alert ID(s) from legacy store {Path}.",
|
||||
merged, LegacyStoreFile);
|
||||
Prune();
|
||||
}
|
||||
if (merged > 0 || stored is not null)
|
||||
Save(migratedLegacy: true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private StoredData? ReadFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return null;
|
||||
return JsonSerializer.Deserialize<StoredData>(File.ReadAllText(path));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not read acknowledged alerts store {Path}.", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Save(bool migratedLegacy)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(DataDir);
|
||||
|
||||
var data = new StoredData
|
||||
{
|
||||
AcknowledgedIds = acknowledged.ToList(),
|
||||
LastUpdated = DateTime.UtcNow,
|
||||
MigratedLegacy = migratedLegacy,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
|
||||
var temp = StoreFile + ".tmp";
|
||||
File.WriteAllText(temp, json);
|
||||
File.Move(temp, StoreFile, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Could not save acknowledged alerts store {Path}; acknowledged alerts may reappear.",
|
||||
StoreFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Runs action under the cross-process mutex. If the mutex cannot be had,
|
||||
// logs and runs anyway: a rare clobber beats never recording an ack.
|
||||
private void WithFileMutex(Action action)
|
||||
{
|
||||
Mutex? mutex = null;
|
||||
var owned = false;
|
||||
try
|
||||
{
|
||||
mutex = new Mutex(false, MutexName);
|
||||
try
|
||||
{
|
||||
owned = mutex.WaitOne(MutexWait);
|
||||
}
|
||||
catch (AbandonedMutexException)
|
||||
{
|
||||
owned = true;
|
||||
}
|
||||
if (!owned)
|
||||
logger.LogWarning("Timed out waiting for fleet alert store mutex; proceeding without it.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not open fleet alert store mutex; proceeding without it.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (owned)
|
||||
mutex!.ReleaseMutex();
|
||||
mutex?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void Prune()
|
||||
{
|
||||
// Keep only newest IDs to prevent unbounded growth
|
||||
if (acknowledged.Count > MaxIds)
|
||||
acknowledged = acknowledged.OrderByDescending(id => id).Take(MaxIds).ToHashSet();
|
||||
}
|
||||
|
||||
private sealed class StoredData
|
||||
{
|
||||
public List<int> AcknowledgedIds { get; set; } = new();
|
||||
public DateTime LastUpdated { get; set; }
|
||||
public bool MigratedLegacy { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
@@ -18,7 +20,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 +40,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 +61,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 +147,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 +173,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 +186,93 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// SID of the active console user, so SYSTEM can grant that one account
|
||||
// access to a file it stages. Null with a reason when nobody is signed in.
|
||||
public static SecurityIdentifier? TryGetActiveUserSid(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 (WTSQueryUserToken failed): "
|
||||
+ new Win32Exception(Marshal.GetLastWin32Error()).Message;
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var identity = new WindowsIdentity(userToken);
|
||||
error = identity.User is null ? "Interactive user token carries no SID." : null;
|
||||
return identity.User;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = "Could not read the interactive user SID: " + exception.Message;
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (userToken != IntPtr.Zero)
|
||||
CloseHandle(userToken);
|
||||
}
|
||||
}
|
||||
|
||||
private enum SecurityImpersonationLevel
|
||||
{
|
||||
SecurityAnonymous,
|
||||
@@ -222,6 +341,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,
|
||||
|
||||
285
agent/ShopDbEndpointAgent.Core/ProtectedFileSystem.cs
Normal file
285
agent/ShopDbEndpointAgent.Core/ProtectedFileSystem.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// File access for paths the agent touches AS SYSTEM where a standard user could
|
||||
// otherwise interfere.
|
||||
//
|
||||
// %ProgramData% lets any user create files and folders beneath it, so a staging
|
||||
// path the agent does not lock down can be pre-created, swapped between the hash
|
||||
// check and execution, or replaced with a junction that redirects a SYSTEM read
|
||||
// or delete to an arbitrary file. Directories made here carry a protected DACL:
|
||||
// SYSTEM and BUILTIN\Administrators only, inheritance off. SIDs are well-known
|
||||
// strings so a localized OS resolves them the same way.
|
||||
internal static class ProtectedFileSystem
|
||||
{
|
||||
private static readonly SecurityIdentifier LocalSystemSid = new("S-1-5-18");
|
||||
private static readonly SecurityIdentifier AdministratorsSid = new("S-1-5-32-544");
|
||||
|
||||
private const uint GenericRead = 0x80000000;
|
||||
private const uint FileShareRead = 0x00000001;
|
||||
private const uint FileShareWrite = 0x00000002;
|
||||
private const uint FileShareDelete = 0x00000004;
|
||||
private const uint OpenExisting = 3;
|
||||
private const uint FileFlagOpenReparsePoint = 0x00200000;
|
||||
|
||||
// %ProgramData%\ShopDB\EndpointAgent. The Defender-excluded agent root.
|
||||
public static string AgentDataRoot => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"ShopDB", "EndpointAgent");
|
||||
|
||||
// Create or re-secure <AgentDataRoot>\<name>. readerSid, when set, gets
|
||||
// list/traverse on the directory itself (not inherited) so a process in that
|
||||
// user's session can run from it and open files it was granted explicitly.
|
||||
// Throws when the directory cannot be proven safe; callers fail closed.
|
||||
public static string EnsureDirectory(string name, SecurityIdentifier? readerSid = null)
|
||||
{
|
||||
var root = AgentDataRoot;
|
||||
Directory.CreateDirectory(root);
|
||||
RefuseReparseChain(root);
|
||||
|
||||
var path = Path.Combine(root, name);
|
||||
var attributes = TryGetAttributes(path);
|
||||
if (attributes is FileAttributes existing
|
||||
&& (existing.HasFlag(FileAttributes.ReparsePoint)
|
||||
|| !existing.HasFlag(FileAttributes.Directory)))
|
||||
{
|
||||
// Link or planted file at our name. Removing a link never touches
|
||||
// its target.
|
||||
if (existing.HasFlag(FileAttributes.Directory))
|
||||
Directory.Delete(path);
|
||||
else
|
||||
File.Delete(path);
|
||||
attributes = null;
|
||||
}
|
||||
|
||||
if (attributes is null)
|
||||
new DirectoryInfo(path).Create(DirectorySecurityFor(readerSid));
|
||||
|
||||
var info = new DirectoryInfo(path);
|
||||
if (!IsTrustedOwner(info))
|
||||
{
|
||||
// Pre-created by someone else. The owner can always rewrite the
|
||||
// DACL, so re-securing is not enough: replace the directory.
|
||||
Directory.Delete(path, recursive: true);
|
||||
new DirectoryInfo(path).Create(DirectorySecurityFor(readerSid));
|
||||
info = new DirectoryInfo(path);
|
||||
if (!IsTrustedOwner(info))
|
||||
throw new IOException($"Staging directory {path} has an untrusted owner");
|
||||
}
|
||||
|
||||
if (IsReparsePoint(path))
|
||||
throw new IOException($"Staging directory {path} is a reparse point");
|
||||
|
||||
info.SetAccessControl(DirectorySecurityFor(readerSid));
|
||||
return path;
|
||||
}
|
||||
|
||||
// New file inside a protected directory. Any existing entry at the name is
|
||||
// removed first, then CreateNew so nothing can be slipped in between. The
|
||||
// DACL is protected; userSid, when set, is granted userRights explicitly.
|
||||
public static FileStream CreateFile(
|
||||
string path, SecurityIdentifier? userSid = null,
|
||||
FileSystemRights userRights = FileSystemRights.ReadAndExecute)
|
||||
{
|
||||
if (TryGetAttributes(path) is not null)
|
||||
File.Delete(path);
|
||||
|
||||
var security = new FileSecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
LocalSystemSid, FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
AdministratorsSid, FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
if (userSid is not null)
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
userSid, userRights, AccessControlType.Allow));
|
||||
|
||||
return new FileInfo(path).Create(
|
||||
FileMode.CreateNew, FileSystemRights.Read | FileSystemRights.Write,
|
||||
FileShare.Read, 4096, FileOptions.None, security);
|
||||
}
|
||||
|
||||
// Attributes of the entry itself (a link reports its own, not its target's).
|
||||
public static FileAttributes? TryGetAttributes(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.GetAttributes(path);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsReparsePoint(string path)
|
||||
=> TryGetAttributes(path) is FileAttributes attributes
|
||||
&& attributes.HasFlag(FileAttributes.ReparsePoint);
|
||||
|
||||
// Open for reading without following a reparse point at the final
|
||||
// component, and refuse if the opened object is one. Share mode is wide so
|
||||
// files a running application holds open still read.
|
||||
public static FileStream OpenReadNoFollow(string path)
|
||||
{
|
||||
var handle = CreateFileW(path, GenericRead,
|
||||
FileShareRead | FileShareWrite | FileShareDelete, IntPtr.Zero,
|
||||
OpenExisting, FileFlagOpenReparsePoint, IntPtr.Zero);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
var hr = Marshal.GetHRForLastWin32Error();
|
||||
handle.Dispose();
|
||||
throw Marshal.GetExceptionForHR(hr) ?? new IOException($"Could not open {path}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!GetFileInformationByHandle(handle, out var information))
|
||||
throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error())
|
||||
?? new IOException($"Could not query {path}");
|
||||
|
||||
var attributes = (FileAttributes)information.FileAttributes;
|
||||
if (attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
throw new UnauthorizedAccessException($"{path} is a reparse point");
|
||||
if (attributes.HasFlag(FileAttributes.Directory))
|
||||
throw new UnauthorizedAccessException($"{path} is a directory");
|
||||
|
||||
return new FileStream(handle, FileAccess.Read);
|
||||
}
|
||||
catch
|
||||
{
|
||||
handle.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// The path the handle really refers to after every junction and link in
|
||||
// its ancestry is resolved. Null when it cannot be read.
|
||||
public static string? FinalPath(SafeFileHandle handle)
|
||||
{
|
||||
var buffer = new StringBuilder(1024);
|
||||
var length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0)
|
||||
return null;
|
||||
if (length >= buffer.Capacity)
|
||||
{
|
||||
buffer = new StringBuilder((int)length + 1);
|
||||
length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
|
||||
if (length == 0 || length >= buffer.Capacity)
|
||||
return null;
|
||||
}
|
||||
|
||||
var final = buffer.ToString();
|
||||
if (final.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
|
||||
return @"\\" + final[8..];
|
||||
if (final.StartsWith(@"\\?\", StringComparison.Ordinal))
|
||||
return final[4..];
|
||||
return final;
|
||||
}
|
||||
|
||||
// Read at most maxBytes of text from the head of a file, BOM-aware.
|
||||
public static string ReadTextHeadNoFollow(string path, int maxBytes)
|
||||
{
|
||||
using var stream = OpenReadNoFollow(path);
|
||||
var buffer = new byte[(int)Math.Min(maxBytes, Math.Max(stream.Length, 0))];
|
||||
var total = 0;
|
||||
int read;
|
||||
while (total < buffer.Length
|
||||
&& (read = stream.Read(buffer, total, buffer.Length - total)) > 0)
|
||||
total += read;
|
||||
|
||||
using var reader = new StreamReader(
|
||||
new MemoryStream(buffer, 0, total), Encoding.UTF8, true);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
// Delete the entry itself. DeleteFile removes a link, never its target;
|
||||
// a directory at a file path is left alone.
|
||||
public static void DeleteFileNoFollow(string path)
|
||||
{
|
||||
if (TryGetAttributes(path) is not FileAttributes attributes)
|
||||
return;
|
||||
if (attributes.HasFlag(FileAttributes.Directory))
|
||||
throw new IOException($"{path} is a directory, not a file");
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
private static DirectorySecurity DirectorySecurityFor(SecurityIdentifier? readerSid)
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
const InheritanceFlags inherit =
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
|
||||
security.AddAccessRule(new FileSystemAccessRule(LocalSystemSid,
|
||||
FileSystemRights.FullControl, inherit, PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(AdministratorsSid,
|
||||
FileSystemRights.FullControl, inherit, PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
if (readerSid is not null)
|
||||
security.AddAccessRule(new FileSystemAccessRule(readerSid,
|
||||
FileSystemRights.ReadAndExecute, InheritanceFlags.None,
|
||||
PropagationFlags.None, AccessControlType.Allow));
|
||||
return security;
|
||||
}
|
||||
|
||||
private static bool IsTrustedOwner(DirectoryInfo info)
|
||||
{
|
||||
var owner = info.GetAccessControl(AccessControlSections.Owner)
|
||||
.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier;
|
||||
return owner is not null
|
||||
&& (owner.Equals(LocalSystemSid) || owner.Equals(AdministratorsSid));
|
||||
}
|
||||
|
||||
// A junction anywhere from ProgramData down to the agent root would move
|
||||
// every "protected" directory somewhere else.
|
||||
private static void RefuseReparseChain(string root)
|
||||
{
|
||||
var programData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.CommonApplicationData).TrimEnd('\\');
|
||||
var current = Path.GetFullPath(root).TrimEnd('\\');
|
||||
while (current.Length > programData.Length
|
||||
&& current.StartsWith(programData, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (IsReparsePoint(current))
|
||||
throw new IOException($"{current} is a reparse point; refusing to stage under it");
|
||||
current = Path.GetDirectoryName(current) ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ByHandleFileInformation
|
||||
{
|
||||
public uint FileAttributes;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
|
||||
public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern SafeFileHandle CreateFileW(
|
||||
string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes,
|
||||
uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle file, out ByHandleFileInformation information);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle file, StringBuilder filePath, uint filePathLength, uint flags);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,17 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
<!-- DPAPI LocalMachine for the per-PC credential (CredentialStore). -->
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" 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" />
|
||||
<!-- FileSystem.AccessControl: protected DACLs on staging, credential and
|
||||
appsettings.json files (ProtectedFileSystem, AgentPipeServer). -->
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
51
agent/ShopDbEndpointAgent.Core/StartupJitter.cs
Normal file
51
agent/ShopDbEndpointAgent.Core/StartupJitter.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// One-time startup delay, shared by TelemetryWorker and CommandWorker.
|
||||
//
|
||||
// Both loops otherwise make their first request the instant the service starts,
|
||||
// and the service is installed start=auto - so a patch-day reboot has an entire
|
||||
// fleet arriving together, twice per machine (a telemetry report AND a command
|
||||
// poll). AgentConfig.JitterSeconds does not help: it spreads the gap BETWEEN
|
||||
// runs, not the first contact.
|
||||
//
|
||||
// Lives here rather than in either worker so the two cannot drift apart, and so
|
||||
// the clamp is enforced in one place.
|
||||
internal static class StartupJitter
|
||||
{
|
||||
// Ceiling on the configured value. A fat-fingered appsettings.json must not
|
||||
// be able to park an endpoint for hours before it first checks in: the
|
||||
// command channel is how an operator would correct it, so an unbounded
|
||||
// delay would take away the means of recovery.
|
||||
private const int MaxStartupJitterSeconds = 300;
|
||||
|
||||
public static TimeSpan Next(int configuredSeconds, ILogger logger)
|
||||
{
|
||||
// Warn rather than clamp silently: an operator who deliberately set a
|
||||
// large value and got a smaller one has config and behaviour disagreeing
|
||||
// with no way to tell why. A negative value is a mistake too, so it is
|
||||
// worth the same line.
|
||||
if (configuredSeconds > MaxStartupJitterSeconds)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"startupjitterseconds {Configured} exceeds the {Max}s ceiling; using {Max}s",
|
||||
configuredSeconds, MaxStartupJitterSeconds, MaxStartupJitterSeconds);
|
||||
}
|
||||
else if (configuredSeconds < 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"startupjitterseconds {Configured} is negative; startup jitter disabled",
|
||||
configuredSeconds);
|
||||
}
|
||||
|
||||
int spread = Math.Clamp(configuredSeconds, 0, MaxStartupJitterSeconds);
|
||||
if (spread == 0)
|
||||
return TimeSpan.Zero;
|
||||
|
||||
// Milliseconds, not whole seconds: 700 endpoints over a 30s window would
|
||||
// otherwise land in 30 clumps of ~23 simultaneous arrivals, which is the
|
||||
// same pile-up in miniature.
|
||||
return TimeSpan.FromMilliseconds(Random.Shared.Next(0, spread * 1000));
|
||||
}
|
||||
}
|
||||
293
agent/ShopDbEndpointAgent.Core/StrictModePreamble.cs
Normal file
293
agent/ShopDbEndpointAgent.Core/StrictModePreamble.cs
Normal file
@@ -0,0 +1,293 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ShopDbEndpointAgent.Core;
|
||||
|
||||
// Inserts the strict error-mode statement into a PowerShell script body.
|
||||
//
|
||||
// The statement cannot simply go first: using statements, [CmdletBinding()] and
|
||||
// param(...) must precede every other statement or the script stops parsing.
|
||||
// So the scanner walks the leading header (whitespace, # and <# #> comments
|
||||
// including #Requires and comment help, using lines, attributes, the param
|
||||
// block) and inserts on the SAME line as the first real statement, followed by
|
||||
// '; ', so every line number PowerShell reports still matches the stored body.
|
||||
// Anything it cannot read with confidence (named begin/process/end blocks,
|
||||
// subexpressions or here-strings inside the header, unbalanced brackets, bytes
|
||||
// that are not valid text) returns null and the caller skips injection.
|
||||
internal static class StrictModePreamble
|
||||
{
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
private static readonly UTF8Encoding Utf8WithBom = new(true);
|
||||
|
||||
private static readonly string[] NamedBlocks =
|
||||
{ "begin", "process", "end", "dynamicparam", "clean" };
|
||||
|
||||
// Returns the rewritten body as UTF-8 WITH BOM (Windows PowerShell 5.1
|
||||
// reads a BOM-less file as the ANSI code page), or null with a reason.
|
||||
public static byte[]? TryApply(byte[] body, string statement, out string? reason)
|
||||
{
|
||||
if (!TryDecode(body, out var text))
|
||||
{
|
||||
reason = "script is not valid UTF-8 or UTF-16 text";
|
||||
return null;
|
||||
}
|
||||
|
||||
var index = FindInsertionPoint(text, out reason);
|
||||
if (index < 0)
|
||||
return null;
|
||||
|
||||
var rewritten = text.Insert(index, statement + "; ");
|
||||
var preamble = Utf8WithBom.GetPreamble();
|
||||
var encoded = Utf8WithBom.GetBytes(rewritten);
|
||||
var result = new byte[preamble.Length + encoded.Length];
|
||||
preamble.CopyTo(result, 0);
|
||||
encoded.CopyTo(result, preamble.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryDecode(byte[] body, out string text)
|
||||
{
|
||||
text = "";
|
||||
try
|
||||
{
|
||||
if (body.Length >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF)
|
||||
text = StrictUtf8.GetString(body, 3, body.Length - 3);
|
||||
else if (body.Length >= 2 && body[0] == 0xFF && body[1] == 0xFE)
|
||||
text = new UnicodeEncoding(false, false, true).GetString(body, 2, body.Length - 2);
|
||||
else if (body.Length >= 2 && body[0] == 0xFE && body[1] == 0xFF)
|
||||
text = new UnicodeEncoding(true, false, true).GetString(body, 2, body.Length - 2);
|
||||
else
|
||||
text = StrictUtf8.GetString(body);
|
||||
return true;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the first statement after the script header, or -1.
|
||||
internal static int FindInsertionPoint(string text, out string? reason)
|
||||
{
|
||||
reason = null;
|
||||
var index = SkipTrivia(text, 0);
|
||||
while (index >= 0 && MatchWord(text, index, "using"))
|
||||
{
|
||||
var lineEnd = text.IndexOf('\n', index);
|
||||
index = SkipTrivia(text, lineEnd < 0 ? text.Length : lineEnd);
|
||||
}
|
||||
if (index < 0)
|
||||
return Uncertain("unterminated block comment in header", out reason);
|
||||
|
||||
var cursor = index;
|
||||
while (cursor < text.Length && text[cursor] == '[')
|
||||
{
|
||||
cursor = SkipBalanced(text, cursor);
|
||||
if (cursor < 0)
|
||||
return Uncertain("could not read attribute in header", out reason);
|
||||
cursor = SkipTrivia(text, cursor);
|
||||
if (cursor < 0)
|
||||
return Uncertain("unterminated block comment in header", out reason);
|
||||
}
|
||||
|
||||
if (MatchWord(text, cursor, "param"))
|
||||
{
|
||||
var open = SkipTrivia(text, cursor + "param".Length);
|
||||
if (open < 0 || open >= text.Length || text[open] != '(')
|
||||
return Uncertain("could not read param block", out reason);
|
||||
var close = SkipBalanced(text, open);
|
||||
if (close < 0)
|
||||
return Uncertain("could not read param block", out reason);
|
||||
index = SkipTrivia(text, close);
|
||||
if (index < 0)
|
||||
return Uncertain("unterminated block comment after param block", out reason);
|
||||
}
|
||||
// Brackets not followed by param are a type literal starting the first
|
||||
// statement, so index stays in front of them.
|
||||
|
||||
if (index >= text.Length)
|
||||
return Uncertain("no statements after the script header", out reason);
|
||||
|
||||
foreach (var block in NamedBlocks)
|
||||
{
|
||||
if (MatchWord(text, index, block))
|
||||
return Uncertain($"script uses a named '{block}' block", out reason);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static int Uncertain(string why, out string? reason)
|
||||
{
|
||||
reason = why;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Skip whitespace, line comments and block comments. -1 on an unterminated
|
||||
// block comment.
|
||||
private static int SkipTrivia(string text, int index)
|
||||
{
|
||||
while (index < text.Length)
|
||||
{
|
||||
var character = text[index];
|
||||
if (char.IsWhiteSpace(character) || character == '\uFEFF')
|
||||
{
|
||||
index++;
|
||||
}
|
||||
else if (character == '<' && index + 1 < text.Length && text[index + 1] == '#')
|
||||
{
|
||||
var end = text.IndexOf("#>", index + 2, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
return -1;
|
||||
index = end + 2;
|
||||
}
|
||||
else if (character == '#')
|
||||
{
|
||||
var lineEnd = text.IndexOf('\n', index);
|
||||
index = lineEnd < 0 ? text.Length : lineEnd + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
private static bool MatchWord(string text, int index, string word)
|
||||
{
|
||||
if (index < 0 || index + word.Length > text.Length)
|
||||
return false;
|
||||
if (string.Compare(text, index, word, 0, word.Length, StringComparison.OrdinalIgnoreCase) != 0)
|
||||
return false;
|
||||
var after = index + word.Length;
|
||||
if (after == text.Length)
|
||||
return true;
|
||||
var next = text[after];
|
||||
return !(char.IsLetterOrDigit(next) || next == '_' || next == '-');
|
||||
}
|
||||
|
||||
private static bool IsSingleQuote(char character)
|
||||
=> character is '\'' or '\u2018' or '\u2019' or '\u201A' or '\u201B';
|
||||
|
||||
private static bool IsDoubleQuote(char character)
|
||||
=> character is '"' or '\u201C' or '\u201D' or '\u201E';
|
||||
|
||||
// From an opening ( [ or { return the index just past its matching closer,
|
||||
// honoring quotes and comments. -1 when unsure.
|
||||
private static int SkipBalanced(string text, int index)
|
||||
{
|
||||
var closers = new Stack<char>();
|
||||
while (index < text.Length)
|
||||
{
|
||||
var character = text[index];
|
||||
switch (character)
|
||||
{
|
||||
case '(':
|
||||
closers.Push(')');
|
||||
index++;
|
||||
break;
|
||||
case '[':
|
||||
closers.Push(']');
|
||||
index++;
|
||||
break;
|
||||
case '{':
|
||||
closers.Push('}');
|
||||
index++;
|
||||
break;
|
||||
case ')':
|
||||
case ']':
|
||||
case '}':
|
||||
if (closers.Count == 0 || closers.Pop() != character)
|
||||
return -1;
|
||||
index++;
|
||||
if (closers.Count == 0)
|
||||
return index;
|
||||
break;
|
||||
case '`':
|
||||
index += 2;
|
||||
break;
|
||||
case '@' when index + 1 < text.Length
|
||||
&& (IsSingleQuote(text[index + 1]) || IsDoubleQuote(text[index + 1])):
|
||||
// Here-string; not worth reading in a header.
|
||||
return -1;
|
||||
case '<' when index + 1 < text.Length && text[index + 1] == '#':
|
||||
var end = text.IndexOf("#>", index + 2, StringComparison.Ordinal);
|
||||
if (end < 0)
|
||||
return -1;
|
||||
index = end + 2;
|
||||
break;
|
||||
case '#':
|
||||
if (index > 0 && (char.IsLetterOrDigit(text[index - 1]) || text[index - 1] == '_'))
|
||||
return -1;
|
||||
var lineEnd = text.IndexOf('\n', index);
|
||||
if (lineEnd < 0)
|
||||
return -1;
|
||||
index = lineEnd + 1;
|
||||
break;
|
||||
default:
|
||||
if (IsSingleQuote(character))
|
||||
{
|
||||
index = SkipSingleQuoted(text, index + 1);
|
||||
if (index < 0)
|
||||
return -1;
|
||||
}
|
||||
else if (IsDoubleQuote(character))
|
||||
{
|
||||
index = SkipDoubleQuoted(text, index + 1);
|
||||
if (index < 0)
|
||||
return -1;
|
||||
}
|
||||
else
|
||||
{
|
||||
index++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int SkipSingleQuoted(string text, int index)
|
||||
{
|
||||
while (index < text.Length)
|
||||
{
|
||||
if (IsSingleQuote(text[index]))
|
||||
{
|
||||
if (index + 1 < text.Length && IsSingleQuote(text[index + 1]))
|
||||
{
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int SkipDoubleQuoted(string text, int index)
|
||||
{
|
||||
while (index < text.Length)
|
||||
{
|
||||
var character = text[index];
|
||||
if (character == '`')
|
||||
{
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (character == '$' && index + 1 < text.Length && text[index + 1] == '(')
|
||||
return -1;
|
||||
if (IsDoubleQuote(character))
|
||||
{
|
||||
if (index + 1 < text.Length && IsDoubleQuote(text[index + 1]))
|
||||
{
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
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,12 +27,38 @@ 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 EnrollmentService? enrollment;
|
||||
private readonly Random jitter = new();
|
||||
|
||||
// Upper bound on holding first contact for the enrollment attempt.
|
||||
private static readonly TimeSpan EnrollmentWaitLimit = TimeSpan.FromSeconds(20);
|
||||
|
||||
// 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,
|
||||
EnrollmentService? enrollment = null)
|
||||
{
|
||||
this.enrollment = enrollment;
|
||||
this.collector = collector;
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
@@ -35,6 +66,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)
|
||||
@@ -43,6 +145,37 @@ public sealed class TelemetryWorker : BackgroundService
|
||||
"Endpoint agent {Version} -> {BaseUrl}, cadence {Cadence}s +/-{Jitter}s",
|
||||
agentVersion, config.BaseUrl, config.CadenceSeconds, config.JitterSeconds);
|
||||
|
||||
// Spread first contact across the fleet. JitterSeconds only spreads the
|
||||
// gap BETWEEN runs, so without this the whole fleet reports the instant
|
||||
// its service starts. See StartupJitter.
|
||||
var startupDelay = StartupJitter.Next(config.StartupJitterSeconds, logger);
|
||||
if (startupDelay > TimeSpan.Zero)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Delaying first telemetry report by {Delay}ms (startup jitter)",
|
||||
(int)startupDelay.TotalMilliseconds);
|
||||
try
|
||||
{
|
||||
await Task.Delay(startupDelay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (enrollment is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await enrollment.WaitForFirstAttemptAsync(EnrollmentWaitLimit, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool firstRun = true;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -111,6 +244,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 +260,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>
|
||||
|
||||
457
agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs
Normal file
457
agent/ShopDbEndpointAgent.Tray/FleetAlertForm.cs
Normal file
@@ -0,0 +1,457 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ShopDbEndpointAgent.Core;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Displays fleet-wide alert notifications, relayed by the local agent, in a toast-style
|
||||
// window. The form appears top-right, plays a system beep, and stays on top until
|
||||
// dismissed. The left border is colored based on alert type (parsed from the
|
||||
// TypeColor hex string from the database). The operator acknowledges the alert by
|
||||
// clicking the button or the X with the mouse. The form steals focus when shown,
|
||||
// so neither button is a tab stop, there is no Accept/Cancel button, keyboard
|
||||
// clicks are ignored, and mouse clicks are ignored until ArmDelay has passed:
|
||||
// keystrokes meant for another app must not acknowledge an unseen alert.
|
||||
// Closing any other way (server withdrew it, timeout, shutdown) is NOT an
|
||||
// acknowledgement.
|
||||
internal sealed class FleetAlertForm : Form
|
||||
{
|
||||
private const int MaxMessageLength = 8000;
|
||||
private const string DefaultTypeName = "Alert";
|
||||
private const string DefaultTitle = "(no title)";
|
||||
private static readonly TimeSpan ArmDelay = TimeSpan.FromMilliseconds(1500);
|
||||
private static readonly Color DefaultTypeColor = Color.Gray;
|
||||
|
||||
private readonly FleetAlert alert;
|
||||
private readonly AgentPipeClient? client;
|
||||
private readonly ILogger? logger;
|
||||
private System.Windows.Forms.Timer? countdownTimer;
|
||||
private System.Windows.Forms.Timer? statusCheckTimer;
|
||||
private readonly CancellationTokenSource statusCheckCancellation = new();
|
||||
private int remainingSeconds;
|
||||
private Label? countdownLabel;
|
||||
private TextBox? messageBox;
|
||||
private readonly List<Font> fonts = new();
|
||||
private DateTime armedAtUtc = DateTime.MaxValue;
|
||||
private bool acknowledged;
|
||||
|
||||
public event EventHandler? OnAcknowledgeClicked;
|
||||
|
||||
public FleetAlertForm(
|
||||
FleetAlert alert, AgentPipeClient? client = null, ILogger? logger = null)
|
||||
{
|
||||
this.alert = alert ?? throw new ArgumentNullException(nameof(alert));
|
||||
this.client = client;
|
||||
this.logger = logger;
|
||||
|
||||
SuspendLayout();
|
||||
AutoScaleDimensions = new SizeF(96F, 96F);
|
||||
AutoScaleMode = AutoScaleMode.Dpi;
|
||||
Text = string.Empty;
|
||||
FormBorderStyle = FormBorderStyle.None;
|
||||
TopMost = true;
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = FormStartPosition.Manual;
|
||||
ClientSize = new Size(400, 320);
|
||||
BackColor = Color.White;
|
||||
KeyPreview = false;
|
||||
|
||||
BuildLayout();
|
||||
ResumeLayout(false);
|
||||
|
||||
Load += FleetAlertForm_Load;
|
||||
Shown += FleetAlertForm_Shown;
|
||||
Resize += (s, e) => ApplyRoundedCorners();
|
||||
FormClosing += FleetAlertForm_FormClosing;
|
||||
}
|
||||
|
||||
private void FleetAlertForm_FormClosing(object? sender, FormClosingEventArgs e)
|
||||
{
|
||||
countdownTimer?.Stop();
|
||||
statusCheckTimer?.Stop();
|
||||
statusCheckCancellation.Cancel();
|
||||
}
|
||||
|
||||
private void FleetAlertForm_Load(object? sender, EventArgs e)
|
||||
{
|
||||
// Size is final (DPI scaled) by Load; place and shape here.
|
||||
var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080);
|
||||
Location = new Point(screen.Right - Width - 20, screen.Top + 20);
|
||||
ApplyRoundedCorners();
|
||||
|
||||
System.Media.SystemSounds.Beep.Play();
|
||||
|
||||
if (client != null)
|
||||
{
|
||||
StartStatusCheck();
|
||||
}
|
||||
}
|
||||
|
||||
private void FleetAlertForm_Shown(object? sender, EventArgs e)
|
||||
{
|
||||
armedAtUtc = DateTime.UtcNow + ArmDelay;
|
||||
if (messageBox != null)
|
||||
{
|
||||
ActiveControl = messageBox;
|
||||
messageBox.Select(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartStatusCheck()
|
||||
{
|
||||
statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 };
|
||||
statusCheckTimer.Tick += StatusCheckTimer_Tick;
|
||||
statusCheckTimer.Start();
|
||||
}
|
||||
|
||||
private void StatusCheckTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (client == null)
|
||||
return;
|
||||
|
||||
var token = statusCheckCancellation.Token;
|
||||
if (token.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var alerts = await client.GetAlertsAsync(token)
|
||||
.ConfigureAwait(false);
|
||||
var stillActive = alerts.Any(a => a.NotificationId == alert.NotificationId);
|
||||
|
||||
if (!stillActive && !token.IsCancellationRequested && IsHandleCreated && !IsDisposed)
|
||||
{
|
||||
BeginInvoke(new Action(() =>
|
||||
{
|
||||
statusCheckTimer?.Stop();
|
||||
Close();
|
||||
}));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// Form is closing
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Agent unreachable or refused: keep the form open
|
||||
logger?.LogDebug(exception,
|
||||
"Status check for fleet alert {NotificationId} failed.", alert.NotificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyRoundedCorners()
|
||||
{
|
||||
using var path = new GraphicsPath();
|
||||
int radius = LogicalToDeviceUnits(8);
|
||||
var rect = new Rectangle(0, 0, Width, Height);
|
||||
|
||||
path.AddArc(rect.X, rect.Y, radius, radius, 180, 90);
|
||||
path.AddArc(rect.Right - radius, rect.Y, radius, radius, 270, 90);
|
||||
path.AddArc(rect.Right - radius, rect.Bottom - radius, radius, radius, 0, 90);
|
||||
path.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90);
|
||||
path.CloseFigure();
|
||||
|
||||
var old = Region;
|
||||
Region = new Region(path);
|
||||
old?.Dispose();
|
||||
}
|
||||
|
||||
// Not wired (1.6.2 made alerts manual-only). If wired, a timeout closes the
|
||||
// form WITHOUT acknowledging, so the alert can be surfaced again.
|
||||
private void StartCountdown()
|
||||
{
|
||||
var minutes = alert.TimeoutMinutes;
|
||||
if (minutes <= 0)
|
||||
return;
|
||||
|
||||
remainingSeconds = minutes * 60;
|
||||
|
||||
countdownTimer = new System.Windows.Forms.Timer { Interval = 1000 };
|
||||
countdownTimer.Tick += CountdownTimer_Tick;
|
||||
countdownTimer.Start();
|
||||
|
||||
UpdateCountdownDisplay();
|
||||
}
|
||||
|
||||
private void CountdownTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
remainingSeconds--;
|
||||
UpdateCountdownDisplay();
|
||||
|
||||
if (remainingSeconds <= 0)
|
||||
{
|
||||
countdownTimer?.Stop();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCountdownDisplay()
|
||||
{
|
||||
if (countdownLabel != null)
|
||||
{
|
||||
int minutes = remainingSeconds / 60;
|
||||
int seconds = remainingSeconds % 60;
|
||||
countdownLabel.Text = $"Closes in {minutes}:{seconds:D2}";
|
||||
}
|
||||
}
|
||||
|
||||
private Font CreateFont(string family, float size, FontStyle style = FontStyle.Regular)
|
||||
{
|
||||
var font = new Font(family, size, style);
|
||||
fonts.Add(font);
|
||||
return font;
|
||||
}
|
||||
|
||||
private static string SafeText(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
private static string PrepareMessage(string? value)
|
||||
{
|
||||
var text = value ?? string.Empty;
|
||||
if (text.Length > MaxMessageLength)
|
||||
text = text.Substring(0, MaxMessageLength) + "...";
|
||||
return text.Replace("\r\n", "\n").Replace('\r', '\n').Replace("\n", "\r\n");
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
// Dock order: Fill added first, outermost edges added last.
|
||||
var contentPanel = new Panel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = Color.White,
|
||||
};
|
||||
Controls.Add(contentPanel);
|
||||
|
||||
var borderPanel = new Panel
|
||||
{
|
||||
BackColor = ParseTypeColor(alert.TypeColor),
|
||||
Dock = DockStyle.Left,
|
||||
Width = 5,
|
||||
};
|
||||
Controls.Add(borderPanel);
|
||||
|
||||
var messagePanel = new Panel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(20, 4, 12, 4),
|
||||
BackColor = Color.White,
|
||||
};
|
||||
contentPanel.Controls.Add(messagePanel);
|
||||
|
||||
messageBox = new TextBox
|
||||
{
|
||||
Text = PrepareMessage(alert.Message),
|
||||
Dock = DockStyle.Fill,
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
WordWrap = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
BorderStyle = BorderStyle.None,
|
||||
BackColor = Color.White,
|
||||
Font = CreateFont("Segoe UI", 9.75f),
|
||||
ForeColor = Color.FromArgb(85, 85, 85),
|
||||
TabIndex = 0,
|
||||
};
|
||||
messagePanel.Controls.Add(messageBox);
|
||||
|
||||
var titleLabel = new Label
|
||||
{
|
||||
Text = SafeText(alert.Title, DefaultTitle),
|
||||
Dock = DockStyle.Top,
|
||||
Height = 50,
|
||||
Padding = new Padding(16, 8, 16, 0),
|
||||
Font = CreateFont("Segoe UI", 11, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(51, 51, 51),
|
||||
AutoSize = false,
|
||||
AutoEllipsis = true,
|
||||
UseMnemonic = false,
|
||||
};
|
||||
contentPanel.Controls.Add(titleLabel);
|
||||
|
||||
var buttonPanel = new Panel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
Height = 46,
|
||||
BackColor = Color.White,
|
||||
};
|
||||
contentPanel.Controls.Add(buttonPanel);
|
||||
|
||||
var acknowledgeButton = new MouseOnlyButton(IsArmed)
|
||||
{
|
||||
Text = "Acknowledge",
|
||||
Width = 140,
|
||||
Height = 32,
|
||||
Left = (395 - 140) / 2,
|
||||
Top = 7,
|
||||
Anchor = AnchorStyles.None,
|
||||
BackColor = Color.FromArgb(76, 175, 80),
|
||||
ForeColor = Color.White,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
Font = CreateFont("Segoe UI", 9, FontStyle.Bold),
|
||||
Cursor = Cursors.Hand,
|
||||
TabStop = false,
|
||||
UseMnemonic = false,
|
||||
};
|
||||
acknowledgeButton.FlatAppearance.BorderSize = 0;
|
||||
acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73);
|
||||
acknowledgeButton.Click += AcknowledgeButton_Click;
|
||||
buttonPanel.Controls.Add(acknowledgeButton);
|
||||
|
||||
var footerPanel = new Panel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
Height = 40,
|
||||
BackColor = Color.White,
|
||||
};
|
||||
contentPanel.Controls.Add(footerPanel);
|
||||
|
||||
countdownLabel = new Label
|
||||
{
|
||||
Text = "Manual acknowledgment required",
|
||||
Dock = DockStyle.Fill,
|
||||
TextAlign = ContentAlignment.MiddleCenter,
|
||||
Font = CreateFont("Segoe UI", 8.25f),
|
||||
ForeColor = Color.FromArgb(153, 153, 153),
|
||||
UseMnemonic = false,
|
||||
};
|
||||
footerPanel.Controls.Add(countdownLabel);
|
||||
|
||||
var footerBorder = new Panel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 1,
|
||||
BackColor = Color.FromArgb(238, 238, 238),
|
||||
};
|
||||
footerPanel.Controls.Add(footerBorder);
|
||||
|
||||
var headerPanel = new Panel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 45,
|
||||
BackColor = Color.FromArgb(245, 245, 245),
|
||||
};
|
||||
contentPanel.Controls.Add(headerPanel);
|
||||
|
||||
var typeLabel = new Label
|
||||
{
|
||||
Text = SafeText(alert.TypeName, DefaultTypeName).ToUpperInvariant(),
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(12, 0, 0, 0),
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
AutoEllipsis = true,
|
||||
Font = CreateFont("Segoe UI", 10, FontStyle.Bold),
|
||||
ForeColor = Color.FromArgb(51, 51, 51),
|
||||
UseMnemonic = false,
|
||||
};
|
||||
headerPanel.Controls.Add(typeLabel);
|
||||
|
||||
var closeButton = new MouseOnlyButton(IsArmed)
|
||||
{
|
||||
Text = "X",
|
||||
Dock = DockStyle.Right,
|
||||
Width = 40,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
Font = CreateFont("Segoe UI", 11),
|
||||
ForeColor = Color.FromArgb(102, 102, 102),
|
||||
BackColor = Color.Transparent,
|
||||
Cursor = Cursors.Hand,
|
||||
TabStop = false,
|
||||
UseMnemonic = false,
|
||||
};
|
||||
closeButton.FlatAppearance.BorderSize = 0;
|
||||
closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224);
|
||||
closeButton.Click += CloseButton_Click;
|
||||
headerPanel.Controls.Add(closeButton);
|
||||
}
|
||||
|
||||
private bool IsArmed() => DateTime.UtcNow >= armedAtUtc;
|
||||
|
||||
private static Color ParseTypeColor(string? hexColor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hexColor))
|
||||
return DefaultTypeColor;
|
||||
|
||||
var hex = hexColor.Trim().TrimStart('#');
|
||||
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.AllowHexSpecifier,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var rgb))
|
||||
return DefaultTypeColor;
|
||||
|
||||
return Color.FromArgb((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF);
|
||||
}
|
||||
|
||||
private void Acknowledge()
|
||||
{
|
||||
if (acknowledged)
|
||||
return;
|
||||
acknowledged = true;
|
||||
|
||||
countdownTimer?.Stop();
|
||||
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void AcknowledgeButton_Click(object? sender, EventArgs e) => Acknowledge();
|
||||
|
||||
private void CloseButton_Click(object? sender, EventArgs e) => Acknowledge();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
countdownTimer?.Stop();
|
||||
countdownTimer?.Dispose();
|
||||
statusCheckTimer?.Stop();
|
||||
statusCheckTimer?.Dispose();
|
||||
statusCheckCancellation.Cancel();
|
||||
statusCheckCancellation.Dispose();
|
||||
foreach (var font in fonts)
|
||||
{
|
||||
font.Dispose();
|
||||
}
|
||||
fonts.Clear();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
// Raises Click only for a left mouse release on the button once armed.
|
||||
// Space, Enter, mnemonics and PerformClick all route through OnClick
|
||||
// without passing OnMouseUp, so they are dropped.
|
||||
private sealed class MouseOnlyButton : Button
|
||||
{
|
||||
private readonly Func<bool> isArmed;
|
||||
private bool inMouseUp;
|
||||
|
||||
public MouseOnlyButton(Func<bool> isArmed)
|
||||
{
|
||||
this.isArmed = isArmed;
|
||||
}
|
||||
|
||||
protected override void OnMouseUp(MouseEventArgs mevent)
|
||||
{
|
||||
inMouseUp = true;
|
||||
try
|
||||
{
|
||||
base.OnMouseUp(mevent);
|
||||
}
|
||||
finally
|
||||
{
|
||||
inMouseUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClick(EventArgs e)
|
||||
{
|
||||
if (inMouseUp && isArmed())
|
||||
base.OnClick(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
269
agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs
Normal file
269
agent/ShopDbEndpointAgent.Tray/FleetAlertWorker.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ShopDbEndpointAgent.Core;
|
||||
|
||||
namespace ShopDbEndpointAgent.Tray;
|
||||
|
||||
// Asks the local agent for fleet-wide alerts every 60 seconds and displays new
|
||||
// alerts to the operator via FleetAlertForm. The agent holds the credential and
|
||||
// talks to the server; the tray only speaks the pipe. An alert is recorded in
|
||||
// FleetAlertStore only once the agent confirms the acknowledgement, so a failed
|
||||
// ack lets it reappear. Runs for the tray's lifetime on a background thread.
|
||||
internal sealed class FleetAlertWorker : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
|
||||
|
||||
// Agent down polls every cycle; warn once, then at most this often.
|
||||
private static readonly TimeSpan UnreachableLogInterval = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly AgentPipeClient client;
|
||||
private readonly FleetAlertStore store;
|
||||
private readonly ILogger logger;
|
||||
private readonly SynchronizationContext? uiContext;
|
||||
private readonly CancellationTokenSource cancellation;
|
||||
private readonly Thread workerThread;
|
||||
|
||||
// Set and cleared on the UI thread only.
|
||||
private FleetAlertForm? openForm;
|
||||
|
||||
// Poller thread only.
|
||||
private DateTime lastUnreachableLogUtc = DateTime.MinValue;
|
||||
private int unreachableCount;
|
||||
|
||||
public FleetAlertWorker(
|
||||
AgentPipeClient client,
|
||||
FleetAlertStore store,
|
||||
ILogger logger,
|
||||
SynchronizationContext? uiContext = null)
|
||||
{
|
||||
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
this.store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.uiContext = uiContext ?? SynchronizationContext.Current;
|
||||
cancellation = new CancellationTokenSource();
|
||||
|
||||
workerThread = new Thread(Run)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "ShopDB fleet alert poller",
|
||||
};
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Fleet alert worker starting; first poll in {Delay}, then every {Interval}.",
|
||||
StartupDelay, PollInterval);
|
||||
workerThread.Start();
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
var token = cancellation.Token;
|
||||
if (token.WaitHandle.WaitOne(StartupDelay))
|
||||
return;
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
PollAndDisplayAlerts(token);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (AgentPipeException exception)
|
||||
{
|
||||
LogUnreachable(exception);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle.");
|
||||
}
|
||||
|
||||
if (token.WaitHandle.WaitOne(PollInterval))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private void LogUnreachable(AgentPipeException exception)
|
||||
{
|
||||
unreachableCount++;
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - lastUnreachableLogUtc < UnreachableLogInterval)
|
||||
return;
|
||||
lastUnreachableLogUtc = now;
|
||||
logger.LogWarning(
|
||||
"Could not get fleet alerts from the agent ({Count} failed poll(s)): {Error}. "
|
||||
+ "Retrying on next cycle.", unreachableCount, exception.Message);
|
||||
}
|
||||
|
||||
private void PollAndDisplayAlerts(CancellationToken token)
|
||||
{
|
||||
var alerts = client.GetAlertsAsync(token).GetAwaiter().GetResult();
|
||||
if (unreachableCount > 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Agent answered the alert poll again after {Count} failed poll(s).", unreachableCount);
|
||||
unreachableCount = 0;
|
||||
lastUnreachableLogUtc = DateTime.MinValue;
|
||||
}
|
||||
if (alerts is FleetAlertList { Skipped.Count: > 0 } parsed)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Skipped {Count} malformed fleet alert(s): {Details}",
|
||||
parsed.Skipped.Count, string.Join("; ", parsed.Skipped));
|
||||
}
|
||||
if (alerts.Count == 0)
|
||||
{
|
||||
logger.LogDebug("No active fleet alerts.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newAlerts = alerts
|
||||
.Where(alert => !store.IsAcknowledged(alert.NotificationId))
|
||||
.ToList();
|
||||
|
||||
if (newAlerts.Count == 0)
|
||||
{
|
||||
logger.LogDebug("All {Count} fleet alerts already acknowledged.", alerts.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("Found {Count} new fleet alert(s).", newAlerts.Count);
|
||||
|
||||
foreach (var alert in newAlerts)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
ShowAlertOnUIThread(alert, token);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAlertOnUIThread(FleetAlert alert, CancellationToken token)
|
||||
{
|
||||
var shown = new ManualResetEventSlim(false);
|
||||
Exception? formException = null;
|
||||
|
||||
var showAction = new SendOrPostCallback(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
using var form = new FleetAlertForm(alert, client, logger);
|
||||
form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token);
|
||||
openForm = form;
|
||||
try
|
||||
{
|
||||
form.ShowDialog();
|
||||
}
|
||||
finally
|
||||
{
|
||||
openForm = null;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
formException = exception;
|
||||
}
|
||||
finally
|
||||
{
|
||||
shown.Set();
|
||||
}
|
||||
});
|
||||
|
||||
if (uiContext is not null)
|
||||
uiContext.Post(showAction, null);
|
||||
else
|
||||
showAction(null);
|
||||
|
||||
shown.Wait(token);
|
||||
|
||||
if (formException is not null)
|
||||
{
|
||||
logger.LogWarning(formException,
|
||||
"Could not display fleet alert {NotificationId}.", alert.NotificationId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Fleet alert {NotificationId} acknowledged; sending to the agent.", alert.NotificationId);
|
||||
|
||||
// Off the UI thread. Recorded locally only on Ok, so a failed ack
|
||||
// shows the alert again on a later poll instead of losing it.
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await client.AckAlertAsync(alert.NotificationId, token);
|
||||
if (response.Ok)
|
||||
{
|
||||
store.MarkAcknowledged(alert.NotificationId);
|
||||
logger.LogInformation("Fleet alert {NotificationId} acknowledged by the agent.",
|
||||
alert.NotificationId);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Agent did not acknowledge fleet alert {NotificationId}: {Error}. It may be shown again.",
|
||||
alert.NotificationId, response.Error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Acknowledgement of fleet alert {NotificationId} cancelled by shutdown.",
|
||||
alert.NotificationId);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception,
|
||||
"Could not acknowledge fleet alert {NotificationId}; it may be shown again.",
|
||||
alert.NotificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Closes a displayed alert without acknowledging it, so the form's status
|
||||
// check stops polling the agent on shutdown.
|
||||
public void CloseOpenAlert()
|
||||
{
|
||||
var form = openForm;
|
||||
if (form is null || form.IsDisposed)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (form.InvokeRequired)
|
||||
form.BeginInvoke(new Action(form.Close));
|
||||
else
|
||||
form.Close();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not close open fleet alert on shutdown.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
cancellation.Cancel();
|
||||
CloseOpenAlert();
|
||||
|
||||
if (workerThread.IsAlive)
|
||||
{
|
||||
if (!workerThread.Join(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
logger.LogWarning("Fleet alert worker thread did not exit within timeout.");
|
||||
}
|
||||
}
|
||||
|
||||
cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,122 @@
|
||||
<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. -->
|
||||
<!-- 1.6.1: fix crash when clicking acknowledge button. The countdown timer
|
||||
is now properly stopped before the form closes to prevent disposal issues. -->
|
||||
<!-- 1.6.2: disable auto-dismiss timer. Fleet alerts now require manual
|
||||
acknowledgment only. Footer shows "Manual acknowledgment required" instead
|
||||
of countdown timer. -->
|
||||
<!-- 1.6.3: auto-close when notification becomes inactive. Fleet alert forms now
|
||||
check every 30 seconds if the notification is still active on the server and
|
||||
automatically close if it has ended or been deactivated. -->
|
||||
<!-- 1.6.4: fix UI thread deadlock. Acknowledgment and status checks now run async
|
||||
properly without blocking the UI thread. Acknowledgment is marked locally first,
|
||||
then posted to server in background to prevent lockups. -->
|
||||
<!-- 1.6.5: fix resource leaks and disposal issues. GraphicsPath properly disposed,
|
||||
Font objects tracked and disposed, worker thread joined before client disposal,
|
||||
Screen.PrimaryScreen null dereference prevented, status check cancellation token
|
||||
propagated to prevent orphaned HTTP requests. -->
|
||||
<!-- 1.6.6: coordinated release with agent 1.13.5 (no tray changes).
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.6.7: coordinated release with agent 1.14.1 (wilm merge).
|
||||
No tray code changes, but deployed together after wilm merge. -->
|
||||
<!-- 1.6.8: coordinated release with agent 1.14.2 (fleet alert scope fixes).
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.6.9: coordinated release with agent 1.17.0 (app delta optimization).
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.6.10: coordinated release with agent 1.18.0 (fingerprint computation).
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.6.11: fleet alert hardening. The toast steals focus, so its buttons are
|
||||
no longer tab stops, there is no Accept/Cancel button, and acknowledge
|
||||
needs a mouse click at least 1.5s after the form is shown: keystrokes
|
||||
meant for another app cannot acknowledge an unseen alert. Long messages
|
||||
scroll instead of being cut off and '&' is shown literally. A null or
|
||||
malformed alert field no longer throws away the whole poll. A timeout
|
||||
closes without acknowledging. The acknowledged store moves to
|
||||
%LOCALAPPDATA%\ShopDB\FleetAlerts (the operator could not write the
|
||||
ProgramData copy, so acks were lost every logon), merging the old file
|
||||
once. Shutdown no longer blocks on the poller's sleeps. -->
|
||||
<!-- 1.7.0: the tray holds no credential. Fleet alerts, alert acknowledgement,
|
||||
Send Status and Report an issue all go through the SYSTEM agent's local
|
||||
pipe (ShopDB.EndpointAgent.v1) instead of reading the collector key and
|
||||
config or shelling the agent exe with the once and report verbs, so a
|
||||
standard user on the bay can no longer lift the fleet key through the
|
||||
tray's access path. The state.json asset-id reader is gone (the agent
|
||||
knows its own identity). An unreachable agent shows the agent-not-running
|
||||
balloon, and alert polling logs at a throttled rate and retries. An alert
|
||||
is recorded as acknowledged only when the agent confirms it, so a failed
|
||||
ack lets it reappear. The collect-usercontext probe is unchanged.
|
||||
Requires the agent release that hosts the pipe.
|
||||
|
||||
1.7.1: no behaviour of its own. The fleet alert key rename landed
|
||||
after the 1.7.0 binary was built and shipped to the display scope,
|
||||
so the rebuild carries different bytes under the same version. The
|
||||
version moves so the two can be told apart. Pairs with agent
|
||||
1.20.1. -->
|
||||
<Version>1.7.1</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;
|
||||
@@ -9,11 +11,11 @@ namespace ShopDbEndpointAgent.Tray;
|
||||
// and headless-friendly: no main window, the icon is the whole UI surface until
|
||||
// the operator opens the ticket dialog.
|
||||
//
|
||||
// "Send Status now" reuses the exact production collection path by shelling out
|
||||
// to the installed shopdbagent.exe with --once. That keeps the collector key and
|
||||
// all collection logic in the service binary: the tray never holds a secret and
|
||||
// never duplicates wiring, it just asks the agent to report on demand so the
|
||||
// ShopDB monitor page reflects the box immediately instead of at next cadence.
|
||||
// "Send Status now", "Report an issue" and fleet alerts all go through the
|
||||
// SYSTEM agent's local pipe (AgentPipeClient). The agent holds the credential
|
||||
// and all collection logic: the tray never reads a key or config and never
|
||||
// duplicates wiring, it just asks the agent to act on demand so the ShopDB
|
||||
// monitor page reflects the box immediately instead of at next cadence.
|
||||
internal sealed class TrayContext : ApplicationContext
|
||||
{
|
||||
// The headless agent/service executable. The tray ships next to it in the
|
||||
@@ -25,8 +27,98 @@ internal sealed class TrayContext : ApplicationContext
|
||||
private readonly Icon brandIcon;
|
||||
private bool sending;
|
||||
|
||||
private const string AgentUnreachableText =
|
||||
"Could not reach the ShopDB Endpoint Agent. Is the ShopDB Endpoint Agent service running?";
|
||||
|
||||
private readonly AgentPipeClient pipeClient = new();
|
||||
|
||||
// Cancels in-flight pipe calls from menu handlers when the tray exits.
|
||||
private readonly CancellationTokenSource shutdownCancellation = new();
|
||||
|
||||
// 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;
|
||||
|
||||
// Fleet alerts are fetched through the agent pipe and displayed to the
|
||||
// operator as toast notifications in the top-right corner. The worker polls
|
||||
// every 60 seconds, filters out already-acknowledged alerts via the store,
|
||||
// and sends acknowledgements to the agent when the operator dismisses the form.
|
||||
private readonly FleetAlertStore? fleetAlertStore;
|
||||
private readonly FleetAlertWorker? fleetAlertWorker;
|
||||
|
||||
// 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 +137,239 @@ 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);
|
||||
}
|
||||
|
||||
// Fleet alert worker. Best-effort: if the worker fails to start, the tray
|
||||
// still runs and the other features work. An agent that is down is not a
|
||||
// startup failure; the worker retries the pipe every cycle.
|
||||
try
|
||||
{
|
||||
fleetAlertStore = new FleetAlertStore(
|
||||
logProvider.CreateLogger(nameof(FleetAlertStore)));
|
||||
fleetAlertWorker = new FleetAlertWorker(
|
||||
pipeClient,
|
||||
fleetAlertStore,
|
||||
logProvider.CreateLogger(nameof(FleetAlertWorker)),
|
||||
uiContext);
|
||||
fleetAlertWorker.Start();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception,
|
||||
"Could not start the fleet alert worker; fleet-wide alerts will not "
|
||||
+ "be displayed. Other tray features are unaffected.");
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -61,13 +386,7 @@ internal sealed class TrayContext : ApplicationContext
|
||||
includeScreenshot = dialog.IncludeScreenshot;
|
||||
}
|
||||
|
||||
var agentExe = ResolveAgentExe();
|
||||
if (agentExe is null)
|
||||
{
|
||||
ShowBalloon("Report an issue", "Could not find " + AgentExeName +
|
||||
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
|
||||
return;
|
||||
}
|
||||
var token = shutdownCancellation.Token;
|
||||
|
||||
// Capture AFTER the dialog closes so the report form is never in the
|
||||
// shot. Stage into the interactive operator's own profile
|
||||
@@ -97,16 +416,23 @@ internal sealed class TrayContext : ApplicationContext
|
||||
ShowBalloon("Report an issue", "Sending your report to ShopDB...",
|
||||
ToolTipIcon.Info);
|
||||
|
||||
int exitCode = await RunAgentAsync(agentExe, "--report \"" + payloadPath + "\"");
|
||||
if (exitCode == 0)
|
||||
// The agent checks the path is inside this user's own profile.
|
||||
var response = await pipeClient.SendReportAsync(payloadPath, token);
|
||||
if (response.Ok)
|
||||
ShowBalloon("Report an issue", "Your report was sent to ShopDB.",
|
||||
ToolTipIcon.Info);
|
||||
else if (response.Data is null)
|
||||
ShowBalloon("Report an issue", AgentUnreachableText, ToolTipIcon.Error);
|
||||
else
|
||||
ShowBalloon("Report an issue",
|
||||
"Report send failed (exit " + exitCode +
|
||||
"Report send failed (" + response.Error +
|
||||
"). See the agent log under ProgramData\\ShopDB.",
|
||||
ToolTipIcon.Warning);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// Tray is exiting.
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
ShowBalloon("Report an issue", "Could not send the report: " +
|
||||
@@ -145,23 +471,15 @@ internal sealed class TrayContext : ApplicationContext
|
||||
}
|
||||
}
|
||||
|
||||
// Runs shopdbagent.exe --once out of process so the operator can push a
|
||||
// fresh report without waiting for the next cadence tick. The exit code is
|
||||
// the agent's own contract: 0 = reported OK, non-zero = failed (bad key,
|
||||
// host removed, network) and the detail is in the rolling agent log.
|
||||
// Asks the agent over the pipe to run telemetry once so the operator can
|
||||
// push a fresh report without waiting for the next cadence tick. The agent
|
||||
// answers ok or an error; the detail is in the rolling agent log.
|
||||
private async void OnSendStatus(object? sender, EventArgs e)
|
||||
{
|
||||
if (sending)
|
||||
return;
|
||||
|
||||
var agentExe = ResolveAgentExe();
|
||||
if (agentExe is null)
|
||||
{
|
||||
ShowBalloon("Send Status", "Could not find " + AgentExeName +
|
||||
". Is the ShopDB Endpoint Agent installed?", ToolTipIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = shutdownCancellation.Token;
|
||||
sending = true;
|
||||
trayIcon.Text = "ShopDB Endpoint Agent (sending status...)";
|
||||
ShowBalloon("Send Status", "Sending this PC's status to ShopDB...",
|
||||
@@ -169,25 +487,45 @@ internal sealed class TrayContext : ApplicationContext
|
||||
|
||||
try
|
||||
{
|
||||
int exitCode = await RunAgentAsync(agentExe, "--once");
|
||||
if (exitCode == 0)
|
||||
// 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. sendstatus 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();
|
||||
|
||||
var response = await pipeClient.SendStatusAsync(token);
|
||||
if (response.Ok)
|
||||
ShowBalloon("Send Status", "Status sent to ShopDB.",
|
||||
ToolTipIcon.Info);
|
||||
else if (response.Data is null)
|
||||
ShowBalloon("Send Status", AgentUnreachableText, ToolTipIcon.Error);
|
||||
else
|
||||
ShowBalloon("Send Status",
|
||||
"Status send failed (exit " + exitCode +
|
||||
"Status send failed (" + response.Error +
|
||||
"). See the agent log under ProgramData\\ShopDB.",
|
||||
ToolTipIcon.Warning);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
// Tray is exiting.
|
||||
return;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
ShowBalloon("Send Status", "Could not run the agent: " +
|
||||
ShowBalloon("Send Status", "Could not reach the agent: " +
|
||||
error.Message, ToolTipIcon.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sending = false;
|
||||
trayIcon.Text = "ShopDB Endpoint Agent";
|
||||
if (!token.IsCancellationRequested)
|
||||
trayIcon.Text = "ShopDB Endpoint Agent";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +587,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 +683,19 @@ 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();
|
||||
|
||||
shutdownCancellation.Cancel();
|
||||
fleetAlertWorker?.Dispose();
|
||||
shutdownCancellation.Dispose();
|
||||
|
||||
trayIcon.Dispose();
|
||||
brandIcon.Dispose();
|
||||
}
|
||||
|
||||
@@ -11,26 +11,156 @@ using ShopDbEndpointAgent.Core;
|
||||
// --console run interactively in the foreground for debugging
|
||||
// --once collect and report exactly once, then exit (smoke test / cron)
|
||||
// --report <path> upload one operator issue report (JSON sidecar written by
|
||||
// the tray), then exit. Keeps the collector key in the
|
||||
// service so the tray never holds a secret.
|
||||
// the tray), then exit.
|
||||
// Not running as SYSTEM, --once and --report are pipe clients: they ask the
|
||||
// running service (AgentPipeServer) to do the work and never load a secret.
|
||||
//
|
||||
// Secrets are never read here: AgentConfigProvider layers the registry
|
||||
// (HKLM\SOFTWARE\GE\ShopDB: CollectorKey, BaseUrl) over the shipped, non-secret
|
||||
// appsettings.json. The executable is intentionally named shopdbagent.exe so it
|
||||
// appsettings.json, and the per-PC credential lives in CredentialStore. The executable is intentionally named shopdbagent.exe so it
|
||||
// never collides with third-party agent.exe processes/services (for example the
|
||||
// MTConnect agent this same build can monitor).
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Not SYSTEM: a user (the tray, or an operator at a prompt) cannot read the
|
||||
// credential or the key, so hand the request to the service over the pipe.
|
||||
// Ahead of the bootstrap logger for the same reason as the probe above.
|
||||
if ((runOnce || !string.IsNullOrWhiteSpace(reportPayloadPath)) && !IsRunningAsSystem())
|
||||
return await RunPipeClientAsync(runOnce, reportPayloadPath);
|
||||
|
||||
static bool IsRunningAsSystem()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return true;
|
||||
try
|
||||
{
|
||||
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||
return identity.IsSystem;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exit code is the tray's contract: 0 = done, non-zero = failed (reason on stderr).
|
||||
static async Task<int> RunPipeClientAsync(bool runOnce, string? reportPath)
|
||||
{
|
||||
var client = new AgentPipeClient();
|
||||
var response = runOnce
|
||||
? await client.SendStatusAsync(CancellationToken.None)
|
||||
: await client.SendReportAsync(Path.GetFullPath(reportPath!), CancellationToken.None);
|
||||
if (response.Ok)
|
||||
return 0;
|
||||
Console.Error.WriteLine("shopdbagent: " + response.Error);
|
||||
return response.Data is null ? 3 : 1;
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -41,15 +171,24 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
|
||||
? FileLoggerProvider.DefaultDirectory()
|
||||
: config.LogPath;
|
||||
|
||||
if (!config.IsUsable())
|
||||
// BaseUrl is the only hard requirement: an enrolled PC runs without the shared
|
||||
// key, and an unenrolled one can still request approval without it.
|
||||
if (string.IsNullOrWhiteSpace(config.BaseUrl))
|
||||
{
|
||||
bootstrapLogger.LogError(
|
||||
"Config incomplete: provision CollectorKey and BaseUrl in " +
|
||||
"HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl/apikey in appsettings.json for dev). " +
|
||||
"Config incomplete: provision BaseUrl in " +
|
||||
"HKLM\\SOFTWARE\\GE\\ShopDB (or baseurl in appsettings.json for dev). " +
|
||||
"See the registry lines above to tell 'missing' from 'access denied'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!config.HasSharedKey())
|
||||
{
|
||||
bootstrapLogger.LogWarning(
|
||||
"No CollectorKey provisioned. Fine once this PC is enrolled; until then " +
|
||||
"requests are unauthenticated and enrollment needs admin approval.");
|
||||
}
|
||||
|
||||
var baseUrlProblem = config.BaseUrlProblem();
|
||||
if (baseUrlProblem is not null)
|
||||
{
|
||||
@@ -65,8 +204,7 @@ try
|
||||
if (!string.IsNullOrWhiteSpace(reportPayloadPath))
|
||||
return await RunReportAsync(config, reportPayloadPath, logDirectory, faultMonitor);
|
||||
|
||||
await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
|
||||
return 0;
|
||||
return await RunHostAsync(config, agentVersion, logDirectory, forceConsole);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
@@ -74,7 +212,7 @@ catch (Exception error)
|
||||
return 1;
|
||||
}
|
||||
|
||||
static async Task RunHostAsync(AgentConfig config, string agentVersion,
|
||||
static async Task<int> RunHostAsync(AgentConfig config, string agentVersion,
|
||||
string logDirectory, bool forceConsole)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
@@ -85,10 +223,44 @@ static async Task RunHostAsync(AgentConfig config, string agentVersion,
|
||||
|
||||
ConfigureLogging(builder.Logging, logDirectory);
|
||||
RegisterCoreServices(builder.Services, config, agentVersion);
|
||||
|
||||
// Enrollment is registered (and so started) first; the workers wait briefly
|
||||
// for its first attempt so an enrollable PC reports on its own credential.
|
||||
builder.Services.AddSingleton(sp => new EnrollmentService(
|
||||
config,
|
||||
sp.GetRequiredService<AgentHttpFactory>(),
|
||||
sp.GetRequiredService<AgentCredentialProvider>(),
|
||||
sp.GetRequiredService<CredentialStore>(),
|
||||
agentVersion,
|
||||
sp.GetRequiredService<ILogger<EnrollmentService>>()));
|
||||
builder.Services.AddSingleton(sp => new AgentPipeServer(
|
||||
config,
|
||||
sp.GetRequiredService<TelemetryWorker>(),
|
||||
sp.GetRequiredService<CollectorClient>(),
|
||||
sp.GetRequiredService<FleetAlertClient>(),
|
||||
sp.GetRequiredService<ICredentialProvider>(),
|
||||
agentVersion,
|
||||
sp.GetRequiredService<ILogger<AgentPipeServer>>()));
|
||||
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<EnrollmentService>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<TelemetryWorker>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<CommandWorker>());
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<AgentPipeServer>());
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
var host = builder.Build();
|
||||
|
||||
// A newly issued credential reports straight away rather than at the next
|
||||
// cadence tick, so the server sees the PC switch over.
|
||||
var credentials = host.Services.GetRequiredService<AgentCredentialProvider>();
|
||||
var collectSignal = host.Services.GetRequiredService<CollectSignal>();
|
||||
credentials.Changed += (_, _) =>
|
||||
{
|
||||
if (credentials.Current is not null)
|
||||
collectSignal.Request();
|
||||
};
|
||||
|
||||
await host.RunAsync();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
|
||||
@@ -111,8 +283,8 @@ static async Task<int> RunOnceAsync(AgentConfig config, string agentVersion,
|
||||
}
|
||||
|
||||
// Uploads one operator issue report described by a tray-written JSON sidecar,
|
||||
// then deletes the payload folder. Runs out of process from the tray so the
|
||||
// collector key stays in the service binary. The exit code is the tray's
|
||||
// then deletes the sidecar. SYSTEM only; a user-context --report goes through
|
||||
// the pipe (RunPipeClientAsync) and the service validates the path. The exit code is the tray's
|
||||
// contract: 0 = uploaded, non-zero = failed (detail in the agent log).
|
||||
static async Task<int> RunReportAsync(AgentConfig config, string payloadPath,
|
||||
string logDirectory, AgentFaultMonitor faultMonitor)
|
||||
@@ -191,13 +363,60 @@ 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>()));
|
||||
|
||||
// Per-PC credential (if enrolled) or shared key, swapped at runtime. One
|
||||
// factory builds every HttpClient so pins and auth cannot be skipped.
|
||||
services.AddSingleton(sp => new CredentialStore(
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Credentials")));
|
||||
services.AddSingleton(sp => new AgentCredentialProvider(
|
||||
config,
|
||||
sp.GetRequiredService<CredentialStore>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Credentials")));
|
||||
services.AddSingleton<ICredentialProvider>(sp =>
|
||||
sp.GetRequiredService<AgentCredentialProvider>());
|
||||
services.AddSingleton(sp => new AgentHttpFactory(
|
||||
config,
|
||||
sp.GetRequiredService<ICredentialProvider>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger("Http")));
|
||||
|
||||
services.AddSingleton(sp => new CollectorClient(
|
||||
config, sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||
sp.GetRequiredService<AgentHttpFactory>(),
|
||||
sp.GetRequiredService<ILogger<CollectorClient>>()));
|
||||
|
||||
services.AddSingleton(sp => new FleetAlertClient(
|
||||
sp.GetRequiredService<AgentHttpFactory>(),
|
||||
sp.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(FleetAlertClient))));
|
||||
|
||||
services.AddSingleton(sp => new TelemetryWorker(
|
||||
sp.GetRequiredService<ICollector>(),
|
||||
@@ -206,23 +425,30 @@ 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>(),
|
||||
sp.GetService<EnrollmentService>()));
|
||||
|
||||
// Command channel: outbound long-poll for allowlisted WebUI commands, run
|
||||
// through the fail-closed executor. Hosted alongside telemetry but on its
|
||||
// own loop so a held-open poll never delays a report.
|
||||
services.AddSingleton(sp => new CommandClient(
|
||||
config, sp.GetRequiredService<ILogger<CommandClient>>()));
|
||||
sp.GetRequiredService<AgentHttpFactory>(),
|
||||
sp.GetRequiredService<ILogger<CommandClient>>()));
|
||||
|
||||
services.AddSingleton(sp => new CommandExecutor(
|
||||
sp.GetRequiredService<ILogger<CommandExecutor>>(),
|
||||
config));
|
||||
config,
|
||||
sp.GetRequiredService<AgentHttpFactory>()));
|
||||
|
||||
services.AddSingleton(sp => new CommandWorker(
|
||||
sp.GetRequiredService<CommandClient>(),
|
||||
sp.GetRequiredService<CommandExecutor>(),
|
||||
sp.GetRequiredService<ILogger<CommandWorker>>(),
|
||||
sp.GetRequiredService<CollectSignal>()));
|
||||
sp.GetRequiredService<CollectSignal>(),
|
||||
config,
|
||||
sp.GetService<EnrollmentService>()));
|
||||
}
|
||||
|
||||
static void ConfigureLogging(ILoggingBuilder logging, string logDirectory)
|
||||
@@ -231,3 +457,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,171 @@
|
||||
(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.13.0: run_script honors a per-script working directory. The script
|
||||
library gains an optional workingdirectory, the server carries it in the
|
||||
run_script descriptor, and ScriptDeliveryClaim gains WorkingDirectory
|
||||
(AGENT-CONTRACT.md). The point is corporate tooling shipped by Intune: a
|
||||
script that shells out to a vendor CLI needs to run beside that CLI, not
|
||||
in the staging folder. Empty means the existing staging path, so older
|
||||
packages and every existing script keep their current behavior - this is
|
||||
additive, hence minor. Fails closed: a named directory that does not
|
||||
exist on the endpoint aborts the run with a clear error rather than
|
||||
silently falling back to staging, because a script that ran in the wrong
|
||||
folder is worse than one that did not run. The directory confers no
|
||||
rights of its own - it is a cwd, not a grant, and the run still happens
|
||||
in the script's configured context. Server-side validation rejects UNC
|
||||
paths and traversal before the value is ever persisted. No tray change -
|
||||
tray stays 1.4.0. -->
|
||||
<!-- 1.12.0: startup jitter before first contact. Both loops previously made
|
||||
their first request the instant the service started, and the service is
|
||||
installed start=auto, so a patch reboot put the whole fleet on the wire
|
||||
together - twice per machine (a telemetry report AND a command poll).
|
||||
jitterseconds did not help: it spreads the gap BETWEEN runs, not first
|
||||
contact. A 700-endpoint burst drove TLS handshakes to 1482ms worst case
|
||||
and pinned CPU at 305% while the database sat idle at 3 active queries;
|
||||
spreading the same arrivals over 30s cut worst-case handshake to 63.7ms
|
||||
with no server-side change. New startupjitterseconds (default 30, 0
|
||||
disables) is applied by StartupJitter.Next in both TelemetryWorker and
|
||||
CommandWorker. Randomized in milliseconds rather than whole seconds
|
||||
because a 30s window at second granularity re-clumps a large fleet into
|
||||
30 buckets of simultaneous arrivals. Clamped to 300s and warned about
|
||||
when out of range: the command channel is how an operator would correct
|
||||
a bad value, so an unbounded delay would remove the means of recovery.
|
||||
Costs up to startupjitterseconds of extra latency before a rebooted
|
||||
endpoint picks up a queued command, on top of a reboot the machine was
|
||||
already unreachable for. The once-only console switch bypasses the delay
|
||||
entirely (it calls RunOnceAsync directly), so smoke tests stay instant.
|
||||
appsettings schema gains a key; older packages without it get the
|
||||
AgentConfig default. No tray change - tray stays 1.4.0. -->
|
||||
<!-- 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. -->
|
||||
<!-- 1.13.1: coordinated release with tray 1.6.0 (fleet alert styling update).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.2: coordinated release with tray 1.6.3 (auto-close when inactive, crash fix).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.3: coordinated release with tray 1.6.4 (deadlock fix).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.13.4: coordinated release with tray 1.6.5 (resource leak fixes).
|
||||
No agent code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
|
||||
command was judged solely on PowerShell's exit code, and PowerShell
|
||||
treats most errors as non-terminating: it writes the error, runs on, and
|
||||
still exits zero. An account-provisioning script therefore reported
|
||||
succeeded having provisioned nothing, and because no output was captured
|
||||
there was no evidence to contradict it. The claim now carries errormode
|
||||
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
|
||||
AFTER digest verification so the hash still validates delivered bytes.
|
||||
Default stays permissive so bodies authored against the old semantics
|
||||
keep working. stdout/stderr is captured on both paths - redirected
|
||||
streams for SYSTEM, a transcript file under the ShopFloor profile for
|
||||
user-session runs, since CreateProcessAsUser cannot inherit our pipes
|
||||
across the session boundary - always logged locally and returned to the
|
||||
server only on failure. Minor because the claim gains a field and an
|
||||
older agent ignoring it keeps today's behaviour. Supersedes the 1.13.1
|
||||
to 1.13.4 tray-coordination bumps merged from wj, which carried no agent
|
||||
code changes. Tray is at 1.6.5 from wj. -->
|
||||
<!-- 1.15.0: successful runs can now return their output to the server, gated
|
||||
by a per-script captureoutput flag on the claim. Failures still always
|
||||
return output; success previously never did, which left a green badge as
|
||||
the only evidence a run produced and no way to tell "did the work" from
|
||||
"PowerShell exited cleanly". Opt-in rather than always-on because output
|
||||
carries whatever the body printed, and that is not worth persisting
|
||||
server-side for every routine run. Minor because the claim gains a field
|
||||
that defaults false: an agent or server too old to know it keeps today's
|
||||
withholding behaviour, which is the safe direction to fail. -->
|
||||
<!-- 1.16.0: adds collect_log, which reads a plaintext log file from a
|
||||
caller-supplied path and returns its contents for the Log Collector tab.
|
||||
Strictly read-only: opened for reading with FileShare.ReadWrite|Delete,
|
||||
because the logs worth collecting are the ones a running application
|
||||
currently holds open and a stricter share mode fails on exactly those.
|
||||
Bounded rather than unlimited - only the last 2 MB is returned, and only
|
||||
for an allowlisted set of text extensions; a binary or oversized file is
|
||||
refused rather than mangled into an unreadable pane. The path is
|
||||
re-validated here even though the server already checked it, since this
|
||||
process runs as SYSTEM and the path arrived over the network: environment
|
||||
variables are expanded BEFORE the checks so %VAR% cannot smuggle a
|
||||
traversal or UNC path past them. Minor because it is a new action name -
|
||||
an older agent asked for it fails closed on the unknown-action default. -->
|
||||
<!-- 1.17.0: application inventory delta optimization. Backend adds
|
||||
app_fingerprint column (SHA256 of displayname|version|publisher) and
|
||||
switches from delete-all-insert-all to delta updates. Agent side not yet
|
||||
implemented - this release only fixes critical backend race conditions:
|
||||
advisory locks prevent TOCTOU races in both app delta and event dedup,
|
||||
UNIQUE constraint on (assetid, app_fingerprint) prevents duplicate
|
||||
fingerprints. Reduces database writes by 99% once agent sends fingerprints.
|
||||
Backend is backward compatible - legacy agents without fingerprints use
|
||||
delete-all-insert-all fallback. Minor for critical race condition fixes. -->
|
||||
<!-- 1.18.0: agent-side fingerprint computation. Computes SHA256 fingerprint
|
||||
for each application (displayname|displayversion|publisher) and includes
|
||||
in collector payload. Backend receives fingerprints and switches from
|
||||
delete-all-insert-all to delta mode (only touch changed apps). Expected
|
||||
impact: 99% reduction in database writes (1.92M/day to 19K/day). Matches
|
||||
backend algorithm implemented in 1.17.0. Minor for feature completion. -->
|
||||
<!-- 1.19.0: endpoint hardening. TLS without a pinned thumbprint now uses
|
||||
normal Windows chain validation instead of accepting any certificate.
|
||||
run_script and update_app stage into SYSTEM/Administrators-only
|
||||
directories under %ProgramData%\ShopDB\EndpointAgent with random file
|
||||
names, and the script hash is checked in memory and again over the
|
||||
exact bytes written, so a standard user can no longer swap a body
|
||||
between verification and execution. User-session transcripts moved out
|
||||
of the operator's profile into that protected area, and SYSTEM never
|
||||
follows a reparse point to read or delete them. SYSTEM scripts drain
|
||||
stdout/stderr while running (no pipe deadlock), cap captured output,
|
||||
and are killed with their process tree after 5 minutes. Strict mode
|
||||
inserts after using/#Requires/param headers, keeps line numbers, and
|
||||
writes UTF-8 with BOM. A user-session run with no exit code is a
|
||||
failure. collect_log refuses answer files, agent config/state, key
|
||||
material and reparse points, and rechecks the handle's final path.
|
||||
Certificate renewal at start is bounded to 15s and exits non-zero so
|
||||
SCM recovery restarts the service. -->
|
||||
<!-- 1.20.0: per-PC endpoint credentials. The shared fleet collector key was
|
||||
readable by any standard user on every bay, so one bay leaked the whole
|
||||
fleet. The agent now enrolls itself (POST /api/endpoints/enroll with
|
||||
hostname, BIOS serial, DeviceId and a persisted nonce) and receives its
|
||||
own credential, stored DPAPI LocalMachine under a SYSTEM/Administrators
|
||||
directory and sent as X-Endpoint-Credential. The shared key stays the
|
||||
fallback until the PC is enrolled, so the build also runs against an
|
||||
older server (a 404 on enroll parks enrollment for 6 hours). A revoked
|
||||
credential (CREDENTIAL_INVALID) is deleted and the PC re-enrolls without
|
||||
a restart, and a script or package download refused that way retries
|
||||
once. Server trust moves to SPKI SHA-256 pins (the ServerPins registry
|
||||
value when it exists, even empty; otherwise serverpins in
|
||||
appsettings.json), checked by one AgentHttpFactory
|
||||
that now builds every HttpClient, including script and package downloads
|
||||
that previously had their own unpinned handlers. The tray holds no
|
||||
secret any more: it reaches the service over the local named pipe
|
||||
ShopDB.EndpointAgent.v1 (ping, sendstatus, sendreport, alerts, ackalert),
|
||||
and the once and report switches become pipe clients when not SYSTEM. A report path must sit inside the
|
||||
caller's own profile. appsettings.json is restricted to SYSTEM and
|
||||
Administrators once the pipe is up. The 1.19 client-certificate renewal
|
||||
and expiry monitor are removed, and with them the non-zero exit at start.
|
||||
Pairs with tray 1.7.0.
|
||||
|
||||
1.20.1: no behaviour of its own. Two fixes landed after the 1.20.0
|
||||
binaries had already been built and shipped to the display scope -
|
||||
the fast retry while an enrollment is pending, and the fleet alert
|
||||
key rename - so the rebuild carries different bytes under the same
|
||||
version. Detection is by hash and copes, but two files calling
|
||||
themselves 1.20.0.0 cannot be told apart by anyone reading a
|
||||
version number, so the version moves. Pairs with tray 1.7.1. -->
|
||||
<Version>1.20.1</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"baseurl": "",
|
||||
"apikey": "REPLACE_WITH_COLLECTOR_KEY",
|
||||
"serverpins": [],
|
||||
"cadenceseconds": 900,
|
||||
"jitterseconds": 60,
|
||||
"startupjitterseconds": 30,
|
||||
"adoptonfirstrun": true,
|
||||
"utilitiespath": "",
|
||||
"logpath": "",
|
||||
|
||||
@@ -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"
|
||||
76
agent/test-fingerprint.ps1
Normal file
76
agent/test-fingerprint.ps1
Normal file
@@ -0,0 +1,76 @@
|
||||
# Test fingerprint computation matches Python backend
|
||||
# Expected: SHA256("Google Chrome|117.0.5938.132|Google LLC")
|
||||
|
||||
$code = @"
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
public class FingerprintTester
|
||||
{
|
||||
public static string ComputeFingerprint(string displayName, string displayVersion, string publisher)
|
||||
{
|
||||
var normalized = $"{displayName}|{displayVersion}|{publisher}";
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
Add-Type -TypeDefinition $code
|
||||
|
||||
# Test vectors from backend
|
||||
$tests = @(
|
||||
@{
|
||||
Name = "Google Chrome"
|
||||
Version = "117.0.5938.132"
|
||||
Publisher = "Google LLC"
|
||||
Description = "Standard application"
|
||||
},
|
||||
@{
|
||||
Name = "Microsoft Office"
|
||||
Version = "16.0.16827.20166"
|
||||
Publisher = "Microsoft Corporation"
|
||||
Description = "Office suite"
|
||||
},
|
||||
@{
|
||||
Name = "Test App No Version"
|
||||
Version = ""
|
||||
Publisher = "Test Publisher"
|
||||
Description = "Empty version string"
|
||||
},
|
||||
@{
|
||||
Name = "Test App Empty"
|
||||
Version = ""
|
||||
Publisher = ""
|
||||
Description = "Empty strings"
|
||||
}
|
||||
)
|
||||
|
||||
Write-Host "C# Fingerprint Algorithm Test" -ForegroundColor Cyan
|
||||
Write-Host "==============================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
foreach ($test in $tests) {
|
||||
$fp = [FingerprintTester]::ComputeFingerprint($test.Name, $test.Version, $test.Publisher)
|
||||
Write-Host "Test: $($test.Description)" -ForegroundColor Yellow
|
||||
Write-Host " Input: '$($test.Name)|$($test.Version)|$($test.Publisher)'"
|
||||
Write-Host " Fingerprint: $fp" -ForegroundColor Green
|
||||
Write-Host " Length: $($fp.Length) chars" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Verify format
|
||||
$testFp = [FingerprintTester]::ComputeFingerprint("Test", "1.0", "Publisher")
|
||||
if ($testFp.Length -eq 64) {
|
||||
Write-Host "Format check: PASS (64 characters)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Format check: FAIL (expected 64, got $($testFp.Length))" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($testFp -match '^[0-9a-f]{64}$') {
|
||||
Write-Host "Hex check: PASS (lowercase hexadecimal)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Hex check: FAIL (invalid characters)" -ForegroundColor Red
|
||||
}
|
||||
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