Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Delegating handler that enforces an allowlist of destination hosts for outbound requests.
|
||||
/// </summary>
|
||||
internal sealed class AllowlistedHttpMessageHandler : DelegatingHandler
|
||||
{
|
||||
private readonly IReadOnlyCollection<string> _allowedHosts;
|
||||
|
||||
public AllowlistedHttpMessageHandler(SourceHttpClientOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var snapshot = options.GetAllowedHostsSnapshot();
|
||||
if (snapshot.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Source HTTP client must configure at least one allowed host.");
|
||||
}
|
||||
|
||||
_allowedHosts = snapshot;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var host = request.RequestUri?.Host;
|
||||
if (string.IsNullOrWhiteSpace(host) || !_allowedHosts.Contains(host))
|
||||
{
|
||||
throw new InvalidOperationException($"Request host '{host ?? "<null>"}' is not allowlisted for this source.");
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Connector.Common.Xml;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Http;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a named HTTP client configured for a source connector with allowlisted hosts and sensible defaults.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddSourceHttpClient(this IServiceCollection services, string name, Action<SourceHttpClientOptions> configure)
|
||||
=> services.AddSourceHttpClient(name, (_, options) => configure(options));
|
||||
|
||||
public static IHttpClientBuilder AddSourceHttpClient(this IServiceCollection services, string name, Action<IServiceProvider, SourceHttpClientOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrEmpty(name);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
services.AddOptions<SourceHttpClientOptions>(name).Configure<IServiceProvider>((options, sp) =>
|
||||
{
|
||||
configure(sp, options);
|
||||
SourceHttpClientConfigurationBinder.Apply(sp, name, options);
|
||||
});
|
||||
|
||||
return services
|
||||
.AddHttpClient(name)
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get(name);
|
||||
|
||||
if (options.BaseAddress is not null)
|
||||
{
|
||||
client.BaseAddress = options.BaseAddress;
|
||||
}
|
||||
|
||||
client.Timeout = options.Timeout;
|
||||
client.DefaultRequestHeaders.UserAgent.Clear();
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent);
|
||||
client.DefaultRequestVersion = options.RequestVersion;
|
||||
client.DefaultVersionPolicy = options.VersionPolicy;
|
||||
|
||||
foreach (var header in options.DefaultRequestHeaders)
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler((sp) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get(name).Clone();
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = options.AllowAutoRedirect,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
EnableMultipleHttp2Connections = options.EnableMultipleHttp2Connections,
|
||||
};
|
||||
options.ConfigureHandler?.Invoke(handler);
|
||||
ApplyProxySettings(handler, options);
|
||||
|
||||
if (options.ServerCertificateCustomValidation is not null)
|
||||
{
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, sslPolicyErrors) =>
|
||||
{
|
||||
X509Certificate2? certToValidate = certificate as X509Certificate2;
|
||||
X509Certificate2? disposable = null;
|
||||
if (certToValidate is null && certificate is not null)
|
||||
{
|
||||
disposable = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
|
||||
certToValidate = disposable;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return options.ServerCertificateCustomValidation(certToValidate, chain, sslPolicyErrors);
|
||||
}
|
||||
finally
|
||||
{
|
||||
disposable?.Dispose();
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (options.TrustedRootCertificates.Count > 0 && handler.SslOptions.RemoteCertificateValidationCallback is null)
|
||||
{
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
|
||||
{
|
||||
if (errors == SslPolicyErrors.None)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
X509Certificate2? certToValidate = certificate as X509Certificate2;
|
||||
X509Certificate2? disposable = null;
|
||||
var trustedRootCopies = new X509Certificate2Collection();
|
||||
try
|
||||
{
|
||||
if (certToValidate is null)
|
||||
{
|
||||
disposable = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
|
||||
certToValidate = disposable;
|
||||
}
|
||||
|
||||
foreach (var root in options.TrustedRootCertificates)
|
||||
{
|
||||
trustedRootCopies.Add(new X509Certificate2(root.RawData));
|
||||
}
|
||||
|
||||
using var customChain = new X509Chain();
|
||||
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
customChain.ChainPolicy.CustomTrustStore.Clear();
|
||||
customChain.ChainPolicy.CustomTrustStore.AddRange(trustedRootCopies);
|
||||
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||
|
||||
if (chain is not null)
|
||||
{
|
||||
foreach (var element in chain.ChainElements)
|
||||
{
|
||||
customChain.ChainPolicy.ExtraStore.Add(element.Certificate);
|
||||
}
|
||||
}
|
||||
|
||||
return certToValidate is not null && customChain.Build(certToValidate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (X509Certificate2 root in trustedRootCopies)
|
||||
{
|
||||
root.Dispose();
|
||||
}
|
||||
|
||||
disposable?.Dispose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return handler;
|
||||
})
|
||||
.AddHttpMessageHandler(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get(name).Clone();
|
||||
return new AllowlistedHttpMessageHandler(options);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers shared helpers used by source connectors.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSourceCommon(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton<Json.JsonSchemaValidator>();
|
||||
services.AddSingleton<Json.IJsonSchemaValidator>(sp => sp.GetRequiredService<Json.JsonSchemaValidator>());
|
||||
services.AddSingleton<XmlSchemaValidator>();
|
||||
services.AddSingleton<IXmlSchemaValidator>(sp => sp.GetRequiredService<XmlSchemaValidator>());
|
||||
services.AddSingleton<Fetch.IJitterSource, Fetch.CryptoJitterSource>();
|
||||
services.AddConcelierAocGuards();
|
||||
services.AddConcelierLinksetMappers();
|
||||
services.AddSingleton<Fetch.RawDocumentStorage>();
|
||||
services.AddSingleton<Fetch.SourceFetchService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ApplyProxySettings(SocketsHttpHandler handler, SourceHttpClientOptions options)
|
||||
{
|
||||
if (options.ProxyAddress is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var proxy = new WebProxy(options.ProxyAddress)
|
||||
{
|
||||
BypassProxyOnLocal = options.ProxyBypassOnLocal,
|
||||
UseDefaultCredentials = options.ProxyUseDefaultCredentials,
|
||||
};
|
||||
|
||||
if (options.ProxyBypassList.Count > 0)
|
||||
{
|
||||
proxy.BypassList = options.ProxyBypassList.ToArray();
|
||||
}
|
||||
|
||||
if (!options.ProxyUseDefaultCredentials
|
||||
&& !string.IsNullOrWhiteSpace(options.ProxyUsername))
|
||||
{
|
||||
proxy.Credentials = new NetworkCredential(
|
||||
options.ProxyUsername,
|
||||
options.ProxyPassword ?? string.Empty);
|
||||
}
|
||||
|
||||
handler.Proxy = proxy;
|
||||
handler.UseProxy = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Http;
|
||||
|
||||
internal static class SourceHttpClientConfigurationBinder
|
||||
{
|
||||
private const string ConcelierSection = "concelier";
|
||||
private const string HttpClientsSection = "httpClients";
|
||||
private const string SourcesSection = "sources";
|
||||
private const string HttpSection = "http";
|
||||
private const string AllowInvalidKey = "allowInvalidCertificates";
|
||||
private const string TrustedRootPathsKey = "trustedRootPaths";
|
||||
private const string ProxySection = "proxy";
|
||||
private const string ProxyAddressKey = "address";
|
||||
private const string ProxyBypassOnLocalKey = "bypassOnLocal";
|
||||
private const string ProxyBypassListKey = "bypassList";
|
||||
private const string ProxyUseDefaultCredentialsKey = "useDefaultCredentials";
|
||||
private const string ProxyUsernameKey = "username";
|
||||
private const string ProxyPasswordKey = "password";
|
||||
private const string OfflineRootKey = "offlineRoot";
|
||||
private const string OfflineRootEnvironmentVariable = "CONCELIER_OFFLINE_ROOT";
|
||||
|
||||
public static void Apply(IServiceProvider services, string clientName, SourceHttpClientOptions options)
|
||||
{
|
||||
var configuration = services.GetService(typeof(IConfiguration)) as IConfiguration;
|
||||
if (configuration is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var loggerFactory = services.GetService(typeof(ILoggerFactory)) as ILoggerFactory;
|
||||
var logger = loggerFactory?.CreateLogger("SourceHttpClientConfiguration");
|
||||
|
||||
var hostEnvironment = services.GetService(typeof(IHostEnvironment)) as IHostEnvironment;
|
||||
|
||||
var processed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var section in EnumerateCandidateSections(configuration, clientName))
|
||||
{
|
||||
if (section is null || !section.Exists() || !processed.Add(section.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplySection(section, configuration, hostEnvironment, clientName, options, logger);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateCandidateSections(IConfiguration configuration, string clientName)
|
||||
{
|
||||
var names = BuildCandidateNames(clientName);
|
||||
foreach (var name in names)
|
||||
{
|
||||
var httpClientSection = GetSection(configuration, ConcelierSection, HttpClientsSection, name);
|
||||
if (httpClientSection is not null && httpClientSection.Exists())
|
||||
{
|
||||
yield return httpClientSection;
|
||||
}
|
||||
|
||||
var sourceHttpSection = GetSection(configuration, ConcelierSection, SourcesSection, name, HttpSection);
|
||||
if (sourceHttpSection is not null && sourceHttpSection.Exists())
|
||||
{
|
||||
yield return sourceHttpSection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildCandidateNames(string clientName)
|
||||
{
|
||||
yield return clientName;
|
||||
|
||||
if (clientName.StartsWith("source.", StringComparison.OrdinalIgnoreCase) && clientName.Length > "source.".Length)
|
||||
{
|
||||
yield return clientName["source.".Length..];
|
||||
}
|
||||
|
||||
var noDots = clientName.Replace('.', '_');
|
||||
if (!string.Equals(noDots, clientName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return noDots;
|
||||
}
|
||||
}
|
||||
|
||||
private static IConfigurationSection? GetSection(IConfiguration configuration, params string[] pathSegments)
|
||||
{
|
||||
IConfiguration? current = configuration;
|
||||
foreach (var segment in pathSegments)
|
||||
{
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
current = current.GetSection(segment);
|
||||
}
|
||||
|
||||
return current as IConfigurationSection;
|
||||
}
|
||||
|
||||
private static void ApplySection(
|
||||
IConfigurationSection section,
|
||||
IConfiguration rootConfiguration,
|
||||
IHostEnvironment? hostEnvironment,
|
||||
string clientName,
|
||||
SourceHttpClientOptions options,
|
||||
ILogger? logger)
|
||||
{
|
||||
var allowInvalid = section.GetValue<bool?>(AllowInvalidKey);
|
||||
if (allowInvalid == true)
|
||||
{
|
||||
options.AllowInvalidServerCertificates = true;
|
||||
var previous = options.ServerCertificateCustomValidation;
|
||||
options.ServerCertificateCustomValidation = (certificate, chain, errors) =>
|
||||
{
|
||||
if (allowInvalid == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return previous?.Invoke(certificate, chain, errors) ?? errors == SslPolicyErrors.None;
|
||||
};
|
||||
|
||||
logger?.LogWarning(
|
||||
"Source HTTP client '{ClientName}' is configured to bypass TLS certificate validation.",
|
||||
clientName);
|
||||
}
|
||||
|
||||
var offlineRoot = section.GetValue<string?>(OfflineRootKey)
|
||||
?? rootConfiguration.GetSection(ConcelierSection).GetValue<string?>(OfflineRootKey)
|
||||
?? Environment.GetEnvironmentVariable(OfflineRootEnvironmentVariable);
|
||||
|
||||
ApplyTrustedRoots(section, offlineRoot, hostEnvironment, clientName, options, logger);
|
||||
ApplyProxyConfiguration(section, clientName, options, logger);
|
||||
}
|
||||
|
||||
private static void ApplyTrustedRoots(
|
||||
IConfigurationSection section,
|
||||
string? offlineRoot,
|
||||
IHostEnvironment? hostEnvironment,
|
||||
string clientName,
|
||||
SourceHttpClientOptions options,
|
||||
ILogger? logger)
|
||||
{
|
||||
var trustedRootSection = section.GetSection(TrustedRootPathsKey);
|
||||
if (!trustedRootSection.Exists())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var paths = trustedRootSection.Get<string[]?>();
|
||||
if (paths is null || paths.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var rawPath in paths)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolvedPath = ResolvePath(rawPath, offlineRoot, hostEnvironment);
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
var message = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Trusted root certificate '{0}' resolved to '{1}' but was not found.",
|
||||
rawPath,
|
||||
resolvedPath);
|
||||
throw new FileNotFoundException(message, resolvedPath);
|
||||
}
|
||||
|
||||
foreach (var certificate in LoadCertificates(resolvedPath))
|
||||
{
|
||||
var thumbprint = certificate.Thumbprint;
|
||||
var added = AddTrustedCertificate(options, certificate);
|
||||
if (added)
|
||||
{
|
||||
logger?.LogInformation(
|
||||
"Source HTTP client '{ClientName}' loaded trusted root certificate '{Thumbprint}' from '{Path}'.",
|
||||
clientName,
|
||||
thumbprint,
|
||||
resolvedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyProxyConfiguration(
|
||||
IConfigurationSection section,
|
||||
string clientName,
|
||||
SourceHttpClientOptions options,
|
||||
ILogger? logger)
|
||||
{
|
||||
var proxySection = section.GetSection(ProxySection);
|
||||
if (!proxySection.Exists())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var address = proxySection.GetValue<string?>(ProxyAddressKey);
|
||||
if (!string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
if (Uri.TryCreate(address, UriKind.Absolute, out var uri))
|
||||
{
|
||||
options.ProxyAddress = uri;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Source HTTP client '{ClientName}' has invalid proxy address '{ProxyAddress}'.",
|
||||
clientName,
|
||||
address);
|
||||
}
|
||||
}
|
||||
|
||||
var bypassOnLocal = proxySection.GetValue<bool?>(ProxyBypassOnLocalKey);
|
||||
if (bypassOnLocal.HasValue)
|
||||
{
|
||||
options.ProxyBypassOnLocal = bypassOnLocal.Value;
|
||||
}
|
||||
|
||||
var bypassListSection = proxySection.GetSection(ProxyBypassListKey);
|
||||
if (bypassListSection.Exists())
|
||||
{
|
||||
var entries = bypassListSection.Get<string[]?>();
|
||||
options.ProxyBypassList.Clear();
|
||||
if (entries is not null)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
options.ProxyBypassList.Add(entry.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var useDefaultCredentials = proxySection.GetValue<bool?>(ProxyUseDefaultCredentialsKey);
|
||||
if (useDefaultCredentials.HasValue)
|
||||
{
|
||||
options.ProxyUseDefaultCredentials = useDefaultCredentials.Value;
|
||||
}
|
||||
|
||||
var username = proxySection.GetValue<string?>(ProxyUsernameKey);
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
options.ProxyUsername = username.Trim();
|
||||
}
|
||||
|
||||
var password = proxySection.GetValue<string?>(ProxyPasswordKey);
|
||||
if (!string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
options.ProxyPassword = password;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePath(string path, string? offlineRoot, IHostEnvironment? hostEnvironment)
|
||||
{
|
||||
if (Path.IsPathRooted(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(offlineRoot))
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(offlineRoot!, path));
|
||||
}
|
||||
|
||||
var baseDirectory = hostEnvironment?.ContentRootPath ?? AppContext.BaseDirectory;
|
||||
return Path.GetFullPath(Path.Combine(baseDirectory, path));
|
||||
}
|
||||
|
||||
private static IEnumerable<X509Certificate2> LoadCertificates(string path)
|
||||
{
|
||||
var certificates = new List<X509Certificate2>();
|
||||
var extension = Path.GetExtension(path);
|
||||
|
||||
if (extension.Equals(".pem", StringComparison.OrdinalIgnoreCase) || extension.Equals(".crt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
try
|
||||
{
|
||||
collection.ImportFromPemFile(path);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
collection.Clear();
|
||||
}
|
||||
|
||||
if (collection.Count > 0)
|
||||
{
|
||||
foreach (var certificate in collection)
|
||||
{
|
||||
certificates.Add(certificate.CopyWithPrivateKeyIfAvailable());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
certificates.Add(X509Certificate2.CreateFromPemFile(path));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use X509CertificateLoader to load certificates from PKCS#12 files (.pfx, .p12, etc.)
|
||||
var certificate = System.Security.Cryptography.X509Certificates.X509CertificateLoader.LoadPkcs12(
|
||||
File.ReadAllBytes(path),
|
||||
password: null);
|
||||
certificates.Add(certificate);
|
||||
}
|
||||
|
||||
return certificates;
|
||||
}
|
||||
|
||||
private static bool AddTrustedCertificate(SourceHttpClientOptions options, X509Certificate2 certificate)
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var thumbprint = certificate.Thumbprint;
|
||||
if (string.IsNullOrWhiteSpace(thumbprint))
|
||||
{
|
||||
certificate.Dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.TrustedRootCertificates.Any(existing =>
|
||||
string.Equals(existing.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
certificate.Dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
options.TrustedRootCertificates.Add(certificate);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper extension method to copy certificate (preserves private key if present)
|
||||
private static X509Certificate2 CopyWithPrivateKeyIfAvailable(this X509Certificate2 certificate)
|
||||
{
|
||||
// In .NET 9+, use X509CertificateLoader instead of obsolete constructors
|
||||
if (certificate.HasPrivateKey)
|
||||
{
|
||||
// Export with private key and re-import using X509CertificateLoader
|
||||
var exported = certificate.Export(X509ContentType.Pkcs12);
|
||||
return X509CertificateLoader.LoadPkcs12(exported, password: null);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For certificates without private keys, load from raw data
|
||||
return X509CertificateLoader.LoadCertificate(certificate.RawData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Common.Http;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration applied to named HTTP clients used by connectors.
|
||||
/// </summary>
|
||||
public sealed class SourceHttpClientOptions
|
||||
{
|
||||
private readonly HashSet<string> _allowedHosts = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _defaultHeaders = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base address used for relative requests.
|
||||
/// </summary>
|
||||
public Uri? BaseAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user-agent string applied to outgoing requests.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps.Concelier/1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether redirects are allowed. Defaults to <c>true</c>.
|
||||
/// </summary>
|
||||
public bool AllowAutoRedirect { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for transient failures.
|
||||
/// </summary>
|
||||
public int MaxAttempts { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Base delay applied to the exponential backoff policy.
|
||||
/// </summary>
|
||||
public TimeSpan BaseDelay { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Hosts that this client is allowed to contact.
|
||||
/// </summary>
|
||||
public ISet<string> AllowedHosts => _allowedHosts;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default HTTP version requested by the client. Defaults to HTTP/2.
|
||||
/// </summary>
|
||||
public Version RequestVersion { get; set; } = HttpVersion.Version20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the policy that determines how HTTP version negotiation occurs. Defaults to <see cref="HttpVersionPolicy.RequestVersionOrLower"/>.
|
||||
/// </summary>
|
||||
public HttpVersionPolicy VersionPolicy { get; set; } = HttpVersionPolicy.RequestVersionOrLower;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether multiple HTTP/2 connections may be established to the same endpoint.
|
||||
/// </summary>
|
||||
public bool EnableMultipleHttp2Connections { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback to customise the underlying <see cref="SocketsHttpHandler"/>.
|
||||
/// </summary>
|
||||
public Action<SocketsHttpHandler>? ConfigureHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional proxy address used for outbound requests.
|
||||
/// </summary>
|
||||
public Uri? ProxyAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the proxy should be bypassed for local addresses. Defaults to <c>true</c>.
|
||||
/// </summary>
|
||||
public bool ProxyBypassOnLocal { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional explicit bypass list applied to the proxy.
|
||||
/// </summary>
|
||||
public IList<string> ProxyBypassList { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the default credentials should be used for the proxy.
|
||||
/// </summary>
|
||||
public bool ProxyUseDefaultCredentials { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional proxy username.
|
||||
/// </summary>
|
||||
public string? ProxyUsername { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional proxy password.
|
||||
/// </summary>
|
||||
public string? ProxyPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether server certificate validation should be bypassed.
|
||||
/// </summary>
|
||||
public bool AllowInvalidServerCertificates { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional trusted root certificates appended to the default trust store when negotiating TLS.
|
||||
/// </summary>
|
||||
public IList<X509Certificate2> TrustedRootCertificates { get; } = new List<X509Certificate2>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked to validate remote certificates when <see cref="TrustedRootCertificates"/> is insufficient.
|
||||
/// </summary>
|
||||
public Func<X509Certificate2?, X509Chain?, SslPolicyErrors, bool>? ServerCertificateCustomValidation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default request headers appended to each outgoing request.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> DefaultRequestHeaders => _defaultHeaders;
|
||||
|
||||
internal SourceHttpClientOptions Clone()
|
||||
{
|
||||
var clone = new SourceHttpClientOptions
|
||||
{
|
||||
BaseAddress = BaseAddress,
|
||||
Timeout = Timeout,
|
||||
UserAgent = UserAgent,
|
||||
AllowAutoRedirect = AllowAutoRedirect,
|
||||
MaxAttempts = MaxAttempts,
|
||||
BaseDelay = BaseDelay,
|
||||
RequestVersion = RequestVersion,
|
||||
VersionPolicy = VersionPolicy,
|
||||
EnableMultipleHttp2Connections = EnableMultipleHttp2Connections,
|
||||
ConfigureHandler = ConfigureHandler,
|
||||
AllowInvalidServerCertificates = AllowInvalidServerCertificates,
|
||||
ServerCertificateCustomValidation = ServerCertificateCustomValidation,
|
||||
ProxyAddress = ProxyAddress,
|
||||
ProxyBypassOnLocal = ProxyBypassOnLocal,
|
||||
ProxyUseDefaultCredentials = ProxyUseDefaultCredentials,
|
||||
ProxyUsername = ProxyUsername,
|
||||
ProxyPassword = ProxyPassword,
|
||||
};
|
||||
|
||||
foreach (var host in _allowedHosts)
|
||||
{
|
||||
clone.AllowedHosts.Add(host);
|
||||
}
|
||||
|
||||
foreach (var header in _defaultHeaders)
|
||||
{
|
||||
clone.DefaultRequestHeaders[header.Key] = header.Value;
|
||||
}
|
||||
|
||||
foreach (var certificate in TrustedRootCertificates)
|
||||
{
|
||||
clone.TrustedRootCertificates.Add(certificate);
|
||||
}
|
||||
|
||||
foreach (var entry in ProxyBypassList)
|
||||
{
|
||||
clone.ProxyBypassList.Add(entry);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
internal IReadOnlyCollection<string> GetAllowedHostsSnapshot()
|
||||
=> new ReadOnlyCollection<string>(_allowedHosts.ToArray());
|
||||
}
|
||||
Reference in New Issue
Block a user