diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs index 4bd51a7478f..5e60454c185 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/ExportClient/OtlpGrpcExportClient.cs @@ -10,7 +10,7 @@ namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; /// Base class for sending OTLP export request over gRPC. -internal sealed class OtlpGrpcExportClient : OtlpExportClient +internal sealed class OtlpGrpcExportClient : OtlpExportClient, IDisposable { public const string GrpcStatusDetailsHeader = "grpc-status-details-bin"; private static readonly ExportClientHttpResponse SuccessExportResponse = new(success: true, deadlineUtc: default, response: null, exception: null); @@ -24,18 +24,97 @@ private static readonly ExportClientGrpcResponse DefaultExceptionExportClientGrp status: null, grpcStatusDetailsHeader: null); +#if NET8_0_OR_GREATER + private readonly HttpClient? secureClient; + private readonly bool useMtls; + private readonly OtlpExporterOptions options; + private readonly string signalPath; + private bool disposed; +#endif + public OtlpGrpcExportClient(OtlpExporterOptions options, HttpClient httpClient, string signalPath) : base(options, httpClient, signalPath) { +#if NET8_0_OR_GREATER + this.options = options; + this.signalPath = signalPath; + + // Determine if we should use mTLS based on certificate configuration and endpoint + this.useMtls = options.Endpoint.Scheme == Uri.UriSchemeHttps && + (!string.IsNullOrEmpty(options.CertificateFilePath) || + (!string.IsNullOrEmpty(options.ClientCertificateFilePath) && !string.IsNullOrEmpty(options.ClientKeyFilePath))); + + // Create a secure client if mTLS is enabled + if (this.useMtls) + { + using var handler = new HttpClientHandler(); + + #if !NET462 + handler.CheckCertificateRevocationList = true; + #endif + + if (!string.IsNullOrEmpty(options.CertificateFilePath)) + { + using var trustedCertificate = MtlsUtility.LoadCertificateWithValidation(options.CertificateFilePath); + handler.ServerCertificateCustomValidationCallback = (_, cert, __, unexpectedErrors) => + { + return MtlsUtility.ValidateCertificateChain(cert!, trustedCertificate); + }; + } + + if (!string.IsNullOrEmpty(options.ClientCertificateFilePath) && !string.IsNullOrEmpty(options.ClientKeyFilePath)) + { + var clientCertificate = MtlsUtility.LoadCertificateWithValidation( + options.ClientCertificateFilePath, + options.ClientKeyFilePath); + handler.ClientCertificates.Add(clientCertificate); + } + + this.secureClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromMilliseconds(options.TimeoutMilliseconds), + }; + } +#endif } internal override MediaTypeHeaderValue MediaTypeHeader => MediaHeaderValue; internal override bool RequireHttp2 => true; - /// + /// public override ExportClientResponse SendExportRequest(byte[] buffer, int contentLength, DateTime deadlineUtc, CancellationToken cancellationToken = default) { +#if NET8_0_OR_GREATER + if (this.useMtls && this.secureClient != null) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, this.Endpoint) + { + Content = new ByteArrayContent(buffer, 0, contentLength) + { + Headers = + { + ContentType = this.MediaTypeHeader, + }, + }, + }; + + using var response = this.secureClient.SendAsync(request, cancellationToken).GetAwaiter().GetResult(); + response.EnsureSuccessStatusCode(); + + OpenTelemetryProtocolExporterEventSource.Log.ExportSuccess(this.Endpoint.ToString(), "mTLS export completed successfully."); + return SuccessExportResponse; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.FailedToReachCollector(this.Endpoint, ex); + return DefaultExceptionExportClientGrpcResponse; + } + } +#endif + try { using var httpRequest = this.CreateHttpRequest(buffer, contentLength); @@ -44,7 +123,7 @@ public override ExportClientResponse SendExportRequest(byte[] buffer, int conten httpResponse.EnsureSuccessStatusCode(); var trailingHeaders = httpResponse.TrailingHeaders(); - Status status = GrpcProtocolHelpers.GetResponseStatus(httpResponse, trailingHeaders); + var status = GrpcProtocolHelpers.GetResponseStatus(httpResponse, trailingHeaders); if (status.Detail.Equals(Status.NoReplyDetailMessage, StringComparison.Ordinal)) { @@ -153,6 +232,40 @@ public override ExportClientResponse SendExportRequest(byte[] buffer, int conten } } + /// + /// Shuts down the exporter and cleans up any resources. + /// + /// The maximum time to wait for the shutdown to complete. + /// True if shutdown succeeded. False otherwise. + public new bool Shutdown(int timeoutMilliseconds) + { +#if NET8_0_OR_GREATER + if (this.secureClient != null) + { + try + { + this.secureClient.Dispose(); + } + catch + { + // Ignore shutdown errors + } + } +#endif + return base.Shutdown(timeoutMilliseconds); + } + + /// + /// Releases all resources used by the current instance. + /// + public void Dispose() + { +#if NET8_0_OR_GREATER + this.Dispose(true); + GC.SuppressFinalize(this); +#endif + } + private static bool IsTransientNetworkError(HttpRequestException ex) { return ex.InnerException is System.Net.Sockets.SocketException socketEx @@ -160,4 +273,23 @@ private static bool IsTransientNetworkError(HttpRequestException ex) || socketEx.SocketErrorCode == System.Net.Sockets.SocketError.ConnectionReset || socketEx.SocketErrorCode == System.Net.Sockets.SocketError.HostUnreachable); } + +#if NET8_0_OR_GREATER + /// + /// Releases the unmanaged resources used by the instance and optionally releases the managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + private void Dispose(bool disposing) + { + if (!this.disposed) + { + if (disposing) + { + this.secureClient?.Dispose(); + } + + this.disposed = true; + } + } +#endif } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/MtlsUtility.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/MtlsUtility.cs new file mode 100644 index 00000000000..196806cb500 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/MtlsUtility.cs @@ -0,0 +1,185 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET8_0_OR_GREATER +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; + +/// +/// Utility class for mutual TLS (mTLS) certificate operations. +/// +internal static class MtlsUtility +{ + /// + /// Loads a certificate from a PEM file with validation checks. + /// + /// Path to the certificate file. + /// The loaded certificate. + /// Thrown when the certificate file is not found. + /// Thrown when there's insufficient permission to read the file. + /// Thrown when the certificate is invalid. + public static X509Certificate2 LoadCertificateWithValidation(string certificateFilePath) + { + ArgumentNullException.ThrowIfNull(certificateFilePath); + + if (!File.Exists(certificateFilePath)) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateFileNotFound(certificateFilePath); + throw new FileNotFoundException("Certificate file not found.", certificateFilePath); + } + +#if NET9_0_OR_GREATER + try + { + var pemContent = File.ReadAllText(certificateFilePath); + var certificate = X509Certificate2.CreateFromPem(pemContent); + ValidateCertificate(certificate); + return certificate; + } + catch (CryptographicException ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateInvalid(ex); + throw; + } +#else + try + { + // Read the PEM content from the certificate file + var pemContent = File.ReadAllText(certificateFilePath); + + // Create the certificate from PEM. If the PEM contains only the certificate, + // the resulting X509Certificate2 will not have a private key. + var certificate = X509Certificate2.CreateFromPem(pemContent); + ValidateCertificate(certificate); + return certificate; // Caller is responsible for disposal + } + catch (CryptographicException ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateInvalid(ex); + throw; + } +#endif + } + + /// + /// Loads a certificate with its private key from PEM files with validation checks. + /// + /// Path to the certificate file. + /// Path to the private key file. + /// The loaded certificate with private key. + /// Thrown when either file is not found. + /// Thrown when there\'s insufficient permission to read either file. + /// Thrown when the certificate or key is invalid. + public static X509Certificate2 LoadCertificateWithValidation(string certificateFilePath, string keyFilePath) + { + ArgumentNullException.ThrowIfNull(certificateFilePath); + ArgumentNullException.ThrowIfNull(keyFilePath); + + if (!File.Exists(certificateFilePath)) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateFileNotFound(certificateFilePath); + throw new FileNotFoundException("Certificate file not found.", certificateFilePath); + } + + if (!File.Exists(keyFilePath)) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateFileNotFound(keyFilePath); + throw new FileNotFoundException("Key file not found.", keyFilePath); + } + +#if NET9_0_OR_GREATER + try + { + var certPem = File.ReadAllText(certificateFilePath); + var keyPem = File.ReadAllText(keyFilePath); + var certificate = X509Certificate2.CreateFromPem(certPem, keyPem); + ValidateCertificate(certificate); + return certificate; + } + catch (CryptographicException ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateInvalid(ex); + throw; + } +#else + try + { + // Read the PEM content from the certificate and key files + var certPem = File.ReadAllText(certificateFilePath); + var keyPem = File.ReadAllText(keyFilePath); + + // Create the certificate from PEM, associating the private key. + var certificate = X509Certificate2.CreateFromPem(certPem, keyPem); + ValidateCertificate(certificate); + return certificate; // Caller is responsible for disposal + } + catch (CryptographicException ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateInvalid(ex); + throw; + } +#endif + } + + /// + /// Validates a certificate chain against a trusted root certificate. + /// + /// The certificate to validate. + /// The trusted root certificate. + /// True if the chain is valid, false otherwise. + public static bool ValidateCertificateChain(X509Certificate2 certificate, X509Certificate2 trustedRoot) + { + using var chain = new X509Chain(); + chain.ChainPolicy.ExtraStore.Add(trustedRoot); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + + bool isValid = chain.Build(certificate); + + if (!isValid) + { + foreach (var status in chain.ChainStatus) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidationFailed( + status.StatusInformation); + } + } + + return isValid; + } + + private static void ValidateCertificate(X509Certificate2 certificate) + { + ArgumentNullException.ThrowIfNull(certificate); + + using var chain = new X509Chain(); + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + + if (!chain.Build(certificate)) + { + var statusInformation = string.Join(", ", chain.ChainStatus.Select(s => $"{s.StatusInformation} ({s.Status})")); + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidationFailed(statusInformation); + throw new InvalidOperationException($"Certificate chain validation failed: {statusInformation}"); + } + } + + private static void ValidateFilePermissions(string filePath) + { + try + { + // Check if file exists and is readable + using (File.OpenRead(filePath)) + { + } + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsFilePermissionCheckFailed(filePath, ex); + throw; + } + } +} +#endif diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs index c4d21d92370..6af9ffadb52 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs @@ -13,6 +13,11 @@ internal sealed class OpenTelemetryProtocolExporterEventSource : EventSource, IC { public static readonly OpenTelemetryProtocolExporterEventSource Log = new(); + void IConfigurationExtensionsLogger.LogInvalidConfigurationValue(string key, string value) + { + this.InvalidConfigurationValue(key, value); + } + [NonEvent] public void FailedToReachCollector(Uri collectorUri, Exception ex) { @@ -104,6 +109,33 @@ public void ExportFailure(Uri endpoint, string message, Status status) } } + [NonEvent] + public void MtlsCertificateLoadError(Exception ex) + { + if (Log.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.MtlsCertificateLoadError_(ex.ToInvariantString()); + } + } + + [NonEvent] + public void MtlsPermissionCheckWarning(string filePath, Exception ex) + { + if (Log.IsEnabled(EventLevel.Warning, EventKeywords.All)) + { + this.MtlsPermissionCheckWarning(filePath, ex.ToInvariantString()); + } + } + + [NonEvent] + public void MtlsConfigurationSuccess(string component) + { + if (this.IsEnabled(EventLevel.Informational, EventKeywords.All)) + { + this.MtlsConfigurationSuccess_(component); + } + } + [Event(2, Message = "Exporter failed send data to collector to {0} endpoint. Data will not be sent. Exception: {1}", Level = EventLevel.Error)] public void FailedToReachCollector(string rawCollectorUri, string ex) { @@ -128,6 +160,12 @@ public void CouldNotTranslateMetric(string className, string methodName) this.WriteEvent(5, className, methodName); } + [Event(7, Message = "Timeout value configured for Otel Exporter was {0}, but was overridden to {1} due to MaxTimeoutValue: {2}.", Level = EventLevel.Warning)] + public void TimeoutOverrideWarning(int configuredTimeout, int effectiveTimeout, int maxTimeoutValue) + { + this.WriteEvent(7, configuredTimeout, effectiveTimeout, maxTimeoutValue); + } + [Event(8, Message = "Unsupported value for protocol '{0}' is configured, default protocol 'grpc' will be used.", Level = EventLevel.Warning)] public void UnsupportedProtocol(string protocol) { @@ -236,8 +274,98 @@ public void ArrayBufferExceededMaxSize() this.WriteEvent(25); } - void IConfigurationExtensionsLogger.LogInvalidConfigurationValue(string key, string value) + // mTLS Events + + [Event(26, Message = "mTLS certificate file not found: {0}", Level = EventLevel.Error)] + public void MtlsCertificateFileNotFound(string filePath) { - this.InvalidConfigurationValue(key, value); + this.WriteEvent(26, filePath); + } + + [Event(27, Message = "mTLS certificate invalid: {0}", Level = EventLevel.Error)] + public void MtlsCertificateInvalid(Exception ex) + { + this.WriteEvent(27, ex.ToInvariantString()); + } + + [Event(28, Message = "mTLS certificate validation failed: {0}", Level = EventLevel.Error)] + public void MtlsCertificateValidationFailed(string message) + { + this.WriteEvent(28, message); + } + + [Event(29, Message = "mTLS certificate chain validation failed: {0}", Level = EventLevel.Error)] + public void MtlsCertificateChainValidationFailed(string statusInformation) + { + this.WriteEvent(29, statusInformation); + } + + [Event(30, Message = "mTLS file permission check failed for {0}: {1}", Level = EventLevel.Warning)] + public void MtlsFilePermissionCheckFailed(string filePath, Exception ex) + { + this.WriteEvent(30, filePath, ex.ToInvariantString()); + } + + [Event(31, Message = "Certificate permission check warning for file {0}. Exception: {1}", Level = EventLevel.Warning)] + public void MtlsPermissionCheckWarning(string filePath, string exception) + { + this.WriteEvent(31, filePath, exception); + } + + [NonEvent] + public void ConfigurationError(string message) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.ConfigurationError_(message); + } + } + + [Event(2, Message = "Environment variable is invalid, key = {0}, value = {1}, error = {2}", Level = EventLevel.Warning)] + public void EnvVarInvalid(string key, string value, string error) + { + if (this.IsEnabled(EventLevel.Warning, EventKeywords.All)) + { + this.WriteEvent(2, key, value, error); + } + } + + [Event(3, Message = "Unsupported action '{0}'", Level = EventLevel.Warning)] + public void UnsupportedAction(string action) + { + if (this.IsEnabled(EventLevel.Warning, EventKeywords.All)) + { + this.WriteEvent(3, action); + } + } + + [Event(4, Message = "Exporter HTTP request failed with status {0}. Error message: {1}", Level = EventLevel.Warning)] + public void ExportHttpFailure(int responseStatusCode, string error) + { + if (this.IsEnabled(EventLevel.Warning, EventKeywords.All)) + { + this.WriteEvent(4, responseStatusCode, error); + } + } + + [Event(5, Message = "Configuration error: {0}", Level = EventLevel.Error)] + private void ConfigurationError_(string message) + { + this.WriteEvent(5, message); + } + + [Event(11, Message = "mTLS certificate load error: {0}", Level = EventLevel.Error)] + private void MtlsCertificateLoadError_(string exception) + { + if (this.IsEnabled(EventLevel.Error, EventKeywords.All)) + { + this.WriteEvent(11, exception); + } + } + + [Event(16, Message = "mTLS configuration succeeded for {0}", Level = EventLevel.Informational)] + private void MtlsConfigurationSuccess_(string component) + { + this.WriteEvent(16, component); } } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index 91ebfdbd3e1..56361b8c326 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -72,10 +72,92 @@ internal OtlpExporterOptions( this.DefaultHttpClientFactory = () => { - return new HttpClient +#if NET8_0_OR_GREATER + // For .NET 8 and later, we can load certificates from PEM files + HttpClientHandler handler = new HttpClientHandler(); + + #if !NET462 + handler.CheckCertificateRevocationList = true; + #endif + + try + { + // Add trusted root certificate if provided + if (!string.IsNullOrEmpty(this.CertificateFilePath)) + { + try + { + var trustedCertificate = MtlsUtility.LoadCertificateWithValidation(this.CertificateFilePath); + + handler.ServerCertificateCustomValidationCallback = (_, cert, __, unexpectedErrors) => + { + return MtlsUtility.ValidateCertificateChain(cert!, trustedCertificate); + }; + + OpenTelemetryProtocolExporterEventSource.Log.MtlsConfigurationSuccess("server validation"); + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadError(ex); + } + } + + // Add client certificate if both files are provided + if (!string.IsNullOrEmpty(this.ClientCertificateFilePath) && !string.IsNullOrEmpty(this.ClientKeyFilePath)) + { + try + { + var clientCertificate = MtlsUtility.LoadCertificateWithValidation( + this.ClientCertificateFilePath, + this.ClientKeyFilePath); + + handler.ClientCertificates.Add(clientCertificate); + OpenTelemetryProtocolExporterEventSource.Log.MtlsConfigurationSuccess("HTTPS client authentication"); + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadError(ex); + } + } + + return new HttpClient(handler) + { + Timeout = TimeSpan.FromMilliseconds(this.TimeoutMilliseconds), + }; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadError(ex); + handler.Dispose(); + + var fallbackHandler = new HttpClientHandler(); + + #if !NET462 + // CheckCertificateRevocationList is not available in .NET 4.6.2 + fallbackHandler.CheckCertificateRevocationList = true; + #endif + + return new HttpClient(fallbackHandler) + { + Timeout = TimeSpan.FromMilliseconds(this.TimeoutMilliseconds), + }; + } +#else + // For earlier .NET versions + var handler = new HttpClientHandler(); + + #if !NET462 + // CheckCertificateRevocationList is not available in .NET 4.6.2 + handler.CheckCertificateRevocationList = true; + #else + #pragma warning disable CA5399 // HttpClient is created without enabling CheckCertificateRevocationList + #endif + + return new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(this.TimeoutMilliseconds), }; +#endif }; this.BatchExportProcessorOptions = defaultBatchOptions!; @@ -148,6 +230,36 @@ public Func HttpClientFactory } } + /// + /// Gets or sets the path to a PEM-encoded CA certificate file used to verify server identity. + /// This option is only supported on .NET 8.0 or later. + /// + /// + /// When specified, this certificate will be used to validate the server's certificate. + /// The file must be readable by the application and contain a valid PEM-encoded certificate. + /// + internal string? CertificateFilePath { get; set; } + + /// + /// Gets or sets the path to a PEM-encoded client certificate file used for client authentication. + /// This option is only supported on .NET 8.0 or later and must be used with . + /// + /// + /// When specified along with , this certificate will be used for client authentication. + /// The file must be readable by the application and contain a valid PEM-encoded certificate. + /// + internal string? ClientCertificateFilePath { get; set; } + + /// + /// Gets or sets the path to a PEM-encoded private key file for the client certificate. + /// This option is only supported on .NET 8.0 or later and must be used with . + /// + /// + /// This private key will be used with the for client authentication. + /// The file must be readable by the application and contain a valid PEM-encoded private key. + /// + internal string? ClientKeyFilePath { get; set; } + /// /// Gets a value indicating whether or not the signal-specific path should /// be appended to . diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs index 218b4721caa..6bc33cb043d 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptionsExtensions.cs @@ -9,6 +9,8 @@ using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.ExportClient; using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation.Transmission; +#if NET8_0_OR_GREATER +#endif namespace OpenTelemetry.Exporter; diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MTlsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MTlsTests.cs new file mode 100644 index 00000000000..81aa3721e7c --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MTlsTests.cs @@ -0,0 +1,224 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET8_0_OR_GREATER +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; +using Xunit; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; + +public class MtlsTests : IDisposable +{ + private readonly string tempDir; + private readonly string caCertPath; + private readonly string clientCertPath; + private readonly string clientKeyPath; + private readonly string invalidCertPath; + + public MtlsTests() + { + // Create temporary directory for test certificates + this.tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(this.tempDir); + + // Set up paths + this.caCertPath = Path.Combine(this.tempDir, "ca.pem"); + this.clientCertPath = Path.Combine(this.tempDir, "client.pem"); + this.clientKeyPath = Path.Combine(this.tempDir, "client-key.pem"); + this.invalidCertPath = Path.Combine(this.tempDir, "invalid.pem"); + + // Create test certificates + this.CreateTestCertificates(); + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + [Fact] + public void LoadCertificateWithValidation_ValidCertificates_LoadsSuccessfully() + { + // Act & Assert - no exception should be thrown + using (var caCert = MtlsUtility.LoadCertificateWithValidation(this.caCertPath)) + { + Assert.NotNull(caCert); + using (var clientCert = MtlsUtility.LoadCertificateWithValidation(this.clientCertPath, this.clientKeyPath)) + { + Assert.NotNull(clientCert); + } + } + } + + [Fact] + public void LoadCertificateWithValidation_NonExistentFile_ThrowsFileNotFoundException() + { + // Act & Assert + var ex = Assert.Throws(() => + MtlsUtility.LoadCertificateWithValidation(Path.Combine(this.tempDir, "nonexistent.pem"))); + + Assert.Contains("Certificate file not found", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LoadCertificateWithValidation_InvalidCertificate_ThrowsException() + { + // Arrange - create an invalid certificate file + File.WriteAllText(this.invalidCertPath, "This is not a valid certificate"); + + // Act & Assert + Assert.ThrowsAny(() => + MtlsUtility.LoadCertificateWithValidation(this.invalidCertPath)); + } + + [Fact] + public void OtlpExporterOptions_WithValidCertificates_ConfiguresHttpClient() + { + // Arrange + var options = new OtlpExporterOptions + { + CertificateFilePath = this.caCertPath, + ClientCertificateFilePath = this.clientCertPath, + ClientKeyFilePath = this.clientKeyPath, + }; + + // Act + HttpClient client = options.HttpClientFactory(); + + // Assert + Assert.NotNull(client); + + // Note: We can't directly check the certificates in the handler, but at least we confirm no exception is thrown + } + + [Fact] + public void ValidateCertificateChain_ValidChain_ReturnsTrue() + { + // Arrange + using var caCert = MtlsUtility.LoadCertificateWithValidation(this.caCertPath); + using var clientCert = MtlsUtility.LoadCertificateWithValidation(this.clientCertPath); + + // Act + bool isValid = MtlsUtility.ValidateCertificateChain(clientCert, caCert); + + // Assert + Assert.True(isValid); + } + + [Fact] + public void ConfigureHttpClientForMtls_WithValidCertificates_CreatesClient() + { + // Arrange + var options = new OtlpExporterOptions + { + Endpoint = new Uri("https://localhost:4317"), + CertificateFilePath = this.caCertPath, + ClientCertificateFilePath = this.clientCertPath, + ClientKeyFilePath = this.clientKeyPath, + }; + + // Act + var client = options.HttpClientFactory(); + + // Assert + Assert.NotNull(client); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + if (Directory.Exists(this.tempDir)) + { + Directory.Delete(this.tempDir, true); + } + } + } + + private static string PemEncodeX509Certificate(X509Certificate2 cert) + { + string pemEncodedCert = "-----BEGIN CERTIFICATE-----\n"; + pemEncodedCert += Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks); + pemEncodedCert += "\n-----END CERTIFICATE-----"; + return pemEncodedCert; + } + + private static string PemEncodePrivateKey(RSA rsa) + { + var privateKey = rsa.ExportPkcs8PrivateKey(); + string pemEncodedKey = "-----BEGIN PRIVATE KEY-----\n"; + pemEncodedKey += Convert.ToBase64String(privateKey, Base64FormattingOptions.InsertLineBreaks); + pemEncodedKey += "\n-----END PRIVATE KEY-----"; + return pemEncodedKey; + } + + private static void MakeFileSecure(string filePath) + { + // For security in tests, we'll just ensure the file is readable + using (File.OpenRead(filePath)) + { + // Just verify we can read the file + } + } + + private void CreateTestCertificates() + { + // Generate a simple self-signed certificate for testing + using var rsa = RSA.Create(2048); + var distinguishedName = new X500DistinguishedName("CN=Test CA"); + var certRequest = new CertificateRequest( + distinguishedName, + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + certRequest.CertificateExtensions.Add( + new X509BasicConstraintsExtension(true, false, 0, true)); + + certRequest.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyCertSign, + false)); + + // Create CA certificate + var now = DateTimeOffset.UtcNow; + var caCert = certRequest.CreateSelfSigned( + now, + now.AddYears(1)); + + // Create client certificate signed by the CA + using var clientKeyRsa = RSA.Create(2048); + var clientDistinguishedName = new X500DistinguishedName("CN=Test Client"); + var clientCertRequest = new CertificateRequest( + clientDistinguishedName, + clientKeyRsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + clientCertRequest.CertificateExtensions.Add( + new X509BasicConstraintsExtension(false, false, 0, true)); + + clientCertRequest.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + false)); + + var clientCert = clientCertRequest.Create( + caCert, + now, + now.AddYears(1), + new byte[] { 1, 2, 3, 4 }); + + // Export certificates and keys to PEM files + File.WriteAllText(this.caCertPath, PemEncodeX509Certificate(caCert)); + File.WriteAllText(this.clientCertPath, PemEncodeX509Certificate(clientCert)); + File.WriteAllText(this.clientKeyPath, PemEncodePrivateKey(clientKeyRsa)); + + // Create an invalid certificate file + File.WriteAllText(this.invalidCertPath, "This is not a valid certificate"); + } +} +#endif diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs index 0f810a25d34..bf97eca8113 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MockCollectorIntegrationTests.cs @@ -491,7 +491,7 @@ public async Task GrpcPersistentStorageRetryTests(bool usePersistentStorageTrans var exporterOptions = new OtlpExporterOptions() { Endpoint = endpoint, TimeoutMilliseconds = 20000 }; using var exporterHttpClient = new HttpClient(); - var exportClient = new OtlpGrpcExportClient(exporterOptions, exporterHttpClient, "opentelemetry.proto.collector.trace.v1.TraceService/Export"); + using var exportClient = new OtlpGrpcExportClient(exporterOptions, exporterHttpClient, "opentelemetry.proto.collector.trace.v1.TraceService/Export"); // TODO: update this to configure via experimental environment variable. OtlpExporterTransmissionHandler transmissionHandler; diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MtlsUtilityTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MtlsUtilityTests.cs new file mode 100644 index 00000000000..3a5a1675b5c --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/MtlsUtilityTests.cs @@ -0,0 +1,155 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET8_0_OR_GREATER +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; +using Xunit; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; + +public class MtlsUtilityTests : IDisposable +{ + private readonly string tempFolder; + private readonly string validCertPath; + private readonly string validKeyPath; + private readonly string invalidCertPath; + private readonly string nonExistentPath; + + public MtlsUtilityTests() + { + // Create a temporary folder for test certificates + this.tempFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(this.tempFolder); + + // Create paths for test files + this.validCertPath = Path.Combine(this.tempFolder, "valid-cert.pem"); + this.validKeyPath = Path.Combine(this.tempFolder, "valid-key.pem"); + this.invalidCertPath = Path.Combine(this.tempFolder, "invalid-cert.pem"); + this.nonExistentPath = Path.Combine(this.tempFolder, "non-existent.pem"); + + // Generate a test certificate and key + using var rsa = RSA.Create(2048); + using var cert = MtlsUtilityTests.GenerateTestCertificate(rsa); + + // Export the certificate and key to PEM files + File.WriteAllText(this.validCertPath, PemEncodeX509Certificate(cert)); + File.WriteAllText(this.validKeyPath, PemEncodePrivateKey(rsa)); + + // Create an invalid certificate file + File.WriteAllText(this.invalidCertPath, "This is not a valid certificate"); + } + + [Fact] + public void LoadCertificateWithValidation_WithValidCertificate_ShouldSucceed() + { + // Act + var certificate = MtlsUtility.LoadCertificateWithValidation(this.validCertPath); + + // Assert + Assert.NotNull(certificate); + Assert.True(certificate.HasPrivateKey == false); + } + + [Fact] + public void LoadCertificateWithValidation_WithValidCertificateAndKey_ShouldSucceed() + { + // Act + var certificate = MtlsUtility.LoadCertificateWithValidation(this.validCertPath, this.validKeyPath); + + // Assert + Assert.NotNull(certificate); + Assert.True(certificate.HasPrivateKey); + } + + [Fact] + public void LoadCertificateWithValidation_WithNonExistentCertificate_ShouldThrowFileNotFoundException() + { + // Act & Assert + Assert.Throws(() => MtlsUtility.LoadCertificateWithValidation(this.nonExistentPath)); + } + + [Fact] + public void LoadCertificateWithValidation_WithNonExistentKey_ShouldThrowFileNotFoundException() + { + // Act & Assert + Assert.Throws(() => + MtlsUtility.LoadCertificateWithValidation(this.validCertPath, this.nonExistentPath)); + } + + [Fact] + public void LoadCertificateWithValidation_WithInvalidCertificate_ShouldThrowCryptographicException() + { + // Act & Assert + Assert.Throws(() => MtlsUtility.LoadCertificateWithValidation(this.invalidCertPath)); + } + + [Fact] + public void ValidateCertificateChain_WithSelfSignedCertificate_ShouldSucceed() + { + // Arrange + var cert = MtlsUtility.LoadCertificateWithValidation(this.validCertPath); + + // Act + var result = MtlsUtility.ValidateCertificateChain(cert, cert); + + // Assert + Assert.True(result); + } + + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + if (Directory.Exists(this.tempFolder)) + { + Directory.Delete(this.tempFolder, true); + } + } + } + + private static X509Certificate2 GenerateTestCertificate(RSA rsa) + { + var certRequest = new CertificateRequest( + new X500DistinguishedName("CN=Test Certificate"), + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + certRequest.CertificateExtensions.Add( + new X509BasicConstraintsExtension(false, false, 0, true)); + + certRequest.CertificateExtensions.Add( + new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, + false)); + + var now = DateTimeOffset.UtcNow; + return certRequest.CreateSelfSigned(now, now.AddYears(1)); + } + + private static string PemEncodeX509Certificate(X509Certificate2 cert) + { + string pemEncodedCert = "-----BEGIN CERTIFICATE-----\n"; + pemEncodedCert += Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks); + pemEncodedCert += "\n-----END CERTIFICATE-----"; + return pemEncodedCert; + } + + private static string PemEncodePrivateKey(RSA rsa) + { + var privateKey = rsa.ExportPkcs8PrivateKey(); + string pemEncodedKey = "-----BEGIN PRIVATE KEY-----\n"; + pemEncodedKey += Convert.ToBase64String(privateKey, Base64FormattingOptions.InsertLineBreaks); + pemEncodedKey += "\n-----END PRIVATE KEY-----"; + return pemEncodedKey; + } +} +#endif