mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(agent): event-driven TenantId wait via RegistryWatcher
Bridges the OOBE / hybrid-AAD-join race window where the agent fires before AADTenantID / CloudDomainJoin\TenantInfo are written to the registry. Field evidence (GardnerMedia, 2026-04-30): the agent fired ~5 minutes before the AAD device cert landed; an event-driven wait of 10 minutes recovers the run instead of bailing and waiting for next reboot. Mechanism (V2 only — V1 keeps the fast-fail path): * New TenantIdAwaiter.WaitForTenantId(timeoutSec, logger, ct) sets up a CompositeRegistryChangeSignal over HKLM\SOFTWARE\Microsoft\Enrollments and HKLM\SYSTEM\...\CloudDomainJoin (Registry64 view, recursive, Name+LastSet+ThreadAgnostic filter), re-probes silently on every change, and returns the resolved TenantId or null on timeout. * 30 s belt-and-suspenders periodic re-probe protects against a missed RegNotifyChangeKeyValue notification. * 100 ms debounce after each signal so multi-value enrollment transactions land before the re-probe. Configuration: * Opt-in via --tenant-id-wait <sec>. Default 0 = no wait (legacy fast-fail). The PS1 bootstrap script owns the production default; the agent itself never assumes one. * CLI value persisted to bootstrap-config.json by --install so the Scheduled Task picks it up on each restart. * Install-AutopilotMonitor-v2.ps1 sets $tenantIdWaitSec = 600. Admins override per tenant by editing this single line and re-deploying. Testability: * IRegistryChangeSignal abstraction lets unit tests drive the loop with a fake signal and scripted probe — 8 deterministic cases cover timeout, signal hit, multi-signal, cancellation, periodic re-probe, whitespace/empty/null handling, zero-timeout short-circuit, and argument validation. * RegistryWatcherIntegrationTests exercise the real Win32 plumbing (RegNotifyChangeKeyValue → Changed event) against HKCU so no elevation is required and the test runs in standard CI. * Both classes opt out of xUnit parallelisation — the timing-sensitive Thread.Sleep handshakes are unreliable on a busy parallel pool. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -295,9 +295,18 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
# TenantId-wait timeout (seconds). When the registry probe finds no TenantId on
|
||||
# first try, the agent registers a RegistryWatcher on the Enrollments + CloudDomainJoin
|
||||
# keys and waits up to this long for AAD/MDM enrollment to populate before bailing.
|
||||
# 0 = no wait (legacy fast-fail). Increase here (and re-deploy via Intune) for tenants
|
||||
# where hybrid join + auto-MDM enrollment regularly needs longer than 10 min.
|
||||
# Field evidence (GardnerMedia, hybrid-AAD-joined, 2026-04-30): the AAD device cert
|
||||
# landed ~5 min after the agent fired, so 600 s is comfortably sufficient.
|
||||
$tenantIdWaitSec = 600
|
||||
|
||||
# Let the agent install/deploy itself and manage its own Scheduled Task
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
Write-Log "Calling agent install mode (--install --tenant-id-wait $tenantIdWaitSec)..."
|
||||
& $agentExePath --install --tenant-id-wait $tenantIdWaitSec
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
[CollectionDefinition(nameof(RegistryWatcherIntegrationTestCollection), DisableParallelization = true)]
|
||||
public sealed class RegistryWatcherIntegrationTestCollection { }
|
||||
|
||||
/// <summary>
|
||||
/// Real <see cref="RegistryWatcher"/> integration test against
|
||||
/// <c>HKCU\SOFTWARE\AutopilotMonitor.Tests\WatcherIntegration</c>. Exercises the
|
||||
/// Win32 <c>RegNotifyChangeKeyValue</c> plumbing end-to-end:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>opens the watched key, registers async notification</description></item>
|
||||
/// <item><description>another thread writes a value / creates a sub-key</description></item>
|
||||
/// <item><description>watcher thread receives the kernel signal and raises Changed</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// HKCU is used (not HKLM) so the test runs without elevation — anyone with a user
|
||||
/// session can write into their own hive. The watcher itself is hive-agnostic, so
|
||||
/// proving the plumbing on HKCU also proves it on HKLM (where production watches
|
||||
/// MDM enrollment + AAD-join keys).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All test data lives under the dedicated test sub-key and is deleted on teardown.
|
||||
/// Tests run single-threaded — the 150 ms sleep that lets the watcher thread enter
|
||||
/// <c>RegNotifyChangeKeyValue</c> before the test thread writes is unreliable on a
|
||||
/// box busy with parallel xUnit work.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection(nameof(RegistryWatcherIntegrationTestCollection))]
|
||||
public sealed class RegistryWatcherIntegrationTests : IDisposable
|
||||
{
|
||||
private const string TestSubKey = @"SOFTWARE\AutopilotMonitor.Tests\WatcherIntegration";
|
||||
|
||||
public RegistryWatcherIntegrationTests()
|
||||
{
|
||||
// Ensure a clean slate — leftover state from a prior aborted run would skew
|
||||
// the test (e.g. existing values changing the watcher's "what's new" notion).
|
||||
CleanupTestKey();
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(TestSubKey))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => CleanupTestKey();
|
||||
|
||||
private static void CleanupTestKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree(TestSubKey, throwOnMissingSubKey: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cleanup-best-effort — never fail the test on teardown noise.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watcher_fires_when_value_is_written()
|
||||
{
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
|
||||
using var watcher = new RegistryWatcher(
|
||||
hive: RegistryHive.CurrentUser,
|
||||
subKey: TestSubKey,
|
||||
watchSubtree: false,
|
||||
view: RegistryView.Default,
|
||||
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
|
||||
trace: null);
|
||||
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
watcher.Start();
|
||||
|
||||
// Give the watcher a moment to enter RegNotifyChangeKeyValue. Without this,
|
||||
// the write below can race ahead of the registration and the kernel won't
|
||||
// record an "outstanding" change for our handle.
|
||||
Thread.Sleep(150);
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(TestSubKey, writable: true))
|
||||
{
|
||||
Assert.NotNull(key);
|
||||
key!.SetValue("ProbeValue", "hello");
|
||||
}
|
||||
|
||||
Assert.True(fired.Wait(TimeSpan.FromSeconds(5)),
|
||||
"RegistryWatcher.Changed did not fire within 5s of value write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watcher_fires_when_subtree_subkey_is_created()
|
||||
{
|
||||
using var fired = new ManualResetEventSlim(false);
|
||||
|
||||
using var watcher = new RegistryWatcher(
|
||||
hive: RegistryHive.CurrentUser,
|
||||
subKey: TestSubKey,
|
||||
watchSubtree: true, // recursive — subkey writes anywhere below count
|
||||
view: RegistryView.Default,
|
||||
filter: RegistryNativeMethods.RegChangeNotifyFilter.Name
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.LastSet
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
|
||||
trace: null);
|
||||
|
||||
watcher.Changed += (_, __) => fired.Set();
|
||||
watcher.Start();
|
||||
|
||||
Thread.Sleep(150);
|
||||
|
||||
// Mirrors the production case: CloudDomainJoin\TenantInfo doesn't exist
|
||||
// yet at agent start; AAD join later writes a sub-key under the parent.
|
||||
using (var sub = Registry.CurrentUser.CreateSubKey($@"{TestSubKey}\NewChild"))
|
||||
{
|
||||
Assert.NotNull(sub);
|
||||
sub!.SetValue("Marker", 1);
|
||||
}
|
||||
|
||||
Assert.True(fired.Wait(TimeSpan.FromSeconds(5)),
|
||||
"RegistryWatcher.Changed did not fire within 5s of sub-key creation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watcher_fires_repeatedly_for_successive_changes()
|
||||
{
|
||||
int fireCount = 0;
|
||||
using var fired = new AutoResetEvent(false);
|
||||
|
||||
using var watcher = new RegistryWatcher(
|
||||
hive: RegistryHive.CurrentUser,
|
||||
subKey: TestSubKey,
|
||||
watchSubtree: false,
|
||||
view: RegistryView.Default,
|
||||
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
|
||||
trace: null);
|
||||
|
||||
watcher.Changed += (_, __) =>
|
||||
{
|
||||
Interlocked.Increment(ref fireCount);
|
||||
fired.Set();
|
||||
};
|
||||
watcher.Start();
|
||||
|
||||
Thread.Sleep(150);
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(TestSubKey, writable: true))
|
||||
{
|
||||
key!.SetValue("V1", "a");
|
||||
Assert.True(fired.WaitOne(TimeSpan.FromSeconds(5)), "first change");
|
||||
|
||||
// RegNotifyChangeKeyValue is single-shot — the watcher loop re-arms
|
||||
// after each Changed. Verify the second write also wakes us.
|
||||
Thread.Sleep(50);
|
||||
key.SetValue("V2", "b");
|
||||
Assert.True(fired.WaitOne(TimeSpan.FromSeconds(5)), "second change");
|
||||
}
|
||||
|
||||
Assert.True(fireCount >= 2, $"expected ≥2 fire events, saw {fireCount}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
// xUnit1031 (no blocking Task.Wait in tests) intentionally suppressed — the API
|
||||
// under test is synchronous; we run it via Task.Run on a worker thread so the
|
||||
// xUnit thread can manipulate scripted state and trigger the fake signal,
|
||||
// then Wait verifies completion. Async would require turning a sync API into a
|
||||
// Task<string>-returning one purely for test ergonomics, which is the wrong
|
||||
// trade-off.
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
[CollectionDefinition(nameof(TenantIdAwaiterTestCollection), DisableParallelization = true)]
|
||||
public sealed class TenantIdAwaiterTestCollection { }
|
||||
|
||||
/// <summary>
|
||||
/// Drives <see cref="TenantIdAwaiter.WaitForTenantIdCore"/> through every interesting
|
||||
/// path with a fake <see cref="IRegistryChangeSignal"/> + scripted probe so the
|
||||
/// orchestration logic is verified without touching the registry. The public
|
||||
/// <see cref="TenantIdAwaiter.WaitForTenantId"/> wrapper isn't covered here — the
|
||||
/// HKCU integration test in <c>RegistryWatcherIntegrationTests</c> proves the
|
||||
/// CompositeRegistryChangeSignal / RegNotifyChangeKeyValue plumbing separately.
|
||||
/// <para>
|
||||
/// Single-threaded collection: these tests use <c>Thread.Sleep</c> as a synchronisation
|
||||
/// primitive (waiting for the awaiter to enter its WaitAny loop before triggering the
|
||||
/// fake signal). Under the default parallel pool the sleep is unreliable on a busy
|
||||
/// box, so the class opts out of parallel execution.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Collection(nameof(TenantIdAwaiterTestCollection))]
|
||||
public sealed class TenantIdAwaiterTests
|
||||
{
|
||||
private sealed class FakeSignal : IRegistryChangeSignal
|
||||
{
|
||||
public event EventHandler? Changed;
|
||||
public bool StartCalled { get; private set; }
|
||||
public bool DisposeCalled { get; private set; }
|
||||
|
||||
public void Start() => StartCalled = true;
|
||||
public void Trigger() => Changed?.Invoke(this, EventArgs.Empty);
|
||||
public void Dispose() => DisposeCalled = true;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Timeout_with_no_signals_returns_null_after_about_one_second()
|
||||
{
|
||||
using var signal = new FakeSignal();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var result = TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () => null!,
|
||||
signal: signal,
|
||||
timeoutSeconds: 1,
|
||||
periodicReprobeMs: 5_000, // larger than timeout, so this never fires
|
||||
debounceMs: 0,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
sw.Stop();
|
||||
Assert.Null(result);
|
||||
Assert.True(sw.Elapsed.TotalSeconds >= 0.9 && sw.Elapsed.TotalSeconds < 2.5,
|
||||
$"expected ~1s, was {sw.Elapsed.TotalSeconds:F2}s");
|
||||
Assert.True(signal.StartCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signal_fires_and_probe_hits_returns_value()
|
||||
{
|
||||
using var signal = new FakeSignal();
|
||||
string? scriptedReturn = null;
|
||||
|
||||
var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () => scriptedReturn!,
|
||||
signal: signal,
|
||||
timeoutSeconds: 10,
|
||||
periodicReprobeMs: 60_000, // never via periodic
|
||||
debounceMs: 5,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None));
|
||||
|
||||
// Let the awaiter enter its wait loop.
|
||||
Thread.Sleep(100);
|
||||
|
||||
// Flip probe to "hit" then signal.
|
||||
scriptedReturn = "tenant-aaa-bbb";
|
||||
signal.Trigger();
|
||||
|
||||
Assert.True(task.Wait(TimeSpan.FromSeconds(3)),
|
||||
"WaitForTenantIdCore did not return within 3s after signal");
|
||||
Assert.Equal("tenant-aaa-bbb", task.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_signals_with_late_probe_hit_returns_value()
|
||||
{
|
||||
using var signal = new FakeSignal();
|
||||
int probeCount = 0;
|
||||
string? scriptedReturn = null;
|
||||
|
||||
var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () =>
|
||||
{
|
||||
Interlocked.Increment(ref probeCount);
|
||||
return scriptedReturn!;
|
||||
},
|
||||
signal: signal,
|
||||
timeoutSeconds: 10,
|
||||
periodicReprobeMs: 60_000,
|
||||
debounceMs: 5,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None));
|
||||
|
||||
Thread.Sleep(80);
|
||||
// Two signal fires that yield no hit
|
||||
signal.Trigger(); Thread.Sleep(40);
|
||||
signal.Trigger(); Thread.Sleep(40);
|
||||
// Now flip the probe and fire again
|
||||
scriptedReturn = "tenant-late";
|
||||
signal.Trigger();
|
||||
|
||||
Assert.True(task.Wait(TimeSpan.FromSeconds(3)));
|
||||
Assert.Equal("tenant-late", task.Result);
|
||||
Assert.True(probeCount >= 3, $"expected ≥3 probes (3 signals), saw {probeCount}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancellation_returns_null_promptly()
|
||||
{
|
||||
using var signal = new FakeSignal();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () => null!,
|
||||
signal: signal,
|
||||
timeoutSeconds: 60, // would otherwise wait a minute
|
||||
periodicReprobeMs: 60_000,
|
||||
debounceMs: 0,
|
||||
logger: null!,
|
||||
ct: cts.Token));
|
||||
|
||||
Thread.Sleep(100);
|
||||
cts.Cancel();
|
||||
|
||||
Assert.True(task.Wait(TimeSpan.FromSeconds(3)),
|
||||
"expected prompt return on cancellation");
|
||||
Assert.Null(task.Result);
|
||||
sw.Stop();
|
||||
Assert.True(sw.Elapsed.TotalSeconds < 2.0,
|
||||
$"cancellation took {sw.Elapsed.TotalSeconds:F2}s — expected <2s");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Periodic_reprobe_picks_up_change_when_no_signal_arrives()
|
||||
{
|
||||
using var signal = new FakeSignal();
|
||||
string? scriptedReturn = null;
|
||||
|
||||
var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () => scriptedReturn!,
|
||||
signal: signal,
|
||||
timeoutSeconds: 10,
|
||||
periodicReprobeMs: 100, // re-probe every 100 ms
|
||||
debounceMs: 0,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None));
|
||||
|
||||
Thread.Sleep(80);
|
||||
scriptedReturn = "tenant-via-periodic";
|
||||
// No signal — only the periodic timer should pick this up.
|
||||
|
||||
Assert.True(task.Wait(TimeSpan.FromSeconds(3)),
|
||||
"periodic re-probe did not catch the registry change");
|
||||
Assert.Equal("tenant-via-periodic", task.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_or_whitespace_probe_result_is_treated_as_miss()
|
||||
{
|
||||
using var signal = new FakeSignal();
|
||||
var probeReturns = new[] { "", " ", null!, "tenant-ok" };
|
||||
int callIdx = 0;
|
||||
|
||||
var task = Task.Run(() => TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () => probeReturns[Math.Min(callIdx++, probeReturns.Length - 1)],
|
||||
signal: signal,
|
||||
timeoutSeconds: 10,
|
||||
periodicReprobeMs: 50,
|
||||
debounceMs: 0,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None));
|
||||
|
||||
Assert.True(task.Wait(TimeSpan.FromSeconds(3)));
|
||||
Assert.Equal("tenant-ok", task.Result);
|
||||
// empty + whitespace + null + "tenant-ok" => callIdx >= 4 by then
|
||||
Assert.True(callIdx >= 4, $"expected ≥4 probe calls, saw {callIdx}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_or_negative_timeout_short_circuits_to_null()
|
||||
{
|
||||
using var signal = new FakeSignal();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var resultZero = TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () => "tenant-would-have-hit",
|
||||
signal: signal,
|
||||
timeoutSeconds: 0,
|
||||
periodicReprobeMs: 100,
|
||||
debounceMs: 0,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
var resultNeg = TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () => "tenant-would-have-hit",
|
||||
signal: signal,
|
||||
timeoutSeconds: -5,
|
||||
periodicReprobeMs: 100,
|
||||
debounceMs: 0,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
sw.Stop();
|
||||
Assert.Null(resultZero);
|
||||
Assert.Null(resultNeg);
|
||||
// Should be near-instantaneous (no Start, no waits).
|
||||
Assert.True(sw.Elapsed.TotalMilliseconds < 200,
|
||||
$"short-circuit took {sw.Elapsed.TotalMilliseconds:F0}ms");
|
||||
Assert.False(signal.StartCalled, "signal must not be started for zero-timeout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_arguments_throw_ArgumentNullException()
|
||||
{
|
||||
using var signal = new FakeSignal();
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: null!,
|
||||
signal: signal,
|
||||
timeoutSeconds: 1,
|
||||
periodicReprobeMs: 100,
|
||||
debounceMs: 0,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None));
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
TenantIdAwaiter.WaitForTenantIdCore(
|
||||
probe: () => null!,
|
||||
signal: null!,
|
||||
timeoutSeconds: 1,
|
||||
periodicReprobeMs: 100,
|
||||
debounceMs: 0,
|
||||
logger: null!,
|
||||
ct: CancellationToken.None));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in seconds the agent waits for a TenantId to become resolvable
|
||||
/// before bailing. When > 0 and the initial registry probe finds no TenantId,
|
||||
/// the agent registers a RegistryWatcher on the relevant Enrollments and
|
||||
/// CloudDomainJoin keys and re-probes on every change until either resolution
|
||||
/// or this timeout. 0 = no wait, fast-fail on miss (legacy behaviour).
|
||||
/// <para>
|
||||
/// Set via the install-time CLI flag <c>--tenant-id-wait <sec></c>, persisted
|
||||
/// to <c>bootstrap-config.json</c>. The PS1 bootstrap script owns the production
|
||||
/// default; the agent itself never assumes one.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public int TenantIdWaitSeconds { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration
|
||||
/// </summary>
|
||||
|
||||
@@ -12,5 +12,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
{
|
||||
public string BootstrapToken { get; set; }
|
||||
public string TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// TenantId-wait timeout in seconds, persisted from the <c>--tenant-id-wait</c>
|
||||
/// install-time arg. Read by AgentBootstrap when the registry probe finds no
|
||||
/// TenantId on first try. 0 / missing = no wait, legacy fast-fail.
|
||||
/// </summary>
|
||||
public int TenantIdWaitSeconds { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Production <see cref="IRegistryChangeSignal"/> wired to two
|
||||
/// <see cref="RegistryWatcher"/> instances:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>HKLM\SOFTWARE\Microsoft\Enrollments</c> (recursive) — catches
|
||||
/// new enrollment GUID sub-keys and <c>AADTenantID</c> writes inside them.</description></item>
|
||||
/// <item><description><c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin</c>
|
||||
/// (recursive, watching the parent because <c>TenantInfo</c> / <c>JoinInfo</c>
|
||||
/// might not exist yet at start-up) — catches AAD-join completion.</description></item>
|
||||
/// </list>
|
||||
/// Both watchers fan their <c>Changed</c> events into a single <see cref="Changed"/>
|
||||
/// edge so the awaiter doesn't have to know which path moved.
|
||||
/// </summary>
|
||||
public sealed class CompositeRegistryChangeSignal : IRegistryChangeSignal
|
||||
{
|
||||
private const RegistryNativeMethods.RegChangeNotifyFilter Filter =
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.Name |
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.LastSet |
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic;
|
||||
|
||||
private readonly List<RegistryWatcher> _watchers = new List<RegistryWatcher>();
|
||||
private readonly AgentLogger _logger;
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler Changed;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the production signal — two watchers on the registry locations the
|
||||
/// <see cref="TenantIdResolver"/> probes. Use the protected constructor in tests
|
||||
/// when you don't want real Win32 watchers.
|
||||
/// </summary>
|
||||
public CompositeRegistryChangeSignal(AgentLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_watchers.Add(BuildWatcher(
|
||||
RegistryHive.LocalMachine,
|
||||
@"SOFTWARE\Microsoft\Enrollments",
|
||||
"Enrollments"));
|
||||
_watchers.Add(BuildWatcher(
|
||||
RegistryHive.LocalMachine,
|
||||
@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin",
|
||||
"CloudDomainJoin"));
|
||||
}
|
||||
|
||||
private RegistryWatcher BuildWatcher(RegistryHive hive, string subKey, string label)
|
||||
{
|
||||
var watcher = new RegistryWatcher(
|
||||
hive: hive,
|
||||
subKey: subKey,
|
||||
watchSubtree: true,
|
||||
view: RegistryView.Registry64,
|
||||
filter: Filter,
|
||||
trace: null);
|
||||
|
||||
watcher.Changed += (sender, args) =>
|
||||
{
|
||||
_logger?.Debug($"CompositeRegistryChangeSignal: {label} change detected.");
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
};
|
||||
watcher.Error += (sender, ex) =>
|
||||
{
|
||||
_logger?.Warning($"CompositeRegistryChangeSignal: {label} watcher error — {ex.GetType().Name}: {ex.Message}");
|
||||
};
|
||||
return watcher;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(CompositeRegistryChangeSignal));
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
|
||||
foreach (var w in _watchers)
|
||||
{
|
||||
try
|
||||
{
|
||||
w.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// RegOpenKeyEx can fail if a watched root happens to not exist on this
|
||||
// device variant. Log + keep going — the other watcher (and the
|
||||
// belt-and-suspenders re-probe in TenantIdAwaiter) cover the gap.
|
||||
_logger?.Warning($"CompositeRegistryChangeSignal: failed to start a watcher — {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var w in _watchers)
|
||||
{
|
||||
try { w.Dispose(); } catch { /* never throw from Dispose */ }
|
||||
}
|
||||
_watchers.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstraction over a (possibly composite) registry change source. Lets
|
||||
/// <see cref="TenantIdAwaiter"/> be unit-tested without Win32 dependencies —
|
||||
/// production wires this to one or more <see cref="Monitoring.Runtime.RegistryWatcher"/>
|
||||
/// instances, tests wire a fake that raises <see cref="Changed"/> on demand.
|
||||
/// </summary>
|
||||
public interface IRegistryChangeSignal : IDisposable
|
||||
{
|
||||
/// <summary>Raised whenever any underlying watched key reports a change.</summary>
|
||||
event EventHandler Changed;
|
||||
|
||||
/// <summary>Begins observing. Idempotent: calling twice on a started signal is a no-op.</summary>
|
||||
void Start();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Event-driven TenantId resolution wait. When the initial
|
||||
/// <see cref="TenantIdResolver"/> probe finds nothing, the awaiter subscribes to
|
||||
/// a registry-change signal (production: <see cref="CompositeRegistryChangeSignal"/>
|
||||
/// over Enrollments + CloudDomainJoin), re-probes silently on every change, and
|
||||
/// returns the TenantId as soon as the registry catches up — or <c>null</c> on
|
||||
/// timeout. A 30 s belt-and-suspenders periodic re-probe protects against a
|
||||
/// missed Win32 notification.
|
||||
/// <para>
|
||||
/// Used to bridge the OOBE / hybrid-AAD-join race window where the agent fires
|
||||
/// before <c>AADTenantID</c> / <c>CloudDomainJoin\TenantInfo</c> are written.
|
||||
/// Field evidence (GardnerMedia, 2026-04-30): the agent fired ~5 minutes before
|
||||
/// the AAD device certificate landed; an event-driven wait of 10 minutes would
|
||||
/// have recovered the run.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TenantIdAwaiter
|
||||
{
|
||||
private const int DefaultPeriodicReprobeMs = 30_000;
|
||||
private const int DefaultDebounceMs = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Production entry point. Returns the resolved TenantId, or <c>null</c> when
|
||||
/// timed out / cancelled. <paramref name="timeoutSeconds"/> <= 0 short-circuits
|
||||
/// to <c>null</c> immediately (legacy fast-fail behaviour — caller should also
|
||||
/// gate on the same condition).
|
||||
/// </summary>
|
||||
public static string WaitForTenantId(int timeoutSeconds, AgentLogger logger, CancellationToken ct)
|
||||
{
|
||||
if (timeoutSeconds <= 0)
|
||||
return null;
|
||||
|
||||
// Initial probe with the real logger so the resolver emits its source-tag
|
||||
// Info line on hit / its full diagnostic block on miss exactly once.
|
||||
var initial = TenantIdResolver.Resolve(logger);
|
||||
if (!string.IsNullOrWhiteSpace(initial))
|
||||
return initial;
|
||||
|
||||
logger?.Info($"TenantIdAwaiter: TenantId not yet resolvable — waiting up to {timeoutSeconds}s for registry signal.");
|
||||
|
||||
using (var signal = new CompositeRegistryChangeSignal(logger))
|
||||
{
|
||||
var hit = WaitForTenantIdCore(
|
||||
probe: () => TenantIdResolver.Resolve(null), // silent during wait — initial probe already logged
|
||||
signal: signal,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
periodicReprobeMs: DefaultPeriodicReprobeMs,
|
||||
debounceMs: DefaultDebounceMs,
|
||||
logger: logger,
|
||||
ct: ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hit))
|
||||
{
|
||||
// Re-probe once with the real logger so the source-tag Info line
|
||||
// (e.g. "resolved TenantId=… from cloud_domain_join_tenant_info")
|
||||
// lands in the log alongside the awaiter's "resolved after Xs".
|
||||
TenantIdResolver.Resolve(logger);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-visible orchestration core. Takes a silent probe delegate and an
|
||||
/// abstract signal so xUnit can drive the loop deterministically without
|
||||
/// touching the registry. Production wires <paramref name="probe"/> to
|
||||
/// <c>TenantIdResolver.Resolve(null)</c> and <paramref name="signal"/> to
|
||||
/// <see cref="CompositeRegistryChangeSignal"/>.
|
||||
/// </summary>
|
||||
internal static string WaitForTenantIdCore(
|
||||
Func<string> probe,
|
||||
IRegistryChangeSignal signal,
|
||||
int timeoutSeconds,
|
||||
int periodicReprobeMs,
|
||||
int debounceMs,
|
||||
AgentLogger logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (probe == null) throw new ArgumentNullException(nameof(probe));
|
||||
if (signal == null) throw new ArgumentNullException(nameof(signal));
|
||||
if (timeoutSeconds <= 0) return null;
|
||||
|
||||
using (var signalEvent = new ManualResetEvent(initialState: false))
|
||||
using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
|
||||
{
|
||||
EventHandler handler = (_, __) => signalEvent.Set();
|
||||
signal.Changed += handler;
|
||||
try
|
||||
{
|
||||
signal.Start();
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var handles = new WaitHandle[] { signalEvent, timeoutCts.Token.WaitHandle };
|
||||
|
||||
while (!timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
var idx = WaitHandle.WaitAny(handles, periodicReprobeMs);
|
||||
if (idx == 1) break; // cancelled / timed out
|
||||
|
||||
bool signalFired = idx == 0;
|
||||
signalEvent.Reset();
|
||||
|
||||
if (signalFired && debounceMs > 0)
|
||||
{
|
||||
// Brief settling so multi-value transactions land before re-probe.
|
||||
// Returns true if cancelled during the debounce → exit promptly.
|
||||
if (timeoutCts.Token.WaitHandle.WaitOne(debounceMs))
|
||||
break;
|
||||
signalEvent.Reset();
|
||||
}
|
||||
|
||||
var hit = probe();
|
||||
if (!string.IsNullOrWhiteSpace(hit))
|
||||
{
|
||||
logger?.Info(
|
||||
$"TenantIdAwaiter: resolved after {sw.Elapsed.TotalSeconds:F0}s " +
|
||||
$"({(signalFired ? "registry signal" : "periodic re-probe")}).");
|
||||
return hit;
|
||||
}
|
||||
|
||||
if (signalFired)
|
||||
logger?.Debug("TenantIdAwaiter: registry change observed but TenantId still unresolvable.");
|
||||
}
|
||||
|
||||
logger?.Warning(
|
||||
$"TenantIdAwaiter: timeout after {sw.Elapsed.TotalSeconds:F0}s — " +
|
||||
"TenantId still not available, agent will exit cleanly and retry on next trigger.");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
signal.Changed -= handler;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,22 @@ namespace AutopilotMonitor.Agent.V2
|
||||
logger.Info("Agent already running from target install directory; payload copy not required.");
|
||||
}
|
||||
|
||||
// Persist bootstrap config if --bootstrap-token was provided. The Scheduled Task
|
||||
// command line has no args — the agent picks this up on the first post-install run.
|
||||
// Persist bootstrap config if any of --bootstrap-token / --tenant-id /
|
||||
// --tenant-id-wait was provided. The Scheduled Task command line has no args —
|
||||
// the agent picks the persisted values up on the first post-install run.
|
||||
var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token");
|
||||
var tenantIdArg = GetArgValue(args, "--tenant-id");
|
||||
if (!string.IsNullOrEmpty(bootstrapTokenArg))
|
||||
var tenantIdWaitArg = GetArgValue(args, "--tenant-id-wait");
|
||||
int tenantIdWaitSeconds = 0;
|
||||
if (!string.IsNullOrEmpty(tenantIdWaitArg) && !int.TryParse(tenantIdWaitArg, out tenantIdWaitSeconds))
|
||||
{
|
||||
logger.Warning($"Install: --tenant-id-wait '{tenantIdWaitArg}' is not a valid integer — ignoring.");
|
||||
tenantIdWaitSeconds = 0;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(bootstrapTokenArg) ||
|
||||
!string.IsNullOrEmpty(tenantIdArg) ||
|
||||
tenantIdWaitSeconds > 0)
|
||||
{
|
||||
var bootstrapConfigPath = Path.Combine(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory),
|
||||
@@ -75,9 +86,14 @@ namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
BootstrapToken = bootstrapTokenArg,
|
||||
TenantId = tenantIdArg,
|
||||
TenantIdWaitSeconds = tenantIdWaitSeconds,
|
||||
};
|
||||
File.WriteAllText(bootstrapConfigPath, JsonConvert.SerializeObject(bootstrapConfig));
|
||||
logger.Info("Bootstrap config persisted for Scheduled Task.");
|
||||
logger.Info(
|
||||
$"Bootstrap config persisted for Scheduled Task " +
|
||||
$"(token={(string.IsNullOrEmpty(bootstrapTokenArg) ? "no" : "yes")}, " +
|
||||
$"tenantId={(string.IsNullOrEmpty(tenantIdArg) ? "no" : "yes")}, " +
|
||||
$"tenantIdWait={tenantIdWaitSeconds}s).");
|
||||
}
|
||||
|
||||
// Persist await-enrollment config if requested.
|
||||
|
||||
@@ -278,6 +278,15 @@ namespace AutopilotMonitor.Agent.V2
|
||||
else if (awaitConfig != null)
|
||||
awaitTimeoutMinutes = awaitConfig.TimeoutMinutes;
|
||||
|
||||
// TenantId wait — CLI wins over persisted bootstrap-config.json. Default 0
|
||||
// (no wait, legacy fast-fail) — the PS1 bootstrap owns the production default.
|
||||
var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait");
|
||||
int tenantIdWaitSeconds = 0;
|
||||
if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait))
|
||||
tenantIdWaitSeconds = parsedTenantIdWait;
|
||||
else if (bootstrapConfig != null)
|
||||
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds;
|
||||
|
||||
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
|
||||
|
||||
var cliLogLevel = GetArgValue(args, "--log-level");
|
||||
@@ -300,6 +309,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
UseBootstrapTokenAuth = useBootstrapTokenAuth,
|
||||
AwaitEnrollment = awaitEnrollment,
|
||||
AwaitEnrollmentTimeoutMinutes = awaitTimeoutMinutes,
|
||||
TenantIdWaitSeconds = tenantIdWaitSeconds,
|
||||
RebootOnComplete = rebootOnComplete,
|
||||
EnableGeoLocation = !disableGeoLocation,
|
||||
ImeLogPathOverride = imeLogPathOverride,
|
||||
|
||||
@@ -120,6 +120,22 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
Program.DeleteAwaitEnrollmentConfig(dataDirectory, logger);
|
||||
}
|
||||
|
||||
// Event-driven TenantId wait — kicks in only when the user opted-in via
|
||||
// --tenant-id-wait <sec> (CLI or persisted bootstrap-config.json). Bridges
|
||||
// the OOBE / hybrid-AAD-join race where the agent fires before the registry
|
||||
// catches up. No-op when TenantIdWaitSeconds == 0 (legacy fast-fail) or when
|
||||
// the await-enrollment branch already resolved a TenantId.
|
||||
if (string.IsNullOrEmpty(agentConfig.TenantId) && agentConfig.TenantIdWaitSeconds > 0)
|
||||
{
|
||||
using (var waitCts = new CancellationTokenSource())
|
||||
{
|
||||
agentConfig.TenantId = TenantIdAwaiter.WaitForTenantId(
|
||||
timeoutSeconds: agentConfig.TenantIdWaitSeconds,
|
||||
logger: logger,
|
||||
ct: waitCts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(agentConfig.TenantId))
|
||||
{
|
||||
logger.Error("V2 agent cannot start: TenantId not available (registry empty + no bootstrap config).");
|
||||
|
||||
@@ -1 +1 @@
|
||||
507
|
||||
516
|
||||
|
||||
@@ -1 +1 @@
|
||||
1231
|
||||
1233
|
||||
|
||||
Reference in New Issue
Block a user