mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
added agent self-update mechanism (catch old agents in the wild), fix startup summary dialog (CHX process trigger)
This commit is contained in:
@@ -190,6 +190,13 @@ namespace AutopilotMonitor.Agent.Core.Configuration
|
||||
/// </summary>
|
||||
public string EnrollmentSummaryBrandingImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in seconds the agent retries launching the enrollment summary dialog
|
||||
/// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
|
||||
/// 0 = no retry (single attempt). Default: 120.
|
||||
/// </summary>
|
||||
public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Whether diagnostics upload is configured for this tenant.
|
||||
/// When true, the agent requests a short-lived upload URL from the backend just before uploading.
|
||||
|
||||
+3
-1
@@ -663,7 +663,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
try { UploadEventsAsync().Wait(TimeSpan.FromSeconds(5)); } catch { }
|
||||
|
||||
// Launch in user session (fire-and-forget)
|
||||
var launched = UserSessionProcessLauncher.LaunchInUserSession(tempDialogExe, args, _logger);
|
||||
var launched = UserSessionProcessLauncher.LaunchInUserSession(
|
||||
tempDialogExe, args, _logger,
|
||||
_configuration.EnrollmentSummaryLaunchRetrySeconds);
|
||||
if (!launched)
|
||||
{
|
||||
_logger.Warning("Could not launch summary dialog in user session (no interactive session found)");
|
||||
|
||||
@@ -332,6 +332,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
_configuration.ShowEnrollmentSummary = config.ShowEnrollmentSummary;
|
||||
_configuration.EnrollmentSummaryTimeoutSeconds = config.EnrollmentSummaryTimeoutSeconds;
|
||||
_configuration.EnrollmentSummaryBrandingImageUrl = config.EnrollmentSummaryBrandingImageUrl;
|
||||
_configuration.EnrollmentSummaryLaunchRetrySeconds = config.EnrollmentSummaryLaunchRetrySeconds;
|
||||
_configuration.SendTraceEvents = config.SendTraceEvents;
|
||||
_enrollmentTracker?.UpdateSendTraceEvents(config.SendTraceEvents);
|
||||
|
||||
|
||||
+137
-31
@@ -26,8 +26,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
/// Launches the specified executable in the logged-in user's desktop session.
|
||||
/// Returns true if the process was launched successfully, false otherwise.
|
||||
/// This is fire-and-forget — the caller does not wait for the process to exit.
|
||||
/// If the process fails with 0x8007045A (desktop locked by credential UI),
|
||||
/// retries for up to maxRetrySeconds.
|
||||
/// </summary>
|
||||
public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger)
|
||||
public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger, int maxRetrySeconds = 120)
|
||||
{
|
||||
IntPtr userToken = IntPtr.Zero;
|
||||
IntPtr environment = IntPtr.Zero;
|
||||
@@ -89,45 +91,114 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
var threadSa = new NativeMethods.SECURITY_ATTRIBUTES();
|
||||
threadSa.nLength = Marshal.SizeOf(threadSa);
|
||||
|
||||
if (!NativeMethods.CreateProcessAsUser(
|
||||
userToken,
|
||||
null, // lpApplicationName (null, use command line)
|
||||
commandLine, // lpCommandLine
|
||||
ref procSa,
|
||||
ref threadSa,
|
||||
false, // bInheritHandles
|
||||
creationFlags,
|
||||
environment,
|
||||
workingDirectory,
|
||||
ref si,
|
||||
out var pi))
|
||||
// During Autopilot enrollment the user's desktop may be locked by the
|
||||
// CloudExperienceHost (CXH) process which hosts ESP + Windows Hello.
|
||||
// GUI processes fail with 0x8007045A (DLL init) when the desktop isn't accessible.
|
||||
//
|
||||
// Strategy: retry loop with integrated CXH monitoring.
|
||||
// - On 24H2+ and ≤22H2: CXH exits cleanly after Hello → we wake up immediately
|
||||
// (Process.WaitForExit instead of blind Thread.Sleep).
|
||||
// - On 23H2: CXH stays running even after Hello/desktop → WaitForExit times out
|
||||
// after retryInterval, behaves like a normal retry sleep.
|
||||
// This gives us instant wake-up when CXH cooperates, and a reliable fallback
|
||||
// when it doesn't — all within a single unified loop.
|
||||
const int retryIntervalSeconds = 10;
|
||||
var retryStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
int attempt = 0;
|
||||
|
||||
// One-time CXH lookup — track for the lifetime of the retry loop
|
||||
Process cxhProcess = null;
|
||||
if (maxRetrySeconds > 0)
|
||||
{
|
||||
logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})");
|
||||
return false;
|
||||
try
|
||||
{
|
||||
cxhProcess = FindProcessInSession("CloudExperienceHost", sessionId);
|
||||
if (cxhProcess != null)
|
||||
logger.Info($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " +
|
||||
$"in session {sessionId} — will monitor for exit during retries");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Close the thread handle — we don't need it
|
||||
NativeMethods.CloseHandle(pi.hThread);
|
||||
|
||||
logger.Info($"UserSessionProcessLauncher: Created process PID {pi.dwProcessId} in user session — verifying startup...");
|
||||
|
||||
// Verify the process actually started (CLR initialized, no immediate crash).
|
||||
// If the process dies within 3 seconds, the exit code tells us exactly why
|
||||
// (e.g. 0xC0000135 = DLL not found, 0xC000007B = invalid image format,
|
||||
// 0xE0434352 = .NET unhandled exception, 0x8007045A = DLL init failed).
|
||||
var waitResult = NativeMethods.WaitForSingleObject(pi.hProcess, 3000);
|
||||
if (waitResult != NativeMethods.WAIT_TIMEOUT)
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
|
||||
if (!NativeMethods.CreateProcessAsUser(
|
||||
userToken,
|
||||
null, // lpApplicationName (null, use command line)
|
||||
commandLine, // lpCommandLine
|
||||
ref procSa,
|
||||
ref threadSa,
|
||||
false, // bInheritHandles
|
||||
creationFlags,
|
||||
environment,
|
||||
workingDirectory,
|
||||
ref si,
|
||||
out var pi))
|
||||
{
|
||||
logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})");
|
||||
return false;
|
||||
}
|
||||
|
||||
NativeMethods.CloseHandle(pi.hThread);
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}, verifying startup...");
|
||||
|
||||
// Wait 3s to verify the process survives DLL init + CLR bootstrap.
|
||||
var waitResult = NativeMethods.WaitForSingleObject(pi.hProcess, 3000);
|
||||
if (waitResult == NativeMethods.WAIT_TIMEOUT)
|
||||
{
|
||||
// Process is still running after 3s — success
|
||||
NativeMethods.CloseHandle(pi.hProcess);
|
||||
logger.Info($"UserSessionProcessLauncher: Successfully launched PID {pi.dwProcessId} in user session" +
|
||||
(attempt > 1 ? $" (after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : ""));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process exited within 3s — check why
|
||||
NativeMethods.GetExitCodeProcess(pi.hProcess, out var exitCode);
|
||||
NativeMethods.CloseHandle(pi.hProcess);
|
||||
|
||||
// 0x8007045A = ERROR_DLL_INIT_FAILED — desktop locked by credential UI.
|
||||
// Retry until the desktop becomes accessible or we exceed the retry window.
|
||||
if (exitCode == 0x8007045A && retryStopwatch.Elapsed.TotalSeconds < maxRetrySeconds)
|
||||
{
|
||||
// If CXH is tracked and still running, wait for its exit instead of blind sleep.
|
||||
// On 24H2+/≤22H2: CXH exits → we wake up instantly.
|
||||
// On 23H2: CXH never exits → WaitForExit times out → same as regular retry interval.
|
||||
if (cxhProcess != null)
|
||||
{
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)...");
|
||||
if (cxhProcess.WaitForExit(retryIntervalSeconds * 1000))
|
||||
{
|
||||
logger.Info("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available");
|
||||
cxhProcess.Dispose();
|
||||
cxhProcess = null;
|
||||
System.Threading.Thread.Sleep(500); // brief delay for desktop init
|
||||
}
|
||||
// else: CXH still running (23H2 behavior) — retry anyway
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s...");
|
||||
System.Threading.Thread.Sleep(retryIntervalSeconds * 1000);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-retryable error, or retry timeout exceeded
|
||||
logger.Warning($"UserSessionProcessLauncher: Process PID {pi.dwProcessId} exited within 3s — " +
|
||||
$"exit code 0x{exitCode:X8} (startup crash or missing dependency)");
|
||||
$"exit code 0x{exitCode:X8}" +
|
||||
(attempt > 1 ? $" (gave up after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : ""));
|
||||
return false;
|
||||
}
|
||||
|
||||
NativeMethods.CloseHandle(pi.hProcess);
|
||||
logger.Info($"UserSessionProcessLauncher: Successfully launched process PID {pi.dwProcessId} in user session");
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
cxhProcess?.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -143,6 +214,41 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a process by name running in a specific session.
|
||||
/// Returns the first matching Process handle, or null if none found.
|
||||
/// </summary>
|
||||
private static Process FindProcessInSession(string processName, uint sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processes = Process.GetProcessesByName(processName);
|
||||
foreach (var proc in processes)
|
||||
{
|
||||
try
|
||||
{
|
||||
if ((uint)proc.SessionId == sessionId)
|
||||
{
|
||||
// Dispose the rest
|
||||
foreach (var other in processes)
|
||||
{
|
||||
if (other.Id != proc.Id)
|
||||
try { other.Dispose(); } catch { }
|
||||
}
|
||||
return proc;
|
||||
}
|
||||
proc.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
proc.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds an explorer.exe process running in a non-SYSTEM user session.
|
||||
/// Returns the Process handle, or null if none found.
|
||||
|
||||
@@ -56,6 +56,15 @@
|
||||
<Message Text="Auto-incremented build version to: $(Version)" Importance="high" />
|
||||
</Target>
|
||||
|
||||
<!-- Generate version.json into build output for self-update and deployment upload -->
|
||||
<Target Name="GenerateVersionJson" AfterTargets="Build">
|
||||
<WriteLinesToFile
|
||||
File="$(OutputPath)version.json"
|
||||
Lines='{ "version": "$(Version)" }'
|
||||
Overwrite="true" />
|
||||
<Message Text="Generated version.json: $(Version)" Importance="high" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AutopilotMonitor.Agent.Core\AutopilotMonitor.Agent.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
|
||||
@@ -65,6 +65,14 @@ namespace AutopilotMonitor.Agent
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// Self-update: check for newer agent version in blob storage and apply if available.
|
||||
// Priority: speed over update — 1s version check, 10s download timeout.
|
||||
// Any failure → continue with current version (never block startup).
|
||||
var agentDir = Environment.ExpandEnvironmentVariables(Constants.AgentDirectory);
|
||||
SelfUpdater.CleanupPreviousUpdate(agentDir, msg => { if (consoleMode) Console.WriteLine(msg); });
|
||||
SelfUpdater.CheckAndApplyUpdateAsync(GetAgentVersion(), agentDir, consoleMode).GetAwaiter().GetResult();
|
||||
// If we reach here, no update was applied — continue normal startup
|
||||
|
||||
try
|
||||
{
|
||||
var config = LoadConfiguration(args);
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// Fast self-update at agent startup: checks version.json in blob storage,
|
||||
/// downloads the ZIP if newer, swaps files (rename-trick for locked binaries),
|
||||
/// and restarts via PowerShell Wait-Process.
|
||||
///
|
||||
/// Design priority: speed over update — better to run the old version than delay startup.
|
||||
/// </summary>
|
||||
static class SelfUpdater
|
||||
{
|
||||
private const int VersionCheckTimeoutMs = 1000; // 1s — aggressive, skip if slow
|
||||
private const int DownloadTimeoutMs = 10000; // 10s — abort if too slow
|
||||
private const string OldFileSuffix = ".old";
|
||||
|
||||
/// <summary>
|
||||
/// Deletes leftover .old files from a previous self-update.
|
||||
/// Called early in startup before any other logic.
|
||||
/// </summary>
|
||||
public static void CleanupPreviousUpdate(string agentDir, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(agentDir))
|
||||
return;
|
||||
|
||||
foreach (var oldFile in Directory.GetFiles(agentDir, "*" + OldFileSuffix))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(oldFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: file may still be locked if previous process hasn't fully exited
|
||||
}
|
||||
}
|
||||
|
||||
// Also clean up any leftover staging directory
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
if (Directory.Exists(stagingDir))
|
||||
{
|
||||
try { Directory.Delete(stagingDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"Self-update cleanup warning: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for a newer agent version and applies the update if available.
|
||||
/// On success, restarts the process and never returns.
|
||||
/// On any failure, returns normally so the current version continues.
|
||||
/// </summary>
|
||||
public static async Task CheckAndApplyUpdateAsync(string currentVersion, string agentDir, bool consoleMode)
|
||||
{
|
||||
Action<string> log = msg =>
|
||||
{
|
||||
LogToFile(msg);
|
||||
if (consoleMode) Console.WriteLine(msg);
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Fetch version.json (1s timeout)
|
||||
var latestVersion = await GetLatestVersionAsync(log);
|
||||
if (latestVersion == null)
|
||||
return; // Could not determine latest version — continue with current
|
||||
|
||||
// Step 2: Compare versions
|
||||
if (!IsNewerVersion(currentVersion, latestVersion))
|
||||
{
|
||||
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
|
||||
return;
|
||||
}
|
||||
|
||||
log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
|
||||
|
||||
// Step 3: Download ZIP (10s timeout)
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip");
|
||||
if (!await DownloadZipAsync(zipPath, log))
|
||||
return;
|
||||
|
||||
// Step 4: Extract to staging directory
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
if (!ExtractToStaging(zipPath, stagingDir, log))
|
||||
return;
|
||||
|
||||
// Step 5: Validate staging
|
||||
var stagedExe = Path.Combine(stagingDir, "AutopilotMonitor.Agent.exe");
|
||||
if (!File.Exists(stagedExe))
|
||||
{
|
||||
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Swap files (rename locked → .old, copy new)
|
||||
if (!SwapFiles(agentDir, stagingDir, log))
|
||||
{
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 7: Clean up staging + temp ZIP
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
|
||||
// Step 8: Restart via PowerShell Wait-Process
|
||||
log($"Self-update: files swapped successfully, restarting agent (v{latestVersion})...");
|
||||
RestartAgent(agentDir, log);
|
||||
|
||||
// RestartAgent calls Environment.Exit — we should never reach here
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches version.json from blob storage and returns the version string.
|
||||
/// Returns null if the check fails or times out.
|
||||
/// </summary>
|
||||
private static async Task<string> GetLatestVersionAsync(Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var versionUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentVersionFileName}";
|
||||
|
||||
using (var client = new WebClient())
|
||||
{
|
||||
var downloadTask = client.DownloadStringTaskAsync(versionUrl);
|
||||
var timeoutTask = Task.Delay(VersionCheckTimeoutMs);
|
||||
|
||||
var completed = await Task.WhenAny(downloadTask, timeoutTask);
|
||||
if (completed == timeoutTask)
|
||||
{
|
||||
log("Self-update: version check timed out (1s) — skipping update");
|
||||
client.CancelAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await downloadTask;
|
||||
var obj = JObject.Parse(json);
|
||||
var version = obj["version"]?.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
log("Self-update: version.json has no 'version' field");
|
||||
return null;
|
||||
}
|
||||
|
||||
return version.Trim();
|
||||
}
|
||||
}
|
||||
catch (WebException ex) when ((ex.Response as HttpWebResponse)?.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
log("Self-update: version.json not found (404) — skipping update");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: version check failed — {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings. Returns true if latest is newer than current.
|
||||
/// </summary>
|
||||
private static bool IsNewerVersion(string current, string latest)
|
||||
{
|
||||
if (!Version.TryParse(current, out var currentVer))
|
||||
return false;
|
||||
if (!Version.TryParse(latest, out var latestVer))
|
||||
return false;
|
||||
|
||||
return latestVer > currentVer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the agent ZIP to a temp path. Returns false on failure.
|
||||
/// </summary>
|
||||
private static async Task<bool> DownloadZipAsync(string zipPath, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileName}";
|
||||
|
||||
// Clean up any previous download
|
||||
if (File.Exists(zipPath))
|
||||
File.Delete(zipPath);
|
||||
|
||||
using (var client = new WebClient())
|
||||
{
|
||||
var downloadTask = client.DownloadFileTaskAsync(zipUrl, zipPath);
|
||||
var timeoutTask = Task.Delay(DownloadTimeoutMs);
|
||||
|
||||
var completed = await Task.WhenAny(downloadTask, timeoutTask);
|
||||
if (completed == timeoutTask)
|
||||
{
|
||||
log("Self-update: ZIP download timed out (10s) — aborting update");
|
||||
client.CancelAsync();
|
||||
try { File.Delete(zipPath); } catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
await downloadTask; // Propagate any exception
|
||||
}
|
||||
|
||||
log($"Self-update: ZIP downloaded ({new FileInfo(zipPath).Length / 1024}KB)");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: ZIP download failed — {ex.Message}");
|
||||
try { File.Delete(zipPath); } catch { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the ZIP to the staging directory. Returns false on failure.
|
||||
/// </summary>
|
||||
private static bool ExtractToStaging(string zipPath, string stagingDir, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Clean up any previous staging
|
||||
if (Directory.Exists(stagingDir))
|
||||
Directory.Delete(stagingDir, true);
|
||||
|
||||
ZipFile.ExtractToDirectory(zipPath, stagingDir);
|
||||
log($"Self-update: ZIP extracted to staging ({Directory.GetFiles(stagingDir).Length} files)");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: ZIP extraction failed — {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swaps files from staging into the agent directory.
|
||||
/// For locked files: rename to .old (Windows allows renaming locked files), then copy new.
|
||||
/// Returns false if the swap fails critically.
|
||||
/// </summary>
|
||||
private static bool SwapFiles(string agentDir, string stagingDir, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stagedFiles = Directory.GetFiles(stagingDir);
|
||||
int swapped = 0;
|
||||
|
||||
foreach (var stagedFile in stagedFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(stagedFile);
|
||||
var targetPath = Path.Combine(agentDir, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
// Try direct overwrite first
|
||||
try
|
||||
{
|
||||
File.Copy(stagedFile, targetPath, overwrite: true);
|
||||
swapped++;
|
||||
continue;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File is locked — use rename trick
|
||||
}
|
||||
|
||||
// Rename locked file to .old (Windows allows this even for locked files)
|
||||
var oldPath = targetPath + OldFileSuffix;
|
||||
if (File.Exists(oldPath))
|
||||
{
|
||||
try { File.Delete(oldPath); } catch { }
|
||||
}
|
||||
|
||||
File.Move(targetPath, oldPath);
|
||||
}
|
||||
|
||||
File.Copy(stagedFile, targetPath);
|
||||
swapped++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: failed to swap {fileName} — {ex.Message}");
|
||||
// Continue with other files; partial update is better than no update
|
||||
// for non-critical files (configs, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
log($"Self-update: swapped {swapped}/{stagedFiles.Length} files");
|
||||
|
||||
// Critical check: the main exe must have been swapped
|
||||
var mainExe = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe");
|
||||
if (!File.Exists(mainExe))
|
||||
{
|
||||
log("Self-update: CRITICAL — main exe missing after swap, aborting");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: file swap failed — {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the agent using PowerShell Wait-Process (same pattern as SummaryDialog.SelfCleanup).
|
||||
/// Wait-Process uses OS handles — zero polling, exits within milliseconds of process termination.
|
||||
/// </summary>
|
||||
private static void RestartAgent(string agentDir, Action<string> log)
|
||||
{
|
||||
var pid = Process.GetCurrentProcess().Id;
|
||||
var agentExePath = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe");
|
||||
|
||||
// PowerShell Wait-Process: waits for our process to actually exit (no polling),
|
||||
// then immediately starts the new agent. 30s timeout as safety net.
|
||||
var psScript = $"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; " +
|
||||
$"& '{agentExePath}'";
|
||||
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(psScript));
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "powershell.exe",
|
||||
Arguments = $"-NoProfile -NonInteractive -EncodedCommand {encoded}",
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
Process.Start(psi);
|
||||
|
||||
log("Self-update: restart script launched, exiting current process");
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up staging directory and temp ZIP file.
|
||||
/// </summary>
|
||||
private static void CleanupStaging(string stagingDir, string zipPath)
|
||||
{
|
||||
try { if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, true); } catch { }
|
||||
try { if (File.Exists(zipPath)) File.Delete(zipPath); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a log line to the agent log file (logger isn't initialized yet at update time).
|
||||
/// </summary>
|
||||
private static void LogToFile(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logPath = Path.Combine(logDir, "agent.log");
|
||||
File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] INFO: {message}\r\n");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort logging — never block on log failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
347
|
||||
358
|
||||
|
||||
@@ -150,6 +150,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
ShowEnrollmentSummary = tenantConfig.ShowEnrollmentSummary ?? false,
|
||||
EnrollmentSummaryTimeoutSeconds = tenantConfig.EnrollmentSummaryTimeoutSeconds ?? 60,
|
||||
EnrollmentSummaryBrandingImageUrl = tenantConfig.EnrollmentSummaryBrandingImageUrl,
|
||||
EnrollmentSummaryLaunchRetrySeconds = tenantConfig.EnrollmentSummaryLaunchRetrySeconds ?? 120,
|
||||
MaxBatchSize = tenantConfig.MaxBatchSize ?? 100,
|
||||
DiagnosticsUploadEnabled = !string.IsNullOrEmpty(tenantConfig.DiagnosticsBlobSasUrl),
|
||||
DiagnosticsUploadMode = tenantConfig.DiagnosticsUploadMode ?? "Off",
|
||||
|
||||
@@ -44,6 +44,35 @@ namespace AutopilotMonitor.Shared
|
||||
/// </summary>
|
||||
public const string ApiBaseUrl = "https://autopilotmonitor-api.azurewebsites.net";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Agent self-update
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for agent binaries in Azure Blob Storage (public read)
|
||||
/// </summary>
|
||||
public const string AgentBlobBaseUrl = "https://autopilotmonitor.blob.core.windows.net/agent";
|
||||
|
||||
/// <summary>
|
||||
/// Version manifest filename in blob storage (JSON: { "version": "1.0.350" })
|
||||
/// </summary>
|
||||
public const string AgentVersionFileName = "version.json";
|
||||
|
||||
/// <summary>
|
||||
/// Agent ZIP filename in blob storage
|
||||
/// </summary>
|
||||
public const string AgentZipFileName = "AutopilotMonitor-Agent.zip";
|
||||
|
||||
/// <summary>
|
||||
/// Staging directory for self-update extraction
|
||||
/// </summary>
|
||||
public const string AgentUpdateStagingDir = @"%ProgramData%\AutopilotMonitor\Agent-Update";
|
||||
|
||||
/// <summary>
|
||||
/// Agent binary directory
|
||||
/// </summary>
|
||||
public const string AgentDirectory = @"%ProgramData%\AutopilotMonitor\Agent";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Upload / batching defaults
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -106,6 +106,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string EnrollmentSummaryBrandingImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in seconds the agent retries launching the enrollment summary dialog
|
||||
/// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
|
||||
/// 0 = no retry (single attempt). Default: 120 (2 minutes).
|
||||
/// </summary>
|
||||
public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of events per upload batch.
|
||||
/// Default: 100
|
||||
|
||||
@@ -242,6 +242,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string EnrollmentSummaryBrandingImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in seconds the agent retries launching the enrollment summary dialog
|
||||
/// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
|
||||
/// null = use agent default (120). 0 = no retry (single attempt).
|
||||
/// </summary>
|
||||
public int? EnrollmentSummaryLaunchRetrySeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to show PowerShell script stdout in the web UI.
|
||||
/// When false, only stderr (error output) is visible for troubleshooting.
|
||||
|
||||
@@ -29,6 +29,8 @@ interface AgentSettingsSectionProps {
|
||||
setEnrollmentSummaryTimeoutSeconds: (value: number) => void;
|
||||
enrollmentSummaryBrandingImageUrl: string;
|
||||
setEnrollmentSummaryBrandingImageUrl: (value: string) => void;
|
||||
enrollmentSummaryLaunchRetrySeconds: number;
|
||||
setEnrollmentSummaryLaunchRetrySeconds: (value: number) => void;
|
||||
}
|
||||
|
||||
export default function AgentSettingsSection({
|
||||
@@ -60,6 +62,8 @@ export default function AgentSettingsSection({
|
||||
setEnrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl,
|
||||
setEnrollmentSummaryBrandingImageUrl,
|
||||
enrollmentSummaryLaunchRetrySeconds,
|
||||
setEnrollmentSummaryLaunchRetrySeconds,
|
||||
}: AgentSettingsSectionProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -302,6 +306,25 @@ export default function AgentSettingsSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Launch Retry Timeout */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Launch Retry Timeout</p>
|
||||
<p className="text-sm text-gray-500">How long the agent retries launching the dialog when the desktop is locked by a credential UI (e.g. Windows Hello). 0 = no retry.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600}
|
||||
value={enrollmentSummaryLaunchRetrySeconds}
|
||||
onChange={(e) => setEnrollmentSummaryLaunchRetrySeconds(Math.min(3600, Math.max(0, parseInt(e.target.value) || 0)))}
|
||||
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branding Image URL */}
|
||||
<div className="p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
|
||||
<div className="mb-2">
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface TenantConfiguration {
|
||||
showEnrollmentSummary?: boolean;
|
||||
enrollmentSummaryTimeoutSeconds?: number;
|
||||
enrollmentSummaryBrandingImageUrl?: string;
|
||||
enrollmentSummaryLaunchRetrySeconds?: number;
|
||||
// Script output visibility
|
||||
showScriptOutput?: boolean;
|
||||
// Agent analyzer settings
|
||||
@@ -141,6 +142,7 @@ export default function SettingsPage() {
|
||||
const [showEnrollmentSummary, setShowEnrollmentSummary] = useState(false);
|
||||
const [enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds] = useState(60);
|
||||
const [enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl] = useState("");
|
||||
const [enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds] = useState(120);
|
||||
|
||||
// Teams notifications state
|
||||
const [teamsWebhookUrl, setTeamsWebhookUrl] = useState("");
|
||||
@@ -185,6 +187,7 @@ export default function SettingsPage() {
|
||||
showEnrollmentSummary !== (config.showEnrollmentSummary ?? false) ||
|
||||
enrollmentSummaryTimeoutSeconds !== (config.enrollmentSummaryTimeoutSeconds ?? 60) ||
|
||||
enrollmentSummaryBrandingImageUrl !== (config.enrollmentSummaryBrandingImageUrl ?? "") ||
|
||||
enrollmentSummaryLaunchRetrySeconds !== (config.enrollmentSummaryLaunchRetrySeconds ?? 120) ||
|
||||
teamsWebhookUrl !== (config.teamsWebhookUrl ?? "") ||
|
||||
teamsNotifyOnSuccess !== (config.teamsNotifyOnSuccess ?? true) ||
|
||||
teamsNotifyOnFailure !== (config.teamsNotifyOnFailure ?? true) ||
|
||||
@@ -205,7 +208,8 @@ export default function SettingsPage() {
|
||||
performanceCollectorInterval, helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile,
|
||||
rebootOnComplete, rebootDelaySeconds, enableGeoLocation, enableImeMatchLog,
|
||||
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl, teamsWebhookUrl, teamsNotifyOnSuccess, teamsNotifyOnFailure,
|
||||
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
teamsWebhookUrl, teamsNotifyOnSuccess, teamsNotifyOnFailure,
|
||||
diagnosticsBlobSasUrl, diagnosticsUploadMode, tenantDiagPaths,
|
||||
enableLocalAdminAnalyzer, localAdminAllowedAccounts,
|
||||
]);
|
||||
@@ -250,6 +254,7 @@ export default function SettingsPage() {
|
||||
setShowEnrollmentSummary(data.showEnrollmentSummary ?? false);
|
||||
setEnrollmentSummaryTimeoutSeconds(data.enrollmentSummaryTimeoutSeconds ?? 60);
|
||||
setEnrollmentSummaryBrandingImageUrl(data.enrollmentSummaryBrandingImageUrl ?? "");
|
||||
setEnrollmentSummaryLaunchRetrySeconds(data.enrollmentSummaryLaunchRetrySeconds ?? 120);
|
||||
setTeamsWebhookUrl(data.teamsWebhookUrl ?? "");
|
||||
setTeamsNotifyOnSuccess(data.teamsNotifyOnSuccess ?? true);
|
||||
setTeamsNotifyOnFailure(data.teamsNotifyOnFailure ?? true);
|
||||
@@ -474,6 +479,7 @@ export default function SettingsPage() {
|
||||
showEnrollmentSummary,
|
||||
enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl: enrollmentSummaryBrandingImageUrl || undefined,
|
||||
enrollmentSummaryLaunchRetrySeconds,
|
||||
teamsWebhookUrl: teamsWebhookUrl || undefined,
|
||||
teamsNotifyOnSuccess,
|
||||
teamsNotifyOnFailure,
|
||||
@@ -1060,6 +1066,8 @@ export default function SettingsPage() {
|
||||
setEnrollmentSummaryTimeoutSeconds={setEnrollmentSummaryTimeoutSeconds}
|
||||
enrollmentSummaryBrandingImageUrl={enrollmentSummaryBrandingImageUrl}
|
||||
setEnrollmentSummaryBrandingImageUrl={setEnrollmentSummaryBrandingImageUrl}
|
||||
enrollmentSummaryLaunchRetrySeconds={enrollmentSummaryLaunchRetrySeconds}
|
||||
setEnrollmentSummaryLaunchRetrySeconds={setEnrollmentSummaryLaunchRetrySeconds}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user