Files
AutopilotMonitor/src/Agent/AutopilotMonitor.Agent.V2/Runtime/BackendClientFactory.cs
T
Oliver Kieselbach 40d3922fe0 TPM-PSS distress says where the device stood when the failure began: ESP phase, OOBE flag, boot; a later success compares reboot and certificate (D-372)
The first reports with context all carried OobeState=completed: Windows'
OOBE flag flips at the desktop, was read at send time after minutes of
retries, and marks the later successful starts too, so it separated
nothing.

Agent: EspPhaseSnapshot reads the ESP registry once with the tracker's
own parser (device_preparation / device_setup / account_setup while
running, <category>_failed, *_done, esp_done, none, unavailable).
DistressDeviceContext (OOBE flag, ESP phase, boot from the uptime
counter) is captured before a registration's first attempt and travels
with the TPM-PSS report; other reports sample it at send time. The
reporter takes a DistressDetail (TPM version, context). sch= is always
set: the events, none, unreadable or timeout. The failed-run record keeps
the ESP phase and OOBE flag, and beside it - never on the wire - the boot
time and the client-certificate thumbprint; the next successful start's
agent_started reports priorRegistrationLastEspPhase, LastOobeState,
RebootedSince (boots more than 2 min apart) and CertChanged, and every
start reports espPhaseAtAgentStart.

Shared/backend: DistressReport.EspPhase (kept as [a-z_] up to 32) and
DeviceBootUtc (kept within 30 days before and 5 minutes after the report),
stored, logged and on the custom event; the TPM panel item carries the ESP
phase. The GA distress detail names the OOBE flag as such and shows ESP
phase and device boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:41 +02:00

354 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
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;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Runtime
{
/// <summary>
/// Phase 3 + Phase 5 of <see cref="Program"/>'s <c>RunAgent</c>: backend HTTP-clients
/// (BackendApiClient + reporters + auth-failure tracker) and the mTLS-backed telemetry
/// uploader. Phase 4 (RemoteConfig fetch + merge + binary-integrity check) lives in
/// <c>RunAgent</c> between the two factory calls and consumes Phase 3's clients to
/// produce the merged <see cref="AgentConfigResponse"/>.
/// <para>
/// <b>NetworkMetrics ownership</b>: the bundle owns one <see cref="NetworkMetrics"/>
/// instance which is shared by both HttpClients (BackendApiClient + telemetry uploader)
/// via <see cref="NetworkMetricsRecordingHandler"/> in each pipeline. Without this
/// share <c>agent_metrics_snapshot.net_total_requests</c> would only reflect the few
/// BackendApiClient calls and undercount by ~25-30x.
/// </para>
/// </summary>
internal static class BackendClientFactory
{
/// <summary>
/// Phase 3 — construct the backend HTTP clients, the distress / emergency reporters
/// and the auth-failure tracker. Resolves the MDM client certificate when cert-mode
/// is enabled; on cert-missing the agent still starts (V1 parity — config-fetch will
/// 401, AuthCertificateMissing distress fires, Phase 5's mTLS pipeline then refuses
/// to start with Exit 4). Tracker ceilings reflect the CLI/bootstrap defaults;
/// tenant-policy overrides are applied later via
/// <see cref="AuthFailureTracker.UpdateLimits"/> once <c>RemoteConfigMerger</c> has run.
/// </summary>
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));
// Single source of truth for HTTP request/byte counters. Shared with the mTLS
// telemetry pipeline in BuildTelemetryClients so net_total_requests reflects every
// outbound call (legacy BackendApiClient calls + dominant /api/agent/telemetry POSTs).
var networkMetrics = new NetworkMetrics();
var hardware = HardwareInfo.GetHardwareInfo(logger);
// Resolve the MDM client cert when cert-mode is on. Cert-missing is non-fatal here:
// BackendApiClient still gets a plain (no-cert) HttpClient and the agent continues so
// (a) Phase 4 config-fetch can fall back to cached config on 401 and (b) the
// AuthCertificateMissing distress reaches the backend before Phase 5's mTLS gate.
X509Certificate2 clientCertificate = null;
if (agentConfig.UseClientCertAuth)
{
logger.Debug("Client certificate authentication enabled — searching for certificate...");
clientCertificate = new DefaultCertificateResolver().FindClientCertificate(logger);
if (clientCertificate != null)
{
logger.Info($"Client certificate loaded successfully (Thumbprint={clientCertificate.Thumbprint}).");
}
else
{
logger.Warning("Client certificate authentication enabled but no certificate found — requests will be sent WITHOUT certificate (will likely fail security validation).");
}
}
else
{
logger.Debug("Client certificate authentication disabled.");
}
// One observer for both backend handler stacks (this one and the mTLS telemetry stack
// in BuildTelemetryClients): the issuer of the last validated server certificate is
// the evidence for a 401/403 that something on the network path answered.
var serverCertificates = new ServerCertificateObserver();
var apiHttpClient = BuildBackendApiHttpClient(networkMetrics, clientCertificate, agentVersion, serverCertificates);
var backendApiClient = new BackendApiClient(
httpClient: apiHttpClient,
baseUrl: agentConfig.ApiBaseUrl,
manufacturer: hardware.Manufacturer,
model: hardware.Model,
serialNumber: hardware.SerialNumber,
useBootstrapTokenAuth: agentConfig.UseBootstrapTokenAuth,
bootstrapToken: agentConfig.BootstrapToken,
agentVersion: agentVersion,
logger: logger,
serverCertificates: serverCertificates);
// 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 distressReporter = new DistressReporter(
baseUrl: agentConfig.ApiBaseUrl,
tenantId: agentConfig.TenantId,
manufacturer: hardware.Manufacturer,
model: hardware.Model,
serialNumber: hardware.SerialNumber,
agentVersion: agentVersion,
logger: logger,
clientCertificate: clientCertificate,
isCloudPc: CloudPcDetector.DetectIsCloudPc());
var emergencyReporter = new EmergencyReporter(
apiClient: backendApiClient,
sessionId: agentConfig.SessionId,
tenantId: agentConfig.TenantId,
agentVersion: agentVersion,
logger: logger);
if (agentConfig.UseClientCertAuth && 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,
networkMetrics: networkMetrics,
hasClientCertificate: clientCertificate != null,
manufacturer: hardware.Manufacturer,
model: hardware.Model,
serialNumber: hardware.SerialNumber,
distressReporter: distressReporter,
emergencyReporter: emergencyReporter,
authFailureTracker: authFailureTracker,
serverCertificates: serverCertificates,
clientCertificateThumbprint: clientCertificate?.Thumbprint);
}
/// <summary>
/// Phase 5 — construct the mTLS-backed <see cref="HttpClient"/> and the
/// <see cref="BackendTelemetryUploader"/>. Reads the bundle's shared
/// <see cref="NetworkMetrics"/> so the agent_metrics_snapshot
/// net_total_requests reflects the dominant <c>/api/agent/telemetry</c> POST stream.
/// </summary>
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
{
// Telemetry path is cert-mandatory: MtlsHttpClientFactory.Create throws on
// missing cert which surfaces here as Exit 4 (V1 parity).
mtlsHttpClient = MtlsHttpClientFactory.Create(
resolver: new DefaultCertificateResolver(),
logger: logger,
metrics: auth.NetworkMetrics,
serverCertificates: auth.ServerCertificates);
}
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
serverCertificates: auth.ServerCertificates,
networkMetrics: auth.NetworkMetrics);
}
catch (Exception ex)
{
logger.Error("BackendTelemetryUploader construction failed.", ex);
return TelemetryClientResult.Exit(5);
}
return TelemetryClientResult.Continue(mtlsHttpClient, uploader);
}
/// <summary>
/// Builds the cert-optional HttpClient used by <see cref="BackendApiClient"/>.
/// Cert is attached at the TLS layer when present; missing cert degrades to a plain
/// (no-cert) handler so the agent can continue starting up — the eventual 401 from
/// the backend then drives the AuthFailureTracker / shutdown gate.
/// <para>
/// Pipeline composition (top → bottom):
/// <list type="number">
/// <item><see cref="NetworkMetricsRecordingHandler"/> — records every SendAsync into
/// the shared <see cref="NetworkMetrics"/> counter.</item>
/// <item><see cref="HttpClientHandler"/> — TLS, optional client cert,
/// gzip/deflate response decompression, optional observing server-certificate
/// callback (<paramref name="serverCertificates"/>; never changes the verdict).</item>
/// </list>
/// </para>
/// </summary>
internal static HttpClient BuildBackendApiHttpClient(
NetworkMetrics networkMetrics,
X509Certificate2 clientCertificate,
string agentVersion,
ServerCertificateObserver serverCertificates = null)
{
var inner = new HttpClientHandler();
if (inner.SupportsAutomaticDecompression)
{
inner.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
}
if (clientCertificate != null)
{
inner.ClientCertificates.Add(clientCertificate);
}
serverCertificates?.Attach(inner);
var pipeline = new NetworkMetricsRecordingHandler(networkMetrics, inner);
var client = new HttpClient(pipeline)
{
Timeout = TimeSpan.FromSeconds(30),
};
var ua = string.IsNullOrEmpty(agentVersion)
? "AutopilotMonitor.Agent"
: $"AutopilotMonitor.Agent/{agentVersion}";
client.DefaultRequestHeaders.UserAgent.ParseAdd(ua);
return client;
}
}
/// <summary>
/// Phase 3 outcome — the backend-facing clients, the cached hardware identity they were
/// built with, and the shared <see cref="NetworkMetrics"/> counter (also injected into
/// the mTLS pipeline in Phase 5). RunAgent retains this bundle through the rest of
/// startup so the lifecycle / termination wiring can read each client without
/// re-construction.
/// </summary>
internal sealed class BackendAuthBundle
{
public BackendApiClient BackendApiClient { get; }
public NetworkMetrics NetworkMetrics { get; }
/// <summary>
/// True iff cert-mode was enabled and a client certificate was successfully resolved
/// from the Windows cert store. Used by tests as the observable proof that the
/// cert-lookup path was (or wasn't) exercised. Production code uses the bool to
/// decide whether <c>AuthCertificateMissing</c> distress should fire.
/// </summary>
public bool HasClientCertificate { 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; }
/// <summary>
/// Server-certificate observer shared by the BackendApiClient handler and the mTLS
/// telemetry handler built in Phase 5.
/// </summary>
public ServerCertificateObserver ServerCertificates { get; }
/// <summary>
/// Thumbprint of the client certificate this run uses; null without one. Kept beside a
/// failed run's record so the next successful start can tell whether the certificate changed.
/// </summary>
public string ClientCertificateThumbprint { get; }
public BackendAuthBundle(
BackendApiClient backendApiClient,
NetworkMetrics networkMetrics,
bool hasClientCertificate,
string manufacturer,
string model,
string serialNumber,
DistressReporter distressReporter,
EmergencyReporter emergencyReporter,
AuthFailureTracker authFailureTracker,
ServerCertificateObserver serverCertificates,
string clientCertificateThumbprint = null)
{
BackendApiClient = backendApiClient;
NetworkMetrics = networkMetrics;
HasClientCertificate = hasClientCertificate;
Manufacturer = manufacturer;
Model = model;
SerialNumber = serialNumber;
DistressReporter = distressReporter;
EmergencyReporter = emergencyReporter;
AuthFailureTracker = authFailureTracker;
ServerCertificates = serverCertificates;
ClientCertificateThumbprint = clientCertificateThumbprint;
}
}
/// <summary>
/// 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
/// <see cref="HttpClient"/> and <see cref="BackendTelemetryUploader"/>.
/// </summary>
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);
}
}