diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs new file mode 100644 index 00000000..05775bb1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BackendClientFactoryTests.cs @@ -0,0 +1,100 @@ +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Runtime; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Program +{ + /// + /// Tests for : Phase 3 (auth-clients bundle) and + /// Phase 5 (telemetry-clients result) extracts from Program.RunAgent. + /// + public sealed class BackendClientFactoryTests + { + private static AgentLogger NewLogger(string path) + => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info); + + private static AgentConfiguration MinimalBootstrapTokenConfig() + => new AgentConfiguration + { + ApiBaseUrl = "https://example.invalid", + TenantId = "00000000-0000-0000-0000-000000000123", + SessionId = "11111111-1111-1111-1111-111111111111", + BootstrapToken = "test-token", + UseBootstrapTokenAuth = true, + UseClientCertAuth = false, + MaxAuthFailures = 3, + AuthFailureTimeoutMinutes = 30, + }; + + // ============================================================ BuildAuthClients + + [Fact] + public void BuildAuthClients_returns_bundle_with_all_clients_populated() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var config = MinimalBootstrapTokenConfig(); + + var bundle = BackendClientFactory.BuildAuthClients(config, agentVersion: "1.2.3.4", logger: logger); + + Assert.NotNull(bundle); + Assert.NotNull(bundle.BackendApiClient); + Assert.NotNull(bundle.DistressReporter); + Assert.NotNull(bundle.EmergencyReporter); + Assert.NotNull(bundle.AuthFailureTracker); + // HardwareInfo.GetHardwareInfo always returns string fields (possibly empty on + // non-WMI environments) — the bundle must surface whatever it produced. + Assert.NotNull(bundle.Manufacturer); + Assert.NotNull(bundle.Model); + Assert.NotNull(bundle.SerialNumber); + } + + [Fact] + public void BuildAuthClients_does_not_attempt_cert_lookup_when_bootstrap_auth_used() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var config = MinimalBootstrapTokenConfig(); + + var bundle = BackendClientFactory.BuildAuthClients(config, agentVersion: "1.2.3.4", logger: logger); + + // With UseClientCertAuth=false, BackendApiClient skips the cert-store search + // entirely → ClientCertificate is null and no AuthCertificateMissing distress + // is dispatched. Asserts the bundle is consistent with that path. + Assert.Null(bundle.BackendApiClient.ClientCertificate); + } + + // =========================================================== TelemetryClientResult + + [Fact] + public void TelemetryClientResult_Exit_carries_exit_code_and_no_clients() + { + var result = TelemetryClientResult.Exit(4); + + Assert.True(result.ShouldExit); + Assert.Equal(4, result.ExitCode); + Assert.Null(result.MtlsHttpClient); + Assert.Null(result.Uploader); + } + + [Fact] + public void TelemetryClientResult_Continue_carries_clients_and_zero_exit_code() + { + using var tmp = new TempDirectory(); + using var fakeHttpClient = new System.Net.Http.HttpClient(); + + // BackendTelemetryUploader is hard to construct purely (needs auth tracker etc.) — + // pass null, the Continue() factory does not validate. The Result-type contract + // is what we exercise here. + var result = TelemetryClientResult.Continue(fakeHttpClient, uploader: null); + + Assert.False(result.ShouldExit); + Assert.Equal(0, result.ExitCode); + Assert.Same(fakeHttpClient, result.MtlsHttpClient); + Assert.Null(result.Uploader); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 3ca6a560..0d27eb8d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -197,53 +197,13 @@ namespace AutopilotMonitor.Agent.V2 var isWhiteGloveResume = bootstrap.IsWhiteGloveResume; var cleanupServiceFactory = bootstrap.CleanupServiceFactory; - var backendApiClient = new BackendApiClient( - baseUrl: agentConfig.ApiBaseUrl, - configuration: agentConfig, - logger: logger, - agentVersion: GetAgentVersion()); - - // M4.6.γ — Emergency + Distress reporters. Plumbed into RemoteConfigService so - // Config-fetch failures (auth vs network) flow to the correct channel. Also fires - // an initial AuthCertificateMissing distress when the MDM cert was expected but - // not found (Legacy parity — surfaces pre-MDM-enrollment dead-ends to the backend - // via the cert-less distress channel). - var hardwareForReporters = HardwareInfo.GetHardwareInfo(logger); - var distressReporter = new DistressReporter( - baseUrl: agentConfig.ApiBaseUrl, - tenantId: agentConfig.TenantId, - manufacturer: hardwareForReporters.Manufacturer, - model: hardwareForReporters.Model, - serialNumber: hardwareForReporters.SerialNumber, - agentVersion: GetAgentVersion(), - logger: logger); - - var emergencyReporter = new EmergencyReporter( - apiClient: backendApiClient, - sessionId: agentConfig.SessionId, - tenantId: agentConfig.TenantId, - agentVersion: GetAgentVersion(), - logger: logger); - - if (agentConfig.UseClientCertAuth && backendApiClient.ClientCertificate == null) - { - _ = distressReporter.TrySendAsync( - DistressErrorType.AuthCertificateMissing, - "MDM certificate not found in LocalMachine or CurrentUser store"); - } - - // Central observer for consecutive 401/403 responses. Initialised with the CLI/bootstrap - // defaults on AgentConfiguration; UpdateLimits is called after RemoteConfigMerger.Merge - // so tenant-policy overrides take effect. ThresholdExceeded is wired below, once the - // shutdown signal is available, to trigger a clean agent exit when the limit is hit. - // V1 parity — the distress reporter is plumbed in at construction so the tracker is - // the single dispatch point for auth-failure distress (first failure only). - var authFailureTracker = new AuthFailureTracker( - maxFailures: agentConfig.MaxAuthFailures, - timeoutMinutes: agentConfig.AuthFailureTimeoutMinutes, - clock: SystemClock.Instance, - logger: logger, - distressReporter: distressReporter); + // Phase 3 (BackendApiClient + reporters + auth-failure tracker) is encapsulated + // in BackendClientFactory; see Runtime/BackendClientFactory.cs. + var auth = Runtime.BackendClientFactory.BuildAuthClients(agentConfig, GetAgentVersion(), logger); + var backendApiClient = auth.BackendApiClient; + var distressReporter = auth.DistressReporter; + var emergencyReporter = auth.EmergencyReporter; + var authFailureTracker = auth.AuthFailureTracker; var remoteConfigService = new RemoteConfigService( backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter, authFailureTracker); @@ -315,45 +275,16 @@ namespace AutopilotMonitor.Agent.V2 logger.Debug($"BootstrapConfigCleanup outer exception: {ex.Message}"); } - HttpClient mtlsHttpClient; - BackendTelemetryUploader uploader; - try + // Phase 5 (mTLS HttpClient + BackendTelemetryUploader) is encapsulated in + // BackendClientFactory. Construction failures map to V1-parity exit codes + // (4 = mTLS, 5 = uploader). See Runtime/BackendClientFactory.cs. + var telemetry = Runtime.BackendClientFactory.BuildTelemetryClients(agentConfig, auth, GetAgentVersion(), logger); + if (telemetry.ShouldExit) { - // Share BackendApiClient.NetworkMetrics so the mTLS pipeline records every - // outbound request — most importantly BackendTelemetryUploader's POST - // /api/agent/telemetry, which dominates per-session traffic. Without this the - // agent_metrics_snapshot net_total_requests undercounts ~25-30x (only the - // legacy BackendApiClient calls would show up). - mtlsHttpClient = MtlsHttpClientFactory.Create( - resolver: new DefaultCertificateResolver(), - logger: logger, - metrics: backendApiClient.NetworkMetrics); - } - catch (InvalidOperationException ex) - { - logger.Error("mTLS HttpClient creation failed — cannot upload telemetry.", ex); - return 4; - } - - try - { - uploader = new BackendTelemetryUploader( - httpClient: mtlsHttpClient, - baseUrl: agentConfig.ApiBaseUrl, - tenantId: agentConfig.TenantId, - manufacturer: hardwareForReporters.Manufacturer, - model: hardwareForReporters.Model, - serialNumber: hardwareForReporters.SerialNumber, - bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null, - agentVersion: GetAgentVersion(), - authFailureTracker: authFailureTracker, - logger: logger); // Plan §5 Fix 5 — upload-cadence logging - } - catch (Exception ex) - { - logger.Error("BackendTelemetryUploader construction failed.", ex); - return 5; + return telemetry.ExitCode; } + var mtlsHttpClient = telemetry.MtlsHttpClient; + var uploader = telemetry.Uploader; // V1 parity (MonitoringService.Start, MonitoringService.RegisterSessionAsync) — // POST /api/agent/register-session with 5-retry exponential backoff (2s/4s/8s/16s) diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs new file mode 100644 index 00000000..2bd3e500 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs @@ -0,0 +1,220 @@ +using System; +using System.Net.Http; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Runtime +{ + /// + /// Phase 3 + Phase 5 of 's RunAgent: backend HTTP-clients + /// (BackendApiClient + reporters + auth-failure tracker) and the mTLS-backed telemetry + /// uploader. Phase 4 (RemoteConfig fetch + merge + binary-integrity check) lives in + /// RunAgent between the two factory calls and consumes Phase 3's clients to + /// produce the merged . + /// + internal static class BackendClientFactory + { + /// + /// Phase 3 — construct the backend HTTP clients, the distress / emergency reporters + /// and the auth-failure tracker. Fires a one-shot AuthCertificateMissing + /// distress when client-cert auth is configured but no cert was found in either + /// LocalMachine or CurrentUser store (Legacy parity — pre-MDM-enrollment dead-end + /// surface). Tracker ceilings reflect the CLI/bootstrap defaults; tenant-policy + /// overrides are applied later via + /// once RemoteConfigMerger has run. + /// + public static BackendAuthBundle BuildAuthClients( + AgentConfiguration agentConfig, + string agentVersion, + AgentLogger logger) + { + if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + var backendApiClient = new BackendApiClient( + baseUrl: agentConfig.ApiBaseUrl, + configuration: agentConfig, + logger: logger, + agentVersion: agentVersion); + + // M4.6.γ — Emergency + Distress reporters. Plumbed into RemoteConfigService so + // Config-fetch failures (auth vs network) flow to the correct channel. Also fires + // an initial AuthCertificateMissing distress when the MDM cert was expected but + // not found (Legacy parity — surfaces pre-MDM-enrollment dead-ends to the backend + // via the cert-less distress channel). + var hardware = HardwareInfo.GetHardwareInfo(logger); + var distressReporter = new DistressReporter( + baseUrl: agentConfig.ApiBaseUrl, + tenantId: agentConfig.TenantId, + manufacturer: hardware.Manufacturer, + model: hardware.Model, + serialNumber: hardware.SerialNumber, + agentVersion: agentVersion, + logger: logger); + + var emergencyReporter = new EmergencyReporter( + apiClient: backendApiClient, + sessionId: agentConfig.SessionId, + tenantId: agentConfig.TenantId, + agentVersion: agentVersion, + logger: logger); + + if (agentConfig.UseClientCertAuth && backendApiClient.ClientCertificate == null) + { + _ = distressReporter.TrySendAsync( + DistressErrorType.AuthCertificateMissing, + "MDM certificate not found in LocalMachine or CurrentUser store"); + } + + // Central observer for consecutive 401/403 responses. Initialised with the CLI/bootstrap + // defaults on AgentConfiguration; UpdateLimits is called after RemoteConfigMerger.Merge + // so tenant-policy overrides take effect. ThresholdExceeded is wired in RunAgent, once + // the shutdown signal is available, to trigger a clean agent exit when the limit is hit. + // V1 parity — the distress reporter is plumbed in at construction so the tracker is + // the single dispatch point for auth-failure distress (first failure only). + var authFailureTracker = new AuthFailureTracker( + maxFailures: agentConfig.MaxAuthFailures, + timeoutMinutes: agentConfig.AuthFailureTimeoutMinutes, + clock: SystemClock.Instance, + logger: logger, + distressReporter: distressReporter); + + return new BackendAuthBundle( + backendApiClient: backendApiClient, + manufacturer: hardware.Manufacturer, + model: hardware.Model, + serialNumber: hardware.SerialNumber, + distressReporter: distressReporter, + emergencyReporter: emergencyReporter, + authFailureTracker: authFailureTracker); + } + + /// + /// Phase 5 — construct the mTLS-backed and the + /// . The uploader's + /// instance is shared with the legacy 's pipeline so + /// the agent_metrics_snapshot net_total_requests reflects every outbound HTTP call, + /// including the dominant /api/agent/telemetry POST (Plan §5 Fix 5 — without + /// this the counter undercounts ~25-30x). + /// + public static TelemetryClientResult BuildTelemetryClients( + AgentConfiguration agentConfig, + BackendAuthBundle auth, + string agentVersion, + AgentLogger logger) + { + if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig)); + if (auth == null) throw new ArgumentNullException(nameof(auth)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + HttpClient mtlsHttpClient; + try + { + // Share BackendApiClient.NetworkMetrics so the mTLS pipeline records every + // outbound request — most importantly BackendTelemetryUploader's POST + // /api/agent/telemetry, which dominates per-session traffic. Without this the + // agent_metrics_snapshot net_total_requests undercounts ~25-30x (only the + // legacy BackendApiClient calls would show up). + mtlsHttpClient = MtlsHttpClientFactory.Create( + resolver: new DefaultCertificateResolver(), + logger: logger, + metrics: auth.BackendApiClient.NetworkMetrics); + } + catch (InvalidOperationException ex) + { + logger.Error("mTLS HttpClient creation failed — cannot upload telemetry.", ex); + return TelemetryClientResult.Exit(4); + } + + BackendTelemetryUploader uploader; + try + { + uploader = new BackendTelemetryUploader( + httpClient: mtlsHttpClient, + baseUrl: agentConfig.ApiBaseUrl, + tenantId: agentConfig.TenantId, + manufacturer: auth.Manufacturer, + model: auth.Model, + serialNumber: auth.SerialNumber, + bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null, + agentVersion: agentVersion, + authFailureTracker: auth.AuthFailureTracker, + logger: logger); // Plan §5 Fix 5 — upload-cadence logging + } + catch (Exception ex) + { + logger.Error("BackendTelemetryUploader construction failed.", ex); + return TelemetryClientResult.Exit(5); + } + + return TelemetryClientResult.Continue(mtlsHttpClient, uploader); + } + } + + /// + /// Phase 3 outcome — the four backend-facing clients and the cached hardware identity + /// they were built with. RunAgent retains this bundle through the rest of startup so + /// the lifecycle / termination wiring can read each client without re-construction. + /// + internal sealed class BackendAuthBundle + { + public BackendApiClient BackendApiClient { get; } + public string Manufacturer { get; } + public string Model { get; } + public string SerialNumber { get; } + public DistressReporter DistressReporter { get; } + public EmergencyReporter EmergencyReporter { get; } + public AuthFailureTracker AuthFailureTracker { get; } + + public BackendAuthBundle( + BackendApiClient backendApiClient, + string manufacturer, + string model, + string serialNumber, + DistressReporter distressReporter, + EmergencyReporter emergencyReporter, + AuthFailureTracker authFailureTracker) + { + BackendApiClient = backendApiClient; + Manufacturer = manufacturer; + Model = model; + SerialNumber = serialNumber; + DistressReporter = distressReporter; + EmergencyReporter = emergencyReporter; + AuthFailureTracker = authFailureTracker; + } + } + + /// + /// Phase 5 outcome — either an early exit (V1 parity: 4 = mTLS construction failure, + /// 5 = uploader construction failure) or a Continue payload carrying the live mTLS + /// and . + /// + internal sealed class TelemetryClientResult + { + public bool ShouldExit { get; } + public int ExitCode { get; } + public HttpClient MtlsHttpClient { get; } + public BackendTelemetryUploader Uploader { get; } + + private TelemetryClientResult(bool shouldExit, int exitCode, HttpClient mtls, BackendTelemetryUploader uploader) + { + ShouldExit = shouldExit; + ExitCode = exitCode; + MtlsHttpClient = mtls; + Uploader = uploader; + } + + public static TelemetryClientResult Exit(int code) + => new TelemetryClientResult(true, code, null, null); + + public static TelemetryClientResult Continue(HttpClient mtls, BackendTelemetryUploader uploader) + => new TelemetryClientResult(false, 0, mtls, uploader); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 526204c8..5b0cffbc 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -367 +370 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index bacbcf47..8c8b8bca 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1177 +1179