- Add ConsoleSessionStore for managing console session state including tenants, profile, and token information. - Create OperatorContextService to manage operator context for orchestrator actions. - Implement OperatorMetadataInterceptor to enrich HTTP requests with operator context metadata. - Develop ConsoleProfileComponent to display user profile and session details, including tenant information and access tokens. - Add corresponding HTML and SCSS for ConsoleProfileComponent to enhance UI presentation. - Write unit tests for ConsoleProfileComponent to ensure correct rendering and functionality.
367 lines
13 KiB
C#
367 lines
13 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|