diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs index c4d21d92370..137c1c718c1 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OpenTelemetryProtocolExporterEventSource.cs @@ -240,4 +240,56 @@ void IConfigurationExtensionsLogger.LogInvalidConfigurationValue(string key, str { this.InvalidConfigurationValue(key, value); } + +#if NET + [Event(26, Message = "{0} loaded successfully from '{1}'.", Level = EventLevel.Informational)] + internal void MtlsCertificateLoaded(string certificateType, string filePath) => + this.WriteEvent(26, certificateType, filePath); + + [Event(27, Message = "Failed to load {0} from '{1}'. Error: {2}", Level = EventLevel.Error)] + internal void MtlsCertificateLoadFailed( + string certificateType, + string filePath, + string error) => this.WriteEvent(27, certificateType, filePath, error); + + [Event(28, Message = "{0} file not found at path: '{1}'.", Level = EventLevel.Error)] + internal void MtlsCertificateFileNotFound(string certificateType, string filePath) => + this.WriteEvent(28, certificateType, filePath); + + [Event( + 29, + Message = "{0} chain validation failed for certificate '{1}'. Errors: {2}", + Level = EventLevel.Error)] + internal void MtlsCertificateChainValidationFailed( + string certificateType, + string subject, + string errors) => this.WriteEvent(29, certificateType, subject, errors); + + [Event( + 30, + Message = "{0} chain validated successfully for certificate '{1}'.", + Level = EventLevel.Informational)] + internal void MtlsCertificateChainValidated(string certificateType, string subject) => + this.WriteEvent(30, certificateType, subject); + + [Event( + 31, + Message = "Server certificate validated successfully for '{0}'.", + Level = EventLevel.Informational)] + internal void MtlsServerCertificateValidated(string subject) => this.WriteEvent(31, subject); + + [Event( + 32, + Message = "Server certificate validation failed for '{0}'. Errors: {1}", + Level = EventLevel.Error)] + internal void MtlsServerCertificateValidationFailed(string subject, string errors) => + this.WriteEvent(32, subject, errors); + + [Event( + 33, + Message = "mTLS configuration enabled. Client certificate: '{0}'.", + Level = EventLevel.Informational)] + internal void MtlsConfigurationEnabled(string clientCertificateSubject) => + this.WriteEvent(33, clientCertificateSubject); +#endif } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsCertificateManager.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsCertificateManager.cs new file mode 100644 index 00000000000..4f358142a74 --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsCertificateManager.cs @@ -0,0 +1,297 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; + +/// +/// Manages certificate loading, validation, and security checks for mTLS connections. +/// +internal static class OtlpMtlsCertificateManager +{ + internal const string CaCertificateType = "CA certificate"; + internal const string ClientCertificateType = "Client certificate"; + internal const string ClientPrivateKeyType = "Client private key"; + + /// + /// Loads a CA certificate from a PEM file. + /// + /// Path to the CA certificate file. + /// The loaded CA certificate. + /// Thrown when the certificate file is not found. + /// Thrown when the certificate cannot be loaded. + public static X509Certificate2 LoadCaCertificate(string caCertificatePath) + { + ValidateFileExists(caCertificatePath, CaCertificateType); + + try + { + var caCertificate = X509Certificate2.CreateFromPemFile(caCertificatePath); + + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded( + CaCertificateType, + caCertificatePath); + + return caCertificate; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed( + CaCertificateType, + caCertificatePath, + ex.Message); + throw new InvalidOperationException( + $"Failed to load CA certificate from '{caCertificatePath}': {ex.Message}", + ex); + } + } + + /// + /// Loads a client certificate from a single file (e.g., PKCS#12 format) or from separate certificate and key files. + /// + /// Path to the client certificate file. + /// Path to the client private key file. Can be null for single-file certificates. + /// The loaded client certificate with private key. + /// Thrown when the certificate file is not found. + /// Thrown when the certificate cannot be loaded. + /// Thrown when clientKeyPath is not null for single-file certificate loading. + public static X509Certificate2 LoadClientCertificate( + string clientCertificatePath, + string? clientKeyPath) + { + if (clientKeyPath == null) + { + // Load certificate from a single file (e.g., PKCS#12 format) + ValidateFileExists(clientCertificatePath, ClientCertificateType); + + try + { + X509Certificate2 clientCertificate; + + // Try to load as PKCS#12 first, then as PEM + try + { +#if NET9_0_OR_GREATER + clientCertificate = X509CertificateLoader.LoadPkcs12FromFile(clientCertificatePath, (string?)null); +#else + clientCertificate = new X509Certificate2(clientCertificatePath); +#endif + } + catch (Exception ex) when (ex is CryptographicException || ex is InvalidDataException || ex is FormatException) + { + // If PKCS#12 fails, try PEM format + clientCertificate = X509Certificate2.CreateFromPemFile(clientCertificatePath); + } + + if (!clientCertificate.HasPrivateKey) + { + throw new InvalidOperationException( + "Client certificate does not have an associated private key."); + } + + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded( + ClientCertificateType, + clientCertificatePath); + + return clientCertificate; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed( + ClientCertificateType, + clientCertificatePath, + ex.Message); + throw new InvalidOperationException( + $"Failed to load client certificate from '{clientCertificatePath}': {ex.Message}", + ex); + } + } + + // Load certificate and key from separate files + ValidateFileExists(clientCertificatePath, ClientCertificateType); + ValidateFileExists(clientKeyPath, ClientPrivateKeyType); + + try + { + X509Certificate2 clientCertificate = X509Certificate2.CreateFromPemFile( + clientCertificatePath, + clientKeyPath); + + if (!clientCertificate.HasPrivateKey) + { + throw new InvalidOperationException( + "Client certificate does not have an associated private key."); + } + + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoaded( + ClientCertificateType, + clientCertificatePath); + + return clientCertificate; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateLoadFailed( + ClientCertificateType, + clientCertificatePath, + ex.Message); + throw new InvalidOperationException( + $"Failed to load client certificate from '{clientCertificatePath}' and key from '{clientKeyPath}': {ex.Message}", + ex); + } + } + + /// + /// Validates the certificate chain for a given certificate. + /// + /// The certificate to validate. + /// Type description for logging (e.g., "Client certificate"). + /// True if the certificate chain is valid; otherwise, false. + public static bool ValidateCertificateChain( + X509Certificate2 certificate, + string certificateType) + { + try + { + using var chain = new X509Chain(); + + // Configure chain policy + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag; + chain.ChainPolicy.RevocationMode = X509RevocationMode.Online; + chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot; + + bool isValid = chain.Build(certificate); + + if (!isValid) + { + var errors = chain + .ChainStatus.Where(status => status.Status != X509ChainStatusFlags.NoError) + .Select(status => $"{status.Status}: {status.StatusInformation}") + .ToArray(); + + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidationFailed( + certificateType, + certificate.Subject, + string.Join("; ", errors)); + + // Check if certificate is expired - this should throw an exception + bool isExpired = chain.ChainStatus.Any(status => + status.Status == X509ChainStatusFlags.NotTimeValid || + status.Status == X509ChainStatusFlags.NotTimeNested); + + if (isExpired) + { + throw new InvalidOperationException( + $"Certificate chain validation failed for {certificateType}: Certificate is expired. " + + $"Errors: {string.Join("; ", errors)}"); + } + + return false; + } + + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidated( + certificateType, + certificate.Subject); + return true; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateChainValidationFailed( + certificateType, + certificate.Subject, + ex.Message); + + return false; + } + } + + /// + /// Validates a server certificate against the provided CA certificate. + /// + /// The server certificate to validate. + /// The certificate chain. + /// The SSL policy errors. + /// The CA certificate to validate against. + /// True if the certificate is valid; otherwise, false. + internal static bool ValidateServerCertificate( + X509Certificate2 serverCert, + X509Chain chain, + SslPolicyErrors sslPolicyErrors, + X509Certificate2 caCertificate) + { + try + { + // If there are no SSL policy errors, accept the certificate + if (sslPolicyErrors == SslPolicyErrors.None) + { + return true; + } + + // If the only error is an untrusted root, validate against our CA + if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors) + { + // Add our CA certificate to the chain + chain.ChainPolicy.ExtraStore.Add(caCertificate); + chain.ChainPolicy.VerificationFlags = + X509VerificationFlags.AllowUnknownCertificateAuthority; + + bool isValid = chain.Build(serverCert); + + if (isValid) + { + // Verify that the chain terminates with our CA + var rootCert = chain.ChainElements[^1].Certificate; + if ( + string.Equals( + rootCert.Thumbprint, + caCertificate.Thumbprint, + StringComparison.OrdinalIgnoreCase)) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidated( + serverCert.Subject); + return true; + } + } + } + + OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidationFailed( + serverCert.Subject, + sslPolicyErrors.ToString()); + + return false; + } + catch (Exception ex) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsServerCertificateValidationFailed( + serverCert.Subject, + ex.Message); + + return false; + } + } + + private static void ValidateFileExists(string filePath, string fileType) + { + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException( + $"{fileType} path cannot be null or empty.", + nameof(filePath)); + } + + if (!File.Exists(filePath)) + { + OpenTelemetryProtocolExporterEventSource.Log.MtlsCertificateFileNotFound( + fileType, + filePath); + throw new FileNotFoundException($"{fileType} file not found at path: {filePath}", filePath); + } + } +} + +#endif diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsHttpClientFactory.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsHttpClientFactory.cs new file mode 100644 index 00000000000..a0f5169b0aa --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpMtlsHttpClientFactory.cs @@ -0,0 +1,135 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using System.Security.Cryptography.X509Certificates; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Implementation; + +/// +/// Factory for creating HttpClient instances configured with mTLS settings. +/// +internal static class OtlpMtlsHttpClientFactory +{ + /// + /// Creates an HttpClient configured with mTLS settings. + /// + /// The mTLS configuration options. + /// Optional action to configure the client. + /// An HttpClient configured for mTLS. + public static HttpClient CreateMtlsHttpClient( + OtlpMtlsOptions mtlsOptions, + Action? configureClient = null) + { + ArgumentNullException.ThrowIfNull(mtlsOptions); + + if (!mtlsOptions.IsEnabled) + { + var client = new HttpClient(); + configureClient?.Invoke(client); + return client; + } + + HttpClientHandler? handler = null; + X509Certificate2? caCertificate = null; + X509Certificate2? clientCertificate = null; + + try + { + // Load certificates + if (!string.IsNullOrEmpty(mtlsOptions.CaCertificatePath)) + { + caCertificate = OtlpMtlsCertificateManager.LoadCaCertificate( + mtlsOptions.CaCertificatePath); + + if (mtlsOptions.EnableCertificateChainValidation) + { + OtlpMtlsCertificateManager.ValidateCertificateChain( + caCertificate, + OtlpMtlsCertificateManager.CaCertificateType); + } + } + + if (!string.IsNullOrEmpty(mtlsOptions.ClientCertificatePath)) + { + if (string.IsNullOrEmpty(mtlsOptions.ClientKeyPath)) + { + // Load certificate without separate key file (e.g., PKCS#12 format) + clientCertificate = OtlpMtlsCertificateManager.LoadClientCertificate( + mtlsOptions.ClientCertificatePath, + null); + } + else + { + clientCertificate = OtlpMtlsCertificateManager.LoadClientCertificate( + mtlsOptions.ClientCertificatePath, + mtlsOptions.ClientKeyPath); + } + + if (mtlsOptions.EnableCertificateChainValidation) + { + OtlpMtlsCertificateManager.ValidateCertificateChain( + clientCertificate, + OtlpMtlsCertificateManager.ClientCertificateType); + } + + OpenTelemetryProtocolExporterEventSource.Log.MtlsConfigurationEnabled( + clientCertificate.Subject); + } + + // Create HttpClientHandler with mTLS configuration +#pragma warning disable CA2000 // Dispose objects before losing scope - HttpClientHandler is disposed by HttpClient + handler = new HttpClientHandler { CheckCertificateRevocationList = true }; +#pragma warning restore CA2000 + + // Add client certificate if available + if (clientCertificate != null) + { + handler.ClientCertificates.Add(clientCertificate); + handler.ClientCertificateOptions = ClientCertificateOption.Manual; + } + + // Set up server certificate validation + if (caCertificate != null) + { + handler.ServerCertificateCustomValidationCallback = ( + httpRequestMessage, + cert, + chain, + sslPolicyErrors) => + { + if (cert == null || chain == null) + { + return false; + } + + return OtlpMtlsCertificateManager.ValidateServerCertificate( + cert, chain, sslPolicyErrors, caCertificate); + }; + } + + var client = new HttpClient(handler, disposeHandler: true); + + configureClient?.Invoke(client); + + return client; + } + catch (Exception ex) + { + // Dispose handler if something went wrong + handler?.Dispose(); + + OpenTelemetryProtocolExporterEventSource.Log.ExportMethodException(ex); + throw; + } + finally + { + // Dispose certificates as they are no longer needed after being added to the handler + caCertificate?.Dispose(); + clientCertificate?.Dispose(); + } + } +} + +#endif diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSpecConfigDefinitions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSpecConfigDefinitions.cs index 3bc62218b3f..8e8fd497b0d 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSpecConfigDefinitions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/Implementation/OtlpSpecConfigDefinitions.cs @@ -31,4 +31,9 @@ internal static class OtlpSpecConfigDefinitions public const string TracesHeadersEnvVarName = "OTEL_EXPORTER_OTLP_TRACES_HEADERS"; public const string TracesTimeoutEnvVarName = "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT"; public const string TracesProtocolEnvVarName = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"; + + // mTLS certificate environment variables + public const string CertificateEnvVarName = "OTEL_EXPORTER_OTLP_CERTIFICATE"; + public const string ClientKeyEnvVarName = "OTEL_EXPORTER_OTLP_CLIENT_KEY"; + public const string ClientCertificateEnvVarName = "OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE"; } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs index 91ebfdbd3e1..b3f67444bf0 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpExporterOptions.cs @@ -55,9 +55,9 @@ public OtlpExporterOptions() internal OtlpExporterOptions( OtlpExporterOptionsConfigurationType configurationType) : this( - configuration: new ConfigurationBuilder().AddEnvironmentVariables().Build(), - configurationType, - defaultBatchOptions: new()) + configuration: new ConfigurationBuilder().AddEnvironmentVariables().Build(), + configurationType, + defaultBatchOptions: new()) { } @@ -72,9 +72,21 @@ internal OtlpExporterOptions( this.DefaultHttpClientFactory = () => { + var timeout = TimeSpan.FromMilliseconds(this.TimeoutMilliseconds); + +#if NET + // If mTLS is configured, create an mTLS-enabled client + if (this.MtlsOptions?.IsEnabled == true) + { + return OtlpMtlsHttpClientFactory.CreateMtlsHttpClient( + this.MtlsOptions, + client => client.Timeout = timeout); + } +#endif + return new HttpClient { - Timeout = TimeSpan.FromMilliseconds(this.TimeoutMilliseconds), + Timeout = timeout, }; }; @@ -158,6 +170,10 @@ public Func HttpClientFactory /// internal bool AppendSignalPathToEndpoint { get; private set; } = true; +#if NET + internal OtlpMtlsOptions? MtlsOptions { get; set; } +#endif + internal bool HasData => this.protocol.HasValue || this.endpoint != null @@ -167,8 +183,7 @@ internal bool HasData internal static OtlpExporterOptions CreateOtlpExporterOptions( IServiceProvider serviceProvider, IConfiguration configuration, - string name) - => new( + string name) => new( configuration, OtlpExporterOptionsConfigurationType.Default, serviceProvider.GetRequiredService>().Get(name)); @@ -188,10 +203,10 @@ internal void ApplyConfigurationUsingSpecificationEnvVars( } if (configuration.TryGetValue( - OpenTelemetryProtocolExporterEventSource.Log, - protocolEnvVarKey, - OtlpExportProtocolParser.TryParse, - out var protocol)) + OpenTelemetryProtocolExporterEventSource.Log, + protocolEnvVarKey, + OtlpExportProtocolParser.TryParse, + out var protocol)) { this.Protocol = protocol; } @@ -286,5 +301,38 @@ private void ApplyConfiguration( { throw new NotSupportedException($"OtlpExporterOptionsConfigurationType '{configurationType}' is not supported."); } + +#if NET + // Apply mTLS configuration from environment variables + this.ApplyMtlsConfiguration(configuration); +#endif } + +#if NET + private void ApplyMtlsConfiguration(IConfiguration configuration) + { + Debug.Assert(configuration != null, "configuration was null"); + + // Check and apply CA certificate path from environment variable + if (configuration.TryGetStringValue(OtlpSpecConfigDefinitions.CertificateEnvVarName, out var caCertPath)) + { + this.MtlsOptions ??= new(); + this.MtlsOptions.CaCertificatePath = caCertPath; + } + + // Check and apply client certificate path from environment variable + if (configuration.TryGetStringValue(OtlpSpecConfigDefinitions.ClientCertificateEnvVarName, out var clientCertPath)) + { + this.MtlsOptions ??= new(); + this.MtlsOptions.ClientCertificatePath = clientCertPath; + } + + // Check and apply client key path from environment variable + if (configuration.TryGetStringValue(OtlpSpecConfigDefinitions.ClientKeyEnvVarName, out var clientKeyPath)) + { + this.MtlsOptions ??= new(); + this.MtlsOptions.ClientKeyPath = clientKeyPath; + } + } +#endif } diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs new file mode 100644 index 00000000000..dc2feaa8e8e --- /dev/null +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/OtlpMtlsOptions.cs @@ -0,0 +1,38 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +namespace OpenTelemetry.Exporter; + +internal sealed class OtlpMtlsOptions +{ + /// + /// Gets or sets the path to the CA certificate file in PEM format. + /// + public string? CaCertificatePath { get; set; } + + /// + /// Gets or sets the path to the client certificate file in PEM format. + /// + public string? ClientCertificatePath { get; set; } + + /// + /// Gets or sets the path to the client private key file in PEM format. + /// + public string? ClientKeyPath { get; set; } + + /// + /// Gets or sets a value indicating whether to enable certificate chain validation. + /// When enabled, the exporter will validate the certificate chain and reject invalid certificates. + /// + public bool EnableCertificateChainValidation { get; set; } = true; + + /// + /// Gets a value indicating whether mTLS is enabled. + /// mTLS is considered enabled if at least the client certificate path is provided. + /// + public bool IsEnabled => !string.IsNullOrWhiteSpace(this.ClientCertificatePath); +} + +#endif diff --git a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/README.md b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/README.md index e12ba68ff22..b358a7b9edc 100644 --- a/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/README.md +++ b/src/OpenTelemetry.Exporter.OpenTelemetryProtocol/README.md @@ -450,6 +450,15 @@ or reader | `OTEL_EXPORTER_OTLP_TIMEOUT` | `TimeoutMilliseconds` | | `OTEL_EXPORTER_OTLP_PROTOCOL` | `Protocol` (`grpc` or `http/protobuf`)| + The following environment variables can be used to configure mTLS + (mutual TLS) authentication (.NET 8.0+ only): + + | Environment variable | `OtlpMtlsOptions` property | Description | + | -------------------------------------------------| ----------------------------- | ------------------------------------- | + | `OTEL_EXPORTER_OTLP_CERTIFICATE` | `CaCertificatePath` | Path to CA certificate file (PEM) | + | `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` | `ClientCertificatePath` | Path to client certificate file (PEM) | + | `OTEL_EXPORTER_OTLP_CLIENT_KEY` | `ClientKeyPath` | Path to client private key file (PEM) | + * Logs: The following environment variables can be used to override the default values diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs index 9536d283cf5..52271cf5b34 100644 --- a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpExporterOptionsTests.cs @@ -263,4 +263,107 @@ public void OtlpExporterOptions_ApplyDefaultsTest() Assert.NotEqual(defaultOptionsWithData.TimeoutMilliseconds, targetOptionsWithData.TimeoutMilliseconds); Assert.NotEqual(defaultOptionsWithData.HttpClientFactory, targetOptionsWithData.HttpClientFactory); } + +#if NET + [Fact] + public void OtlpExporterOptions_MtlsEnvironmentVariables() + { + // Test CA certificate environment variable + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CERTIFICATE", "/path/to/ca.crt"); + + try + { + var options = new OtlpExporterOptions(); + + Assert.NotNull(options.MtlsOptions); + Assert.Equal("/path/to/ca.crt", options.MtlsOptions.CaCertificatePath); + } + finally + { + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CERTIFICATE", null); + } + } + + [Fact] + public void OtlpExporterOptions_MtlsEnvironmentVariables_ClientCertificate() + { + // Test client certificate and key environment variables + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", "/path/to/client.crt"); + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CLIENT_KEY", "/path/to/client.key"); + + try + { + var options = new OtlpExporterOptions(); + + Assert.NotNull(options.MtlsOptions); + Assert.Equal("/path/to/client.crt", options.MtlsOptions.ClientCertificatePath); + Assert.Equal("/path/to/client.key", options.MtlsOptions.ClientKeyPath); + Assert.True(options.MtlsOptions.IsEnabled); + } + finally + { + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", null); + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CLIENT_KEY", null); + } + } + + [Fact] + public void OtlpExporterOptions_MtlsEnvironmentVariables_AllCertificates() + { + // Test all mTLS environment variables together + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CERTIFICATE", "/path/to/ca.crt"); + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", "/path/to/client.crt"); + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CLIENT_KEY", "/path/to/client.key"); + + try + { + var options = new OtlpExporterOptions(); + + Assert.NotNull(options.MtlsOptions); + Assert.Equal("/path/to/ca.crt", options.MtlsOptions.CaCertificatePath); + Assert.Equal("/path/to/client.crt", options.MtlsOptions.ClientCertificatePath); + Assert.Equal("/path/to/client.key", options.MtlsOptions.ClientKeyPath); + Assert.True(options.MtlsOptions.IsEnabled); + } + finally + { + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CERTIFICATE", null); + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE", null); + Environment.SetEnvironmentVariable("OTEL_EXPORTER_OTLP_CLIENT_KEY", null); + } + } + + [Fact] + public void OtlpExporterOptions_MtlsEnvironmentVariables_NoEnvironmentVariables() + { + // Ensure no mTLS options are set when no environment variables are present + var options = new OtlpExporterOptions(); + + Assert.Null(options.MtlsOptions); + } + + [Fact] + public void OtlpExporterOptions_MtlsEnvironmentVariables_UsingIConfiguration() + { + // Test using IConfiguration instead of environment variables + var values = new Dictionary + { + ["OTEL_EXPORTER_OTLP_CERTIFICATE"] = "/config/path/to/ca.crt", + ["OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE"] = "/config/path/to/client.crt", + ["OTEL_EXPORTER_OTLP_CLIENT_KEY"] = "/config/path/to/client.key", + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + + var options = new OtlpExporterOptions(configuration, OtlpExporterOptionsConfigurationType.Default, new()); + + Assert.NotNull(options.MtlsOptions); + Assert.Equal("/config/path/to/ca.crt", options.MtlsOptions.CaCertificatePath); + Assert.Equal("/config/path/to/client.crt", options.MtlsOptions.ClientCertificatePath); + Assert.Equal("/config/path/to/client.key", options.MtlsOptions.ClientKeyPath); + Assert.True(options.MtlsOptions.IsEnabled); + } +#endif } diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsCertificateManagerTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsCertificateManagerTests.cs new file mode 100644 index 00000000000..d3fac9900a7 --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsCertificateManagerTests.cs @@ -0,0 +1,212 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using Xunit; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; + +public class OtlpMtlsCertificateManagerTests +{ + private const string TestCertPem = + @"-----BEGIN CERTIFICATE----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1234567890ABCDEFGHIJ +KLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890A +BCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123 +4567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUV +WXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNO +PQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFG +HIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 +ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123 +4567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUV +WXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNO +PQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFG +HIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 +ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123 +4567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUV +WXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNO +PQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFG +HIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 +ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123 +4567890ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCD +-----END CERTIFICATE-----"; + + [Fact] + public void LoadClientCertificate_ThrowsFileNotFoundException_WhenCertificateFileDoesNotExist() + { + var exception = Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadClientCertificate( + "/nonexistent/client.crt", + "/nonexistent/client.key")); + + Assert.Contains("Certificate file not found", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/nonexistent/client.crt", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LoadClientCertificate_ThrowsFileNotFoundException_WhenPrivateKeyFileDoesNotExist() + { + var tempCertFile = Path.GetTempFileName(); + File.WriteAllText(tempCertFile, TestCertPem); + + try + { + var exception = Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadClientCertificate( + tempCertFile, + "/nonexistent/client.key")); + + Assert.Contains("Private key file not found", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/nonexistent/client.key", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(tempCertFile); + } + } + + [Fact] + public void LoadCaCertificate_ThrowsFileNotFoundException_WhenTrustStoreFileDoesNotExist() + { + var exception = Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadCaCertificate("/nonexistent/ca.crt")); + + Assert.Contains("CA certificate file not found", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("/nonexistent/ca.crt", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LoadClientCertificate_ThrowsInvalidOperationException_WhenCertificateFileIsEmpty() + { + var tempCertFile = Path.GetTempFileName(); + var tempKeyFile = Path.GetTempFileName(); + File.WriteAllText(tempCertFile, string.Empty); + File.WriteAllText(tempKeyFile, string.Empty); + + try + { + var exception = Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadClientCertificate(tempCertFile, tempKeyFile)); + + Assert.Contains( + "Failed to load client certificate", + exception.Message, + StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(tempCertFile); + File.Delete(tempKeyFile); + } + } + + [Fact] + public void LoadCaCertificate_ThrowsInvalidOperationException_WhenTrustStoreFileIsEmpty() + { + var tempTrustStoreFile = Path.GetTempFileName(); + File.WriteAllText(tempTrustStoreFile, string.Empty); + + try + { + var exception = Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadCaCertificate(tempTrustStoreFile)); + + Assert.Contains( + "Failed to load CA certificate", + exception.Message, + StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(tempTrustStoreFile); + } + } + + [Fact] + public void ValidateCertificateChain_DoesNotThrow_WithValidCertificate() + { + // Create a self-signed certificate for testing + using var cert = CreateSelfSignedCertificate(); + + // Should not throw for self-signed certificate with proper validation + var result = OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ValidateCertificateChain(cert, "test certificate"); + + // For self-signed certificates, validation may fail, but method should not throw + Assert.True(result || !result); // Just check that it returns a boolean + } + + [Fact] + public void ValidateCertificateChain_ReturnsResult_WithValidCertificate() + { + // Create a valid certificate for testing + using var cert = CreateSelfSignedCertificate(); + + // Should return a boolean result + var result = OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.ValidateCertificateChain(cert, "test certificate"); + + // The result can be true or false, but the method should not throw + Assert.True(result || !result); + } + + [Fact] + public void LoadClientCertificate_LoadsFromSeparateFiles() + { + var tempCertFile = Path.GetTempFileName(); + var tempKeyFile = Path.GetTempFileName(); + File.WriteAllText(tempCertFile, TestCertPem); + File.WriteAllText(tempKeyFile, "test-key-content"); + + try + { + // This test verifies that the method loads from separate files + // Note: We expect this to fail because we're using dummy cert/key content + // but it should not fail due to the method signature + var exception = Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpMtlsCertificateManager.LoadClientCertificate( + tempCertFile, + tempKeyFile)); + + // The exception should be about certificate loading, not method signature + Assert.Contains("Failed to load client certificate", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(tempCertFile); + File.Delete(tempKeyFile); + } + } + + private static System.Security.Cryptography.X509Certificates.X509Certificate2 CreateSelfSignedCertificate() + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=Test Certificate", + rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + + var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(30)); + return cert; + } + + private static System.Security.Cryptography.X509Certificates.X509Certificate2 CreateExpiredCertificate() + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=Expired Test Certificate", + rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + + // Create a certificate that expired yesterday + var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-30), + DateTimeOffset.UtcNow.AddDays(-1)); + return cert; + } +} + +#endif diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsHttpClientFactoryTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsHttpClientFactoryTests.cs new file mode 100644 index 00000000000..89fdfed9841 --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsHttpClientFactoryTests.cs @@ -0,0 +1,138 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using Xunit; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; + +public class OtlpMtlsHttpClientFactoryTests +{ + [Fact] + public void CreateHttpClient_ReturnsHttpClient_WhenMtlsIsDisabled() + { + var options = new OtlpMtlsOptions(); // Disabled by default + + using var httpClient = OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options); + + Assert.NotNull(httpClient); + Assert.IsType(httpClient); + } + + [Fact] + public void CreateHttpClient_ThrowsFileNotFoundException_WhenCertificateFileDoesNotExist() + { + var options = new OtlpMtlsOptions { ClientCertificatePath = "/nonexistent/client.crt" }; + + var exception = Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options)); + + Assert.Contains("Certificate file not found", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CreateHttpClient_ConfiguresClientCertificate_WhenValidCertificateProvided() + { + var tempCertFile = Path.GetTempFileName(); + try + { + // Create a self-signed certificate for testing + using var cert = CreateSelfSignedCertificate(); + var certBytes = cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Pfx); + File.WriteAllBytes(tempCertFile, certBytes); + + var options = new OtlpMtlsOptions + { + ClientCertificatePath = tempCertFile, + EnableCertificateChainValidation = false, // Ignore validation for test cert + }; + + using var httpClient = OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options); + + Assert.NotNull(httpClient); + + // Verify the HttpClientHandler has client certificates configured + var handlerField = typeof(HttpClient).GetField( + "_handler", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (handlerField?.GetValue(httpClient) is HttpClientHandler handler) + { + Assert.NotEmpty(handler.ClientCertificates); + } + } + finally + { + if (File.Exists(tempCertFile)) + { + File.Delete(tempCertFile); + } + } + } + + [Fact] + public void CreateHttpClient_ConfiguresServerCertificateValidation_WhenTrustedRootCertificatesProvided() + { + var tempTrustStoreFile = Path.GetTempFileName(); + try + { + // Create a self-signed certificate for testing as trusted root + using var trustedCert = CreateSelfSignedCertificate(); + var trustedCertPem = Convert.ToBase64String(trustedCert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Cert)); + var pemContent = + $"-----BEGIN CERTIFICATE-----\n{trustedCertPem}\n-----END CERTIFICATE-----"; + File.WriteAllText(tempTrustStoreFile, pemContent); + + var options = new OtlpMtlsOptions + { + CaCertificatePath = tempTrustStoreFile, + }; + + using var httpClient = OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(options); + + Assert.NotNull(httpClient); + + // Verify the HttpClientHandler has server certificate validation configured + var handlerField = typeof(HttpClient).GetField( + "_handler", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (handlerField?.GetValue(httpClient) is HttpClientHandler handler) + { + Assert.NotNull(handler.ServerCertificateCustomValidationCallback); + } + } + finally + { + if (File.Exists(tempTrustStoreFile)) + { + File.Delete(tempTrustStoreFile); + } + } + } + + [Fact] + public void CreateMtlsHttpClient_ThrowsArgumentNullException_WhenOptionsIsNull() + { + var exception = Assert.Throws(() => + OpenTelemetryProtocol.Implementation.OtlpMtlsHttpClientFactory.CreateMtlsHttpClient(null!)); + + Assert.Equal("mtlsOptions", exception.ParamName); + } + + private static System.Security.Cryptography.X509Certificates.X509Certificate2 CreateSelfSignedCertificate() + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=Test Certificate", + rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + + var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(30)); + return cert; + } +} + +#endif diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsOptionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsOptionsTests.cs new file mode 100644 index 00000000000..e5d9128fe4b --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpMtlsOptionsTests.cs @@ -0,0 +1,66 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using Xunit; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; + +public class OtlpMtlsOptionsTests +{ + [Fact] + public void DefaultValues_AreValid() + { + var options = new OtlpMtlsOptions(); + + Assert.Null(options.ClientCertificatePath); + Assert.Null(options.ClientKeyPath); + Assert.Null(options.CaCertificatePath); + Assert.True(options.EnableCertificateChainValidation); + Assert.False(options.IsEnabled); + } + + [Fact] + public void Properties_CanBeSet() + { + var options = new OtlpMtlsOptions + { + ClientCertificatePath = "/path/to/client.crt", + ClientKeyPath = "/path/to/client.key", + CaCertificatePath = "/path/to/ca.crt", + EnableCertificateChainValidation = false, + }; + + Assert.Equal("/path/to/client.crt", options.ClientCertificatePath); + Assert.Equal("/path/to/client.key", options.ClientKeyPath); + Assert.Equal("/path/to/ca.crt", options.CaCertificatePath); + Assert.False(options.EnableCertificateChainValidation); + Assert.True(options.IsEnabled); + } + + [Fact] + public void IsEnabled_ReturnsFalse_WhenNoClientCertificateProvided() + { + var options = new OtlpMtlsOptions(); + Assert.False(options.IsEnabled); + } + + [Fact] + public void IsEnabled_ReturnsTrue_WhenClientCertificateFilePathProvided() + { + var options = new OtlpMtlsOptions { ClientCertificatePath = "/path/to/client.crt" }; + Assert.True(options.IsEnabled); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void IsEnabled_ReturnsFalse_WhenClientCertificateFilePathIsEmpty(string filePath) + { + var options = new OtlpMtlsOptions { ClientCertificatePath = filePath }; + Assert.False(options.IsEnabled); + } +} + +#endif diff --git a/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionsTests.cs b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionsTests.cs new file mode 100644 index 00000000000..11c9b5b33d0 --- /dev/null +++ b/test/OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests/OtlpSpecConfigDefinitionsTests.cs @@ -0,0 +1,32 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +#if NET + +using Xunit; + +namespace OpenTelemetry.Exporter.OpenTelemetryProtocol.Tests; + +public class OtlpSpecConfigDefinitionsTests +{ + [Fact] + public void AllEnvironmentVariableNames_AreUnique() + { + var envVars = new[] + { + OtlpSpecConfigDefinitions.DefaultEndpointEnvVarName, + OtlpSpecConfigDefinitions.DefaultHeadersEnvVarName, + OtlpSpecConfigDefinitions.DefaultTimeoutEnvVarName, + OtlpSpecConfigDefinitions.DefaultProtocolEnvVarName, + OtlpSpecConfigDefinitions.CertificateEnvVarName, + OtlpSpecConfigDefinitions.ClientKeyEnvVarName, + OtlpSpecConfigDefinitions.ClientCertificateEnvVarName, + }; + + var uniqueVars = envVars.Distinct().ToArray(); + + Assert.Equal(envVars.Length, uniqueVars.Length); + } +} + +#endif