Fix user-context collection, split agent log destinations, widen upload cap
Mapped drives stopped reporting because the user-context path failed silently in three separate places, and none of them left a trace to read back. - Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running powershell.exe -File. A missing or quarantined script file fails BEFORE any statement runs, so the script's own try/catch never sees it and the only symptom is exit -196608. - Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken is privileged, endpoint protection blocks it, and on failure the tray (writer) and service (reader) silently disagree about the path. - Spool: consume after a successful parse, so a stopped tray cannot keep re-reporting stale mapped drives as current. Unparseable files are left in place for the next cycle. - Logging: user-session components cannot write to Program Files or to the SYSTEM-owned ProgramData log directory, and the file sink degrades to a silent no-op when it cannot create its directory. Tray and probe now log to C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the sink records a SinkProblem instead of swallowing UnauthorizedAccessException. - Installer: provision the operator spool/log directories with ShopFloor:Modify before the tray first runs, and stop the transcript via trap so a throw cannot orphan exactly the failed install worth reading. - Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was rejected on SIZE, but the size check returns before the PE is parsed, so the operator only ever saw a version-detection failure. AGENT-CONTRACT.md gains a normative section defining the three security contexts, their log destinations, the fixed spool path and consume-on-read. Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is identifiable from a log line.
This commit is contained in:
@@ -272,7 +272,13 @@ public sealed class EndpointCollector : ICollector
|
||||
return lastUserContext;
|
||||
}
|
||||
|
||||
var collected = userContextSpool.TryReadResult(resultPath);
|
||||
// Consume rather than peek: the file is removed once it has been read, so
|
||||
// a tray that has died cannot keep this endpoint reporting the same
|
||||
// mapped drives forever. lastUserContext still repeats the previous value
|
||||
// between successful reads, because the tray collects on a much slower
|
||||
// cadence than telemetry and dropping the key in between would read on
|
||||
// the server as "the drives went away".
|
||||
var collected = userContextSpool.ConsumeResult(resultPath);
|
||||
if (collected is not null)
|
||||
lastUserContext = collected;
|
||||
|
||||
|
||||
@@ -14,32 +14,76 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
private readonly string directory;
|
||||
private readonly LogLevel minLevel;
|
||||
private readonly int retentionDays;
|
||||
private readonly string filePrefix;
|
||||
private readonly bool stampFirst;
|
||||
private readonly object gate = new();
|
||||
private readonly ConcurrentDictionary<string, FileLogger> loggers = new();
|
||||
private string currentDateStamp = "";
|
||||
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", bool stampFirst = false)
|
||||
{
|
||||
this.directory = directory;
|
||||
this.minLevel = minLevel;
|
||||
this.retentionDays = retentionDays;
|
||||
this.filePrefix = filePrefix;
|
||||
this.stampFirst = stampFirst;
|
||||
// Best-effort: creating the log directory can fail on a locked-down host
|
||||
// or when a non-privileged user targets a SYSTEM-owned path. Never let
|
||||
// that abort construction; Write() degrades to a no-op if the sink is
|
||||
// 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 +129,11 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
writer?.Flush();
|
||||
writer?.Dispose();
|
||||
currentDateStamp = stamp;
|
||||
var path = Path.Combine(directory, $"agent-{stamp}.log");
|
||||
// stampFirst gives "<datetime>-trayagent.log"; the service keeps its
|
||||
// established "agent-<date>.log" so existing log collection still finds
|
||||
// it. Both roll daily and prune on the same pattern.
|
||||
var name = stampFirst ? $"{stamp}-{filePrefix}.log" : $"{filePrefix}-{stamp}.log";
|
||||
var path = Path.Combine(directory, name);
|
||||
writer = new StreamWriter(new FileStream(
|
||||
path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
|
||||
{
|
||||
@@ -99,7 +147,8 @@ public sealed class FileLoggerProvider : ILoggerProvider
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||
foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log"))
|
||||
var pattern = stampFirst ? $"*-{filePrefix}.log" : $"{filePrefix}-*.log";
|
||||
foreach (var file in Directory.EnumerateFiles(directory, pattern))
|
||||
{
|
||||
if (File.GetLastWriteTime(file) < cutoff)
|
||||
File.Delete(file);
|
||||
|
||||
@@ -31,6 +31,9 @@ public sealed class UserContextProbe
|
||||
private const string ProbeScript = """
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$result = [ordered]@{}
|
||||
# Must match UserContextSpool.TryGetResultPath on the reader side. Uses
|
||||
# USERPROFILE, which for the shop-floor operator resolves to
|
||||
# C:\Users\ShopFloor - the same fixed location the service reads.
|
||||
$spool = Join-Path $env:USERPROFILE 'ShopDB'
|
||||
$target = Join-Path $spool 'usercontext.json'
|
||||
$temporary = "$target.tmp"
|
||||
@@ -145,6 +148,10 @@ public sealed class UserContextProbe
|
||||
// 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)
|
||||
@@ -160,19 +167,34 @@ public sealed class UserContextProbe
|
||||
// failure must not take the tray or the agent process down with it.
|
||||
public bool Collect()
|
||||
{
|
||||
string? scriptPath = null;
|
||||
try
|
||||
{
|
||||
scriptPath = Path.Combine(
|
||||
Path.GetTempPath(), $"shopdb-usercontext-{Guid.NewGuid():N}.ps1");
|
||||
File.WriteAllText(scriptPath, ProbeScript);
|
||||
// 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(ProbeScript));
|
||||
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments =
|
||||
"-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "
|
||||
+ "\"" + scriptPath + "\"",
|
||||
"-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand "
|
||||
+ encoded,
|
||||
WorkingDirectory = Path.GetTempPath(),
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
@@ -196,8 +218,16 @@ public sealed class UserContextProbe
|
||||
|
||||
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}.", process.ExitCode);
|
||||
"The user-context probe exited with code {ExitCode}.{Detail}",
|
||||
process.ExitCode, detail);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -208,22 +238,6 @@ public sealed class UserContextProbe
|
||||
logger.LogWarning(exception, "The user-context probe failed.");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (scriptPath is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(scriptPath))
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A leftover temp script is harmless; it is uniquely named
|
||||
// and cleaned by the OS.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TryKill(System.Diagnostics.Process process)
|
||||
|
||||
@@ -47,16 +47,36 @@ public sealed class UserContextSpool
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
// Full path to the spool file for the signed-in user, or null (with a
|
||||
// reason) when nobody is signed in. No session means no session-scoped data
|
||||
// to collect, which is a normal state on an unattended machine, not a fault.
|
||||
// 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 Path.Combine(pinned, SpoolFolderName, ResultFileName);
|
||||
}
|
||||
|
||||
var profileDirectory =
|
||||
InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error);
|
||||
if (string.IsNullOrWhiteSpace(profileDirectory))
|
||||
{
|
||||
reason = error ?? "The interactive user profile could not be resolved.";
|
||||
reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, "
|
||||
+ "and the interactive profile could not be resolved either: "
|
||||
+ (error ?? "unknown error");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -159,6 +179,38 @@ public sealed class UserContextSpool
|
||||
}
|
||||
}
|
||||
|
||||
// Read the spool and then REMOVE it, so the next report can only carry a
|
||||
// result the tray produced after this read. Leaving a consumed file in place
|
||||
// means a tray that has stopped running keeps re-reporting the same drives
|
||||
// as though they were current, which is worse than reporting nothing: the
|
||||
// card looks healthy while the data is frozen.
|
||||
//
|
||||
// The removal is deliberately AFTER a successful parse. If the file was
|
||||
// mid-write (truncated JSON) it is left alone rather than destroyed, so a
|
||||
// read that raced the tray costs one cycle instead of a good result. The
|
||||
// collectedat age check still applies as a second bound for the window
|
||||
// between the tray writing and the service next waking up.
|
||||
public Dictionary<string, object?>? ConsumeResult(string resultPath)
|
||||
{
|
||||
var parsed = TryReadResult(resultPath);
|
||||
if (parsed is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(resultPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Non-fatal: the value was already read. It does mean the next cycle
|
||||
// may re-read the same result, which the age check will retire.
|
||||
logger.LogWarning(exception,
|
||||
"Read the user-context spool but could not remove it at {Path}", resultPath);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -21,7 +21,14 @@
|
||||
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.1.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. -->
|
||||
<Version>1.1.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;
|
||||
@@ -44,8 +46,27 @@ internal sealed class TrayContext : ApplicationContext
|
||||
private bool userContextStarted;
|
||||
private bool collectingUserContext;
|
||||
|
||||
// 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",
|
||||
stampFirst: true);
|
||||
logger = logProvider.CreateLogger("Tray");
|
||||
logger.LogInformation(
|
||||
"Tray {Version} started; user-context probe in {Delay}, then every {Interval}.",
|
||||
typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||
UserContextStartupDelay, UserContextInterval);
|
||||
|
||||
var menu = new ContextMenuStrip();
|
||||
menu.Items.Add("Send Status now", null, OnSendStatus);
|
||||
menu.Items.Add("Report an issue...", null, OnReportIssue);
|
||||
@@ -90,16 +111,39 @@ internal sealed class TrayContext : ApplicationContext
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
collectingUserContext = true;
|
||||
try
|
||||
{
|
||||
await RunAgentAsync(agentExe, "--collect-usercontext");
|
||||
logger.LogInformation("Running the user-context probe: {Agent}", agentExe);
|
||||
var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext");
|
||||
if (exitCode == 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"The user-context probe finished; spool written to {Spool}.",
|
||||
Path.Combine(
|
||||
FileLoggerProvider.ShopFloorProfileDirectory(), "ShopDB"));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"The user-context probe exited with code {ExitCode}.", exitCode);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Intentionally silent. The agent logs the detail to its own file.
|
||||
// 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.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -50,8 +50,18 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath)
|
||||
// diagnose.
|
||||
if (collectUserContext)
|
||||
{
|
||||
// Logs to the OPERATOR's profile, not the service's ProgramData directory.
|
||||
// This verb always runs as the signed-in user (the tray invokes it), and a
|
||||
// non-privileged user cannot write to the SYSTEM-owned log directory. The
|
||||
// file sink degrades to a no-op when it cannot create its directory, so
|
||||
// pointing this at the service location meant every probe failure was
|
||||
// discarded unread - which is exactly how a broken probe looked identical to
|
||||
// a probe that was never invoked.
|
||||
using var probeLoggerFactory = LoggerFactory.Create(logging =>
|
||||
logging.AddProvider(new FileLoggerProvider(logDirectory)));
|
||||
logging.AddProvider(new FileLoggerProvider(
|
||||
FileLoggerProvider.UserProfileLogDirectory(),
|
||||
filePrefix: "trayagent",
|
||||
stampFirst: true)));
|
||||
var probe = new UserContextProbe(
|
||||
probeLoggerFactory.CreateLogger<UserContextProbe>());
|
||||
return probe.Collect() ? 0 : 1;
|
||||
|
||||
@@ -7,6 +7,22 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>ShopDbEndpointAgent</RootNamespace>
|
||||
<AssemblyName>shopdbagent</AssemblyName>
|
||||
<!-- 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
|
||||
@@ -74,7 +90,7 @@
|
||||
(AgentConfig.LaunchPaths, shipped defaults overridable per site).
|
||||
1.1.0: generic, data-driven service_*/process_* actions carry their
|
||||
target over the wire, so new policies/apps need no agent rebuild. -->
|
||||
<Version>1.8.0</Version>
|
||||
<Version>1.8.1</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -157,10 +157,49 @@ function Uninstall-PreviousAgent {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
# Installer transcript. Deliberately SEPARATE from both the service log
|
||||
# (%ProgramData%\ShopDB\EndpointAgent\logs\agent-<date>.log) and the tray log
|
||||
# (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log): the installer runs
|
||||
# elevated and one-shot, and mixing its output into the service's rolling log
|
||||
# made an install indistinguishable from runtime behaviour when reading back a
|
||||
# failure. This runs as admin, so ProgramData is writable here.
|
||||
$installLogDir = Join-Path $env:ProgramData 'ShopDB\Logs'
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $installLogDir -Force | Out-Null
|
||||
$installLog = Join-Path $installLogDir (
|
||||
'{0}-shopdbinstalls.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||
Start-Transcript -Path $installLog -Append | Out-Null
|
||||
$transcriptStarted = $true
|
||||
}
|
||||
catch {
|
||||
# Never let logging block an install.
|
||||
$transcriptStarted = $false
|
||||
Write-Warning "could not start the install transcript: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# The shop-floor operator profile. The tray and the user-context probe run as
|
||||
# this local account on every shop-floor endpoint, and it is the one place both
|
||||
# the operator (writer) and the SYSTEM service (reader) can reach. Defined here
|
||||
# so the install can provision the directories and report their location.
|
||||
$shopFloorProfile = Join-Path (Join-Path $env:SystemDrive 'Users') 'ShopFloor'
|
||||
|
||||
# Stop the transcript on ANY terminating error, not just the success path. The
|
||||
# script throws on several validation and smoke-test failures, and those are
|
||||
# precisely the installs worth reading back afterwards - an orphaned transcript
|
||||
# would truncate exactly the failure it was started to capture.
|
||||
trap {
|
||||
if ($transcriptStarted) {
|
||||
try { Stop-Transcript | Out-Null } catch { }
|
||||
$script:transcriptStarted = $false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan
|
||||
Write-Host "BaseUrl : $BaseUrl"
|
||||
Write-Host "InstallDir : $InstallDir"
|
||||
Write-Host "ServiceName : $ServiceName"
|
||||
if ($transcriptStarted) { Write-Host "InstallLog : $installLog" }
|
||||
|
||||
# 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator
|
||||
# typically extracts installer.zip into Downloads, so every file carries a
|
||||
@@ -188,6 +227,43 @@ Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..."
|
||||
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl
|
||||
Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey
|
||||
|
||||
# 1a. Provision the user-context spool and tray log directories in the operator
|
||||
# profile, and grant the operator Modify on them.
|
||||
#
|
||||
# This is the handoff point between the two security contexts (AGENT-CONTRACT.md
|
||||
# section 5): the tray WRITES here as the ShopFloor operator, and the SYSTEM
|
||||
# service READS and then removes the spool. The operator cannot write to
|
||||
# Program Files or to the SYSTEM-owned ProgramData log directory, and the file
|
||||
# log sink degrades to a silent no-op when it cannot create its directory - so
|
||||
# without this step a tray-side failure leaves no trace anywhere on the machine.
|
||||
# Creating them here, elevated, means the directories exist and are writable
|
||||
# before the tray ever starts.
|
||||
Write-Host "[1a/5] Provisioning operator spool/log directories under $shopFloorProfile ..."
|
||||
if (Test-Path $shopFloorProfile) {
|
||||
foreach ($dir in @(
|
||||
(Join-Path $shopFloorProfile 'ShopDB'),
|
||||
(Join-Path $shopFloorProfile 'ShopDB\Logs'))) {
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$acl = Get-Acl $dir
|
||||
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
||||
'ShopFloor', 'Modify',
|
||||
'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
||||
$acl.SetAccessRule($rule)
|
||||
Set-Acl -Path $dir -AclObject $acl
|
||||
Write-Host " provisioned $dir (ShopFloor: Modify)"
|
||||
}
|
||||
catch {
|
||||
# Non-fatal: telemetry, commands and inventory are unaffected. Only
|
||||
# user-context (mapped drives, idle time) depends on this path.
|
||||
Write-Warning " could not provision $dir : $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning " no ShopFloor profile at $shopFloorProfile; user-context (mapped drives, idle time) will be unavailable until that user has logged in once."
|
||||
}
|
||||
|
||||
# 2. Copy agent files to the install directory. The agent and tray are
|
||||
# single-file self-contained executables, so the .exe plus appsettings.json is
|
||||
# the whole payload; there are no side-by-side runtime DLLs to copy.
|
||||
@@ -352,4 +428,10 @@ else {
|
||||
|
||||
Write-Host "`nInstall complete." -ForegroundColor Green
|
||||
Write-Host "Service '$ServiceName' is running and set to auto-start."
|
||||
Write-Host "Logs: $logDir"
|
||||
# The three log destinations are separate on purpose; see AGENT-CONTRACT.md.
|
||||
Write-Host "Service log : $logDir"
|
||||
Write-Host "Tray log : $(Join-Path $shopFloorProfile 'ShopDB\Logs')"
|
||||
if ($transcriptStarted) {
|
||||
Write-Host "Install log : $installLog"
|
||||
try { Stop-Transcript | Out-Null } catch { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user