Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}