2026-05-31 18:46:54 +02:00
using System.Net.Security ;
using System.Security.Cryptography ;
using System.Security.Cryptography.X509Certificates ;
2026-05-21 16:48:34 +02:00
using AIStudio.Settings ;
namespace AIStudio.Tools ;
/// <summary>
/// Provides utility methods to standardize the management of HTTP client timeouts
/// across various components in the application.
/// </summary>
public static class ExternalHttpClientTimeout
{
public const int MIN_HTTP_CLIENT_TIMEOUT_SECONDS = 120 ;
public const int MAX_HTTP_CLIENT_TIMEOUT_SECONDS = 3600 ;
public const int DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS = 3600 ;
2026-05-31 18:46:54 +02:00
private const string ENV_CUSTOM_ROOT_CERTIFICATES_ENABLED = "MINDWORK_AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATES_ENABLED" ;
private const string ENV_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH = "MINDWORK_AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH" ;
private const string ENV_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS = "MINDWORK_AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS" ;
2026-06-11 11:37:40 +02:00
private const string ENV_POLICY_CUSTOM_ROOT_CERTIFICATES_CONFIGURED = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATES_POLICY_CONFIGURED" ;
private const string ENV_POLICY_CUSTOM_ROOT_CERTIFICATES_ENABLED = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATES_ENABLED" ;
private const string ENV_POLICY_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH" ;
private const string ENV_POLICY_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS = "AI_STUDIO_EXTERNAL_HTTP_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS" ;
2026-05-31 18:46:54 +02:00
// id-kp-serverAuth: Extended Key Usage for TLS server authentication.
// See RFC 5280, section 4.2.1.12: https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
private const string TLS_SERVER_AUTHENTICATION_EKU_OID = "1.3.6.1.5.5.7.3.1" ;
private static string TB ( string fallbackEN ) => PluginSystem . I18N . I . T ( fallbackEN , typeof ( ExternalHttpClientTimeout ). Namespace , nameof ( ExternalHttpClientTimeout ));
private static readonly Lazy < ILogger > LOGGER = new (() => Program . LOGGER_FACTORY . CreateLogger ( nameof ( ExternalHttpClientTimeout )));
2026-06-10 21:01:27 +02:00
private static SettingsManager SettingsManagerAccess => Program . SERVICE_PROVIDER . GetRequiredService < SettingsManager >();
2026-05-31 18:46:54 +02:00
private static readonly Lock CUSTOM_ROOT_CERTIFICATE_LOCK = new ();
private static CustomRootCertificateCache ? CUSTOM_ROOT_CERTIFICATE_CACHE ;
2026-05-21 16:48:34 +02:00
2026-05-31 18:46:54 +02:00
public static HttpClient CreateHttpClient ( ExternalHttpTrustPolicy trustPolicy ) => CreateHttpClient ( null , trustPolicy );
public static HttpClient CreateHttpClient ( Uri ? baseAddress , ExternalHttpTrustPolicy trustPolicy )
2026-05-21 16:48:34 +02:00
{
2026-05-31 18:46:54 +02:00
var customRootCertificateCache = GetCustomRootCertificateCache ();
var httpClient = customRootCertificateCache . State . IsUsable
? new HttpClient ( new HttpClientHandler
{
ServerCertificateCustomValidationCallback = ( request , certificate , chain , sslPolicyErrors ) =>
ValidateServerCertificateWithCustomRootCertificates ( request , certificate , chain , sslPolicyErrors , customRootCertificateCache , trustPolicy )
})
: new HttpClient ();
2026-05-21 16:48:34 +02:00
Configure ( httpClient , baseAddress );
return httpClient ;
}
2026-09-04 15:48:07 +02:00
public static void ConfigureSocketsHttpHandler ( SocketsHttpHandler handler , string host , ExternalHttpTrustPolicy trustPolicy )
{
var customRootCertificateCache = GetCustomRootCertificateCache ();
if (! customRootCertificateCache . State . IsUsable )
return ;
handler . SslOptions . RemoteCertificateValidationCallback = ( _ , certificate , chain , sslPolicyErrors ) =>
ValidateServerCertificateWithCustomRootCertificates ( host , certificate , chain , sslPolicyErrors , customRootCertificateCache , trustPolicy );
}
2026-05-31 18:46:54 +02:00
public static ExternalHttpCustomRootCertificateState CustomRootCertificateState => GetCustomRootCertificateCache (). State ;
2026-05-21 16:48:34 +02:00
public static string GetTimeoutDescription ()
{
var timeout = GetTimeout ();
if ( timeout . TotalHours >= 1 && timeout . TotalMinutes % 60 == 0 )
{
var hours = ( int ) timeout . TotalHours ;
return hours == 1 ? "1 hour" : $"{hours} hours" ;
}
if ( timeout . TotalMinutes >= 1 && timeout . TotalSeconds % 60 == 0 )
{
var minutes = ( int ) timeout . TotalMinutes ;
return minutes == 1 ? "1 minute" : $"{minutes} minutes" ;
}
var seconds = ( int ) timeout . TotalSeconds ;
return seconds == 1 ? "1 second" : $"{seconds} seconds" ;
}
public static CancellationTokenSource CreateTimeoutTokenSource ( CancellationToken cancellationToken )
{
var timeoutTokenSource = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken );
timeoutTokenSource . CancelAfter ( GetTimeout ());
return timeoutTokenSource ;
}
public static bool IsTimeoutException ( Exception exception , CancellationToken userCancellationToken = default )
{
if ( userCancellationToken . IsCancellationRequested )
return false ;
if ( exception is TimeoutException )
return true ;
if ( exception is OperationCanceledException )
return true ;
return exception . InnerException is not null && IsTimeoutException ( exception . InnerException , userCancellationToken );
}
private static TimeSpan GetTimeout ()
{
2026-06-10 21:01:27 +02:00
var seconds = SettingsManagerAccess . ConfigurationData . App . HttpClientTimeoutSeconds ;
2026-05-21 16:48:34 +02:00
if ( seconds <= 0 )
seconds = DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS ;
seconds = Math . Clamp ( seconds , MIN_HTTP_CLIENT_TIMEOUT_SECONDS , MAX_HTTP_CLIENT_TIMEOUT_SECONDS );
return TimeSpan . FromSeconds ( seconds );
}
private static void Configure ( HttpClient httpClient , Uri ? baseAddress = null )
{
httpClient . Timeout = GetTimeout ();
if ( baseAddress is not null )
httpClient . BaseAddress = baseAddress ;
}
2026-05-31 18:46:54 +02:00
private static CustomRootCertificateCache GetCustomRootCertificateCache ()
{
var configuration = ReadCustomRootCertificateConfiguration ();
var cacheKey = $"{configuration.Enabled}|{configuration.BundlePath}|{string.Join(" ; ", configuration.AllowedHostPatterns)}|{ReadCertificateBundleFileSignature(configuration.BundlePath)}" ;
lock ( CUSTOM_ROOT_CERTIFICATE_LOCK )
{
if ( CUSTOM_ROOT_CERTIFICATE_CACHE is not null && CUSTOM_ROOT_CERTIFICATE_CACHE . CacheKey == cacheKey )
return CUSTOM_ROOT_CERTIFICATE_CACHE ;
CUSTOM_ROOT_CERTIFICATE_CACHE = LoadCustomRootCertificateCache ( cacheKey , configuration );
LogCustomRootCertificateState ( CUSTOM_ROOT_CERTIFICATE_CACHE . State );
return CUSTOM_ROOT_CERTIFICATE_CACHE ;
}
}
private static CustomRootCertificateConfiguration ReadCustomRootCertificateConfiguration ()
{
2026-06-11 11:37:40 +02:00
if ( TryParseBooleanEnvironmentValue ( Environment . GetEnvironmentVariable ( ENV_POLICY_CUSTOM_ROOT_CERTIFICATES_CONFIGURED ), out var policyConfigured ) && policyConfigured )
{
var policyEnabled = TryParseBooleanEnvironmentValue ( Environment . GetEnvironmentVariable ( ENV_POLICY_CUSTOM_ROOT_CERTIFICATES_ENABLED ), out var parsedPolicyEnabled ) && parsedPolicyEnabled ;
var policyBundlePath = Environment . GetEnvironmentVariable ( ENV_POLICY_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH )?. Trim () ?? string . Empty ;
var policyAllowedHosts = Environment . GetEnvironmentVariable ( ENV_POLICY_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS ) ?? string . Empty ;
return new ( policyEnabled , policyBundlePath , ReadAllowedHostPatternsFromDelimitedValue ( policyAllowedHosts ), TB ( "policy files" ));
}
2026-05-31 18:46:54 +02:00
var envEnabled = Environment . GetEnvironmentVariable ( ENV_CUSTOM_ROOT_CERTIFICATES_ENABLED );
var envBundlePath = Environment . GetEnvironmentVariable ( ENV_CUSTOM_ROOT_CERTIFICATE_BUNDLE_PATH );
var envAllowedHosts = Environment . GetEnvironmentVariable ( ENV_CUSTOM_ROOT_CERTIFICATE_ALLOWED_HOSTS );
2026-06-11 11:37:40 +02:00
if (! string . IsNullOrWhiteSpace ( envEnabled ) || ! string . IsNullOrWhiteSpace ( envBundlePath ) || ! string . IsNullOrWhiteSpace ( envAllowedHosts ))
{
var enabled = TryParseBooleanEnvironmentValue ( envEnabled , out var parsedEnvEnabled )
? parsedEnvEnabled
: SettingsManagerAccess . ConfigurationData . App . ExternalHttpCustomRootCertificatesEnabled ;
2026-05-31 18:46:54 +02:00
2026-06-11 11:37:40 +02:00
var bundlePath = ! string . IsNullOrWhiteSpace ( envBundlePath )
? envBundlePath . Trim ()
: SettingsManagerAccess . ConfigurationData . App . ExternalHttpCustomRootCertificateBundlePath . Trim ();
2026-05-31 18:46:54 +02:00
2026-06-11 11:37:40 +02:00
var allowedHostPatterns = ! string . IsNullOrWhiteSpace ( envAllowedHosts )
? ReadAllowedHostPatternsFromDelimitedValue ( envAllowedHosts )
: ReadAllowedHostPatterns ( SettingsManagerAccess . ConfigurationData . App . ExternalHttpCustomRootCertificateAllowedHosts );
2026-05-31 18:46:54 +02:00
2026-06-11 11:37:40 +02:00
return new ( enabled , bundlePath , allowedHostPatterns , TB ( "environment variables" ));
}
2026-05-31 18:46:54 +02:00
2026-06-11 11:37:40 +02:00
return new (
SettingsManagerAccess . ConfigurationData . App . ExternalHttpCustomRootCertificatesEnabled ,
SettingsManagerAccess . ConfigurationData . App . ExternalHttpCustomRootCertificateBundlePath . Trim (),
ReadAllowedHostPatterns ( SettingsManagerAccess . ConfigurationData . App . ExternalHttpCustomRootCertificateAllowedHosts ),
ReadCustomRootCertificateSettingsSource ());
2026-05-31 18:46:54 +02:00
}
2026-06-11 11:37:40 +02:00
private static string ReadCustomRootCertificateSettingsSource ()
2026-05-31 18:46:54 +02:00
{
var enabledIsManaged = ManagedConfiguration . TryGet ( x => x . App , x => x . ExternalHttpCustomRootCertificatesEnabled , out var enabledMeta ) && enabledMeta . IsLocked ;
var bundlePathIsManaged = ManagedConfiguration . TryGet ( x => x . App , x => x . ExternalHttpCustomRootCertificateBundlePath , out var bundlePathMeta ) && bundlePathMeta . IsLocked ;
var allowedHostsIsManaged = ManagedConfiguration . TryGet ( x => x . App , x => x . ExternalHttpCustomRootCertificateAllowedHosts , out var allowedHostsMeta ) && allowedHostsMeta . IsLocked ;
return enabledIsManaged || bundlePathIsManaged || allowedHostsIsManaged
? TB ( "configuration plugin" )
: TB ( "app settings" );
}
2026-06-11 11:37:40 +02:00
private static IReadOnlyList < string > ReadAllowedHostPatternsFromDelimitedValue ( string allowedHosts )
2026-05-31 18:46:54 +02:00
{
2026-06-11 11:37:40 +02:00
return ReadAllowedHostPatterns ( allowedHosts . Split ([ ';' , ',' ], StringSplitOptions . RemoveEmptyEntries | StringSplitOptions . TrimEntries ));
}
2026-05-31 18:46:54 +02:00
2026-06-11 11:37:40 +02:00
private static IReadOnlyList < string > ReadAllowedHostPatterns ( IEnumerable < string > rawPatterns )
{
2026-05-31 18:46:54 +02:00
var patterns = new HashSet < string >( StringComparer . OrdinalIgnoreCase );
foreach ( var rawPattern in rawPatterns )
{
if ( TryNormalizeAllowedHostPattern ( rawPattern , out var pattern ))
patterns . Add ( pattern );
else
LOGGER . Value . LogWarning ( $"Ignoring invalid external HTTP custom root certificate host pattern: '{rawPattern}'." );
}
return patterns . Order ( StringComparer . OrdinalIgnoreCase ). ToList ();
}
private static bool TryNormalizeAllowedHostPattern ( string? rawPattern , out string pattern )
{
pattern = string . Empty ;
if ( string . IsNullOrWhiteSpace ( rawPattern ))
return false ;
var normalized = rawPattern . Trim (). TrimEnd ( '.' ). ToLowerInvariant ();
if ( normalized . Contains ( "://" , StringComparison . Ordinal ) || normalized . Contains ( '/' , StringComparison . Ordinal ) || normalized . Contains ( ':' , StringComparison . Ordinal ))
return false ;
if ( normalized . StartsWith ( "*." , StringComparison . Ordinal ))
{
var suffix = normalized [ 2. .];
if (! IsValidDnsHost ( suffix ))
return false ;
pattern = $"*.{suffix}" ;
return true ;
}
if ( normalized . Contains ( '*' , StringComparison . Ordinal ))
return false ;
if (! IsValidDnsHost ( normalized ))
return false ;
pattern = normalized ;
return true ;
}
private static bool IsValidDnsHost ( string host )
{
if ( string . IsNullOrWhiteSpace ( host ))
return false ;
if ( Uri . CheckHostName ( host ) is not UriHostNameType . Dns )
return false ;
return host . Split ( '.' ). All ( label => ! string . IsNullOrWhiteSpace ( label ) && ! label . StartsWith ( '-' ) && ! label . EndsWith ( '-' ));
}
private static string ReadCertificateBundleFileSignature ( string bundlePath )
{
if ( string . IsNullOrWhiteSpace ( bundlePath ))
return string . Empty ;
try
{
var fileInfo = new FileInfo ( bundlePath );
return fileInfo . Exists
? $"{fileInfo.Length}|{fileInfo.LastWriteTimeUtc.Ticks}"
: "missing" ;
}
catch
{
return "unavailable" ;
}
}
private static bool TryParseBooleanEnvironmentValue ( string? value , out bool parsedValue )
{
parsedValue = false ;
if ( string . IsNullOrWhiteSpace ( value ))
return false ;
var normalized = value . Trim ();
if ( bool . TryParse ( normalized , out parsedValue ))
return true ;
if ( normalized is "1" || normalized . Equals ( "yes" , StringComparison . OrdinalIgnoreCase ) || normalized . Equals ( "on" , StringComparison . OrdinalIgnoreCase ))
{
parsedValue = true ;
return true ;
}
if ( normalized is "0" || normalized . Equals ( "no" , StringComparison . OrdinalIgnoreCase ) || normalized . Equals ( "off" , StringComparison . OrdinalIgnoreCase ))
{
parsedValue = false ;
return true ;
}
return false ;
}
private static CustomRootCertificateCache LoadCustomRootCertificateCache ( string cacheKey , CustomRootCertificateConfiguration configuration )
{
var certificates = new X509Certificate2Collection ();
if (! configuration . Enabled )
{
return new (
cacheKey ,
certificates ,
new ExternalHttpCustomRootCertificateState ( false , configuration . Source , configuration . BundlePath , configuration . AllowedHostPatterns , false , 0 , [], string . Empty ));
}
if ( string . IsNullOrWhiteSpace ( configuration . BundlePath ))
{
return new (
cacheKey ,
certificates ,
new ExternalHttpCustomRootCertificateState ( true , configuration . Source , configuration . BundlePath , configuration . AllowedHostPatterns , false , 0 , [], TB ( "No certificate bundle path is configured." )));
}
if (! File . Exists ( configuration . BundlePath ))
{
return new (
cacheKey ,
certificates ,
new ExternalHttpCustomRootCertificateState ( true , configuration . Source , configuration . BundlePath , configuration . AllowedHostPatterns , false , 0 , [], TB ( "The configured certificate bundle file does not exist." )));
}
try
{
var importedCertificates = new X509Certificate2Collection ();
importedCertificates . ImportFromPemFile ( configuration . BundlePath );
foreach ( var certificate in importedCertificates )
{
if (! IsRootCertificateAuthority ( certificate ))
continue ;
certificates . Add ( certificate );
}
var fingerprints = certificates
. Select ( certificate => certificate . GetCertHashString ( HashAlgorithmName . SHA256 ))
. Order ( StringComparer . OrdinalIgnoreCase )
. ToList ();
var issue = certificates . Count == 0
? TB ( "The configured certificate bundle does not contain usable root CA certificates." )
: string . Empty ;
return new (
cacheKey ,
certificates ,
new ExternalHttpCustomRootCertificateState ( true , configuration . Source , configuration . BundlePath , configuration . AllowedHostPatterns , certificates . Count > 0 , certificates . Count , fingerprints , issue ));
}
catch ( Exception e )
{
return new (
cacheKey ,
certificates ,
new ExternalHttpCustomRootCertificateState ( true , configuration . Source , configuration . BundlePath , configuration . AllowedHostPatterns , false , 0 , [], e . Message ));
}
}
private static bool IsRootCertificateAuthority ( X509Certificate2 certificate )
{
if (! certificate . SubjectName . RawData . SequenceEqual ( certificate . IssuerName . RawData ))
return false ;
return certificate . Extensions
. OfType < X509BasicConstraintsExtension >()
. Any ( extension => extension . CertificateAuthority );
}
private static bool ValidateServerCertificateWithCustomRootCertificates (
HttpRequestMessage request ,
X509Certificate ? certificate ,
X509Chain ? originalChain ,
SslPolicyErrors sslPolicyErrors ,
CustomRootCertificateCache customRootCertificateCache ,
ExternalHttpTrustPolicy trustPolicy )
2026-09-04 15:48:07 +02:00
{
return ValidateServerCertificateWithCustomRootCertificates (
ReadRequestHost ( request ),
certificate ,
originalChain ,
sslPolicyErrors ,
customRootCertificateCache ,
trustPolicy );
}
private static bool ValidateServerCertificateWithCustomRootCertificates (
string host ,
X509Certificate ? certificate ,
X509Chain ? originalChain ,
SslPolicyErrors sslPolicyErrors ,
CustomRootCertificateCache customRootCertificateCache ,
ExternalHttpTrustPolicy trustPolicy )
2026-05-31 18:46:54 +02:00
{
if ( sslPolicyErrors is SslPolicyErrors . None )
return true ;
2026-07-15 14:43:41 +02:00
if ( certificate is null )
{
LOGGER . Value . LogError ( $"Rejected external HTTPS certificate for '{HostForLog(host)}' because the TLS stack did not provide a server certificate. TLS policy errors: {sslPolicyErrors}." );
2026-05-31 18:46:54 +02:00
return false ;
2026-07-15 14:43:41 +02:00
}
if ( sslPolicyErrors is not SslPolicyErrors . RemoteCertificateChainErrors )
{
LOGGER . Value . LogError ( $"Rejected external HTTPS certificate for '{HostForLog(host)}' because custom root certificates can only resolve certificate chain trust errors. TLS policy errors: {sslPolicyErrors}." );
return false ;
}
2026-05-31 18:46:54 +02:00
if ( trustPolicy is ExternalHttpTrustPolicy . SYSTEM_TRUST_ONLY )
{
LOGGER . Value . LogError ( $"Rejected external HTTPS certificate for '{HostForLog(host)}' because this request requires system trust only. Configured custom root certificates are not allowed for this request." );
return false ;
}
if (! IsAllowedCustomRootCertificateHost ( host , customRootCertificateCache . State . AllowedHostPatterns ))
{
LOGGER . Value . LogError ( $"Rejected external HTTPS certificate for '{HostForLog(host)}' because the host is not allowed to use configured custom root certificates." );
return false ;
}
var ownsServerCertificate = certificate is not X509Certificate2 ;
var serverCertificate = certificate as X509Certificate2 ?? new X509Certificate2 ( certificate );
try
{
using var customChain = new X509Chain ();
customChain . ChainPolicy . TrustMode = X509ChainTrustMode . CustomRootTrust ;
customChain . ChainPolicy . CustomTrustStore . AddRange ( customRootCertificateCache . Certificates );
customChain . ChainPolicy . ApplicationPolicy . Add ( new Oid ( TLS_SERVER_AUTHENTICATION_EKU_OID ));
2026-09-04 15:48:07 +02:00
2026-07-15 14:43:41 +02:00
// Match the .NET 9 HttpClient default used for the initial system-trust validation.
// Hostname, signature, validity, EKU, and root trust checks remain enabled.
customChain . ChainPolicy . RevocationMode = X509RevocationMode . NoCheck ;
2026-05-31 18:46:54 +02:00
if ( originalChain is not null )
{
foreach ( var element in originalChain . ChainElements )
{
if ( element . Certificate . Thumbprint == serverCertificate . Thumbprint )
continue ;
customChain . ChainPolicy . ExtraStore . Add ( element . Certificate );
}
}
var isValid = customChain . Build ( serverCertificate );
if ( isValid )
2026-09-04 15:48:07 +02:00
LogCustomRootCertificateAccepted ( host );
2026-07-15 14:43:41 +02:00
else
2026-09-04 15:48:07 +02:00
LogCustomRootCertificateValidationFailure ( host , sslPolicyErrors , customChain );
2026-05-31 18:46:54 +02:00
return isValid ;
}
finally
{
if ( ownsServerCertificate )
serverCertificate . Dispose ();
}
}
private static bool IsAllowedCustomRootCertificateHost ( string host , IReadOnlyList < string > allowedHostPatterns )
{
if ( string . IsNullOrWhiteSpace ( host ))
return false ;
var normalizedHost = host . Trim (). TrimEnd ( '.' ). ToLowerInvariant ();
foreach ( var pattern in allowedHostPatterns )
{
if (! pattern . StartsWith ( "*." , StringComparison . Ordinal ))
{
if ( normalizedHost . Equals ( pattern , StringComparison . OrdinalIgnoreCase ))
return true ;
continue ;
}
var suffix = pattern [ 2. .];
if (! normalizedHost . EndsWith ( $".{suffix}" , StringComparison . OrdinalIgnoreCase ))
continue ;
var prefix = normalizedHost [..^( suffix . Length + 1 )];
if (! prefix . Contains ( '.' , StringComparison . Ordinal ))
return true ;
}
return false ;
}
private static void LogCustomRootCertificateState ( ExternalHttpCustomRootCertificateState state )
{
if (! state . IsEnabled )
{
LOGGER . Value . LogInformation ( "External HTTP custom root certificates are disabled." );
return ;
}
if ( state . IsUsable )
{
LOGGER . Value . LogWarning ( $"External HTTP custom root certificates are enabled from {state.Source}. Loaded {state.CertificateCount} root certificate(s) from '{state.BundlePath}'. Allowed hosts: {FormatAllowedHostPatternsForLog(state.AllowedHostPatterns)}. Fingerprints: {string.Join(" , ", state.CertificateFingerprints)}" );
return ;
}
LOGGER . Value . LogWarning ( $"External HTTP custom root certificates are enabled from {state.Source}, but no additional root certificates are usable. Bundle path: '{state.BundlePath}'. Issue: {state.Issue}" );
}
2026-09-04 15:48:07 +02:00
private static void LogCustomRootCertificateAccepted ( string host ) => LOGGER . Value . LogWarning ( $"Accepted an external HTTPS certificate for '{host}' using configured custom root certificates." );
2026-05-31 18:46:54 +02:00
2026-09-04 15:48:07 +02:00
private static void LogCustomRootCertificateValidationFailure ( string host , SslPolicyErrors sslPolicyErrors , X509Chain chain )
2026-07-15 14:43:41 +02:00
{
var chainStatuses = FormatChainStatusesForLog ( chain . ChainStatus );
var elementStatuses = chain . ChainElements
. Cast < X509ChainElement >()
. Select (( element , index ) => $"element {index}: {FormatChainStatusesForLog(element.ChainElementStatus)}" )
. ToList ();
LOGGER . Value . LogError ( $"Rejected external HTTPS certificate for '{HostForLog(host)}' after validation with configured custom root certificates. TLS policy errors: {sslPolicyErrors}. Chain statuses: {chainStatuses}. Chain element statuses: {string.Join(" ; ", elementStatuses)}" );
}
private static string FormatChainStatusesForLog ( IEnumerable < X509ChainStatus > statuses )
{
var formattedStatuses = statuses
. Select ( status => $"{status.Status} ({status.StatusInformation.Trim()})" )
. ToList ();
return formattedStatuses . Count == 0
? "none"
: string . Join ( ", " , formattedStatuses );
}
2026-05-31 18:46:54 +02:00
private static string ReadRequestHost ( HttpRequestMessage request )
{
var host = request . RequestUri ?. IdnHost ;
if ( string . IsNullOrWhiteSpace ( host ))
host = request . RequestUri ?. Host ;
return host ?? string . Empty ;
}
private static string HostForLog ( string host ) => string . IsNullOrWhiteSpace ( host ) ? "unknown host" : host ;
private static string FormatAllowedHostPatternsForLog ( IReadOnlyList < string > allowedHostPatterns )
{
if ( allowedHostPatterns . Count == 0 )
return "none" ;
return string . Join ( ", " , allowedHostPatterns );
}
private readonly record struct CustomRootCertificateConfiguration ( bool Enabled , string BundlePath , IReadOnlyList < string > AllowedHostPatterns , string Source );
private sealed record CustomRootCertificateCache (
string CacheKey ,
X509Certificate2Collection Certificates ,
ExternalHttpCustomRootCertificateState State );
2026-07-15 14:43:41 +02:00
}