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