mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
refactor(v2core): M4.4.5.d mTLS HttpClient factory + certificate resolver seam
MtlsHttpClientFactory baut HttpClient/HttpClientHandler mit Client-Cert attached fuer den BackendTelemetryUploader. Delegiert an ICertificateResolver-Seam (DefaultCertificateResolver in Prod nutzt CertificateHelper, Tests liefern Fake mit self-signed Cert). Factory wirft InvalidOperationException wenn kein Cert gefunden — mTLS ist Pflicht, Agent darf ohne Cert nicht starten. Decompression (GZip + Deflate) + 30s Default-Timeout analog Legacy BackendApiClient. CreateHandler separat exponiert, damit Tests direkt auf dem Handler asserten koennen (HttpClient._handler ist internal + reflection-fragil auf net48). +5 Agent.V2.Core Tests (260 → 265 gruen; self-signed-Cert via System.Security.Cryptography.CertificateRequest, kein Cert-Store-Touch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
public sealed class MtlsHttpClientFactoryTests
|
||||
{
|
||||
private sealed class FakeCertificateResolver : ICertificateResolver
|
||||
{
|
||||
private readonly X509Certificate2? _cert;
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public FakeCertificateResolver(X509Certificate2? cert) => _cert = cert;
|
||||
|
||||
public X509Certificate2? FindClientCertificate(AgentLogger logger)
|
||||
{
|
||||
CallCount++;
|
||||
return _cert;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-memory self-signed cert so the factory has something real to attach
|
||||
/// without touching the Windows cert store. <see cref="CertificateRequest"/> is
|
||||
/// available on net48 (4.7.2+).
|
||||
/// </summary>
|
||||
private static X509Certificate2 CreateSelfSignedCert()
|
||||
{
|
||||
using (var rsa = RSA.Create(2048))
|
||||
{
|
||||
var req = new CertificateRequest(
|
||||
"CN=apmon-v2-test",
|
||||
rsa,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
return req.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
DateTimeOffset.UtcNow.AddDays(1));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHandler_attaches_cert_and_enables_decompression()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
using var cert = CreateSelfSignedCert();
|
||||
var resolver = new FakeCertificateResolver(cert);
|
||||
|
||||
using var handler = MtlsHttpClientFactory.CreateHandler(resolver, logger);
|
||||
|
||||
Assert.Equal(1, resolver.CallCount);
|
||||
Assert.Equal(
|
||||
DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
handler.AutomaticDecompression);
|
||||
Assert.Single(handler.ClientCertificates);
|
||||
Assert.Equal(cert.Thumbprint, ((X509Certificate2)handler.ClientCertificates[0]).Thumbprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_returns_client_with_default_timeout()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
using var cert = CreateSelfSignedCert();
|
||||
var resolver = new FakeCertificateResolver(cert);
|
||||
|
||||
using var client = MtlsHttpClientFactory.Create(resolver, logger);
|
||||
|
||||
Assert.NotNull(client);
|
||||
Assert.Equal(MtlsHttpClientFactory.DefaultTimeout, client.Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_respects_custom_timeout()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
using var cert = CreateSelfSignedCert();
|
||||
var resolver = new FakeCertificateResolver(cert);
|
||||
|
||||
using var client = MtlsHttpClientFactory.Create(resolver, logger, TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), client.Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateHandler_throws_when_resolver_returns_null()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var resolver = new FakeCertificateResolver(null);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => MtlsHttpClientFactory.CreateHandler(resolver, logger));
|
||||
|
||||
Assert.Contains("no MDM client certificate found", ex.Message);
|
||||
Assert.Equal(1, resolver.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_rejects_null_dependencies()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
using var cert = CreateSelfSignedCert();
|
||||
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => MtlsHttpClientFactory.Create(null!, logger));
|
||||
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => MtlsHttpClientFactory.Create(new FakeCertificateResolver(cert), null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Baut einen <see cref="HttpClient"/> mit mTLS-Client-Cert für den
|
||||
/// <see cref="Transport.Telemetry.BackendTelemetryUploader"/>. Plan §4.x M4.4.5.d.
|
||||
/// <para>
|
||||
/// <b>Design</b>: Static Factory + <see cref="ICertificateResolver"/>-Seam statt direkte
|
||||
/// Abhängigkeit an <see cref="CertificateHelper"/>. Tests injizieren einen
|
||||
/// Fake-Resolver mit einem self-signed Cert; Prod nutzt
|
||||
/// <see cref="DefaultCertificateResolver"/>, der den Windows-Cert-Store durchsucht.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Konfiguration</b> (analog Legacy <c>BackendApiClient</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="DecompressionMethods.GZip"/> + <see cref="DecompressionMethods.Deflate"/></item>
|
||||
/// <item>Default-Timeout 30s (override via Parameter)</item>
|
||||
/// <item>Keine <c>CookieContainer</c>, keine Default-Credentials</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MtlsHttpClientFactory
|
||||
{
|
||||
/// <summary>Plan §4.x M4.4.5.d — Default-Timeout (analog Legacy).</summary>
|
||||
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Baut einen <see cref="HttpClientHandler"/> mit attached Client-Cert. Wirft
|
||||
/// <see cref="InvalidOperationException"/> wenn <paramref name="resolver"/> kein
|
||||
/// Zertifikat liefert — der Agent darf ohne Cert nicht starten (mTLS ist Pflicht).
|
||||
/// <para>
|
||||
/// Separat exponiert, damit Tests direkt auf dem Handler asserten können
|
||||
/// (<see cref="HttpClient"/>'s <c>_handler</c>-Feld ist internal und in net48
|
||||
/// reflection-unzuverlässig).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static HttpClientHandler CreateHandler(
|
||||
ICertificateResolver resolver,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (resolver == null) throw new ArgumentNullException(nameof(resolver));
|
||||
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var cert = resolver.FindClientCertificate(logger);
|
||||
if (cert == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"MtlsHttpClientFactory: no MDM client certificate found. " +
|
||||
"Agent cannot start without a client cert (mTLS is mandatory). " +
|
||||
"Check cert enrollment status in HKLM\\SOFTWARE\\Microsoft\\Enrollments.");
|
||||
}
|
||||
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
};
|
||||
handler.ClientCertificates.Add(cert);
|
||||
|
||||
logger.Info(
|
||||
$"MtlsHttpClientFactory: handler ready (cert thumbprint={cert.Thumbprint}).");
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut einen fertig konfigurierten <see cref="HttpClient"/> — Handler via
|
||||
/// <see cref="CreateHandler"/>, Timeout via Parameter (default <see cref="DefaultTimeout"/>).
|
||||
/// </summary>
|
||||
public static HttpClient Create(
|
||||
ICertificateResolver resolver,
|
||||
AgentLogger logger,
|
||||
TimeSpan? timeout = null)
|
||||
{
|
||||
var handler = CreateHandler(resolver, logger);
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
Timeout = timeout ?? DefaultTimeout,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#nullable enable
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstraktion über <see cref="CertificateHelper.FindMdmCertificate"/>, damit Tests die
|
||||
/// Zert-Auflösung ohne echten Windows-Cert-Store ersetzen können. Plan §4.x M4.4.5.d.
|
||||
/// </summary>
|
||||
public interface ICertificateResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Sucht das MDM-Client-Zertifikat. <c>null</c> wenn nichts gefunden — der Caller
|
||||
/// (typischerweise <see cref="Orchestration.MtlsHttpClientFactory"/>) entscheidet dann
|
||||
/// über Fehlerbehandlung (wirft in Prod, Test-Resolver liefert null für die negative
|
||||
/// Test-Variante).
|
||||
/// </summary>
|
||||
X509Certificate2? FindClientCertificate(AgentLogger logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produktiver <see cref="ICertificateResolver"/>. Delegiert an
|
||||
/// <see cref="CertificateHelper.FindMdmCertificate"/>.
|
||||
/// </summary>
|
||||
public sealed class DefaultCertificateResolver : ICertificateResolver
|
||||
{
|
||||
private readonly string? _thumbprintOverride;
|
||||
|
||||
public DefaultCertificateResolver(string? thumbprintOverride = null)
|
||||
{
|
||||
_thumbprintOverride = thumbprintOverride;
|
||||
}
|
||||
|
||||
public X509Certificate2? FindClientCertificate(AgentLogger logger) =>
|
||||
CertificateHelper.FindMdmCertificate(_thumbprintOverride, logger);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user