Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Deal.Grpc.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Загруженный набор сертификатов mTLS внутреннего gRPC (Ruling 6, план Task 13; общий шаблон — C31).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Создаётся один раз на старте процесса, когда <see cref="MtlsOptions.Enabled"/>=true, из файлов
|
||||
/// deploy/certs (генерация — scripts/mtls-certs.sh); при выключенном флаге <see cref="Load"/> возвращает
|
||||
/// null — процесс остаётся на plaintext + service-token (Ruling 2 этапа 6). Экземпляр живёт до конца
|
||||
/// процесса: сертификаты держат Kestrel (серверный) и исходящие каналы процессов с клиентской ролью
|
||||
/// (клиентский), поэтому IDisposable сознательно нет — преждевременный Dispose сломал бы живые
|
||||
/// соединения. Fail-fast: при включённом флаге любой пустой/битый путь или пароль —
|
||||
/// <see cref="InvalidOperationException"/> на старте.
|
||||
///
|
||||
/// Проверка второй стороны — цепочка на нашу CA (CustomRootTrust, без revocation): dev-CA не в системном
|
||||
/// хранилище, поэтому стандартная проверка доверия дала бы RemoteCertificateChainErrors и без кастомного
|
||||
/// билда цепочки каждое соединение отвергалось бы.
|
||||
/// </remarks>
|
||||
public sealed class MtlsCertificates
|
||||
{
|
||||
// Роль в сообщениях об ошибках: CA-сертификат (проверка второй стороны).
|
||||
private const string CaRoleName = "CA-сертификат (проверка второй стороны)";
|
||||
|
||||
// Роль в сообщениях об ошибках: серверный сертификат Kestrel-gRPC.
|
||||
private const string ServerRoleName = "серверный сертификат Kestrel-gRPC процесса";
|
||||
|
||||
// Роль в сообщениях об ошибках: клиентский сертификат исходящих каналов.
|
||||
private const string ClientRoleName = "клиентский сертификат исходящих каналов (deal-client)";
|
||||
|
||||
private MtlsCertificates(
|
||||
X509Certificate2 caCertificate,
|
||||
X509Certificate2 serverCertificate,
|
||||
X509Certificate2 clientCertificate)
|
||||
{
|
||||
CaCertificate = caCertificate;
|
||||
ServerCertificate = serverCertificate;
|
||||
ClientCertificate = clientCertificate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CA-сертификат из CaPem: корень доверия для проверки второй стороны.
|
||||
/// </summary>
|
||||
public X509Certificate2 CaCertificate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Серверный сертификат процесса из PFX (подпись своего Kestrel-gRPC-эндпоинта).
|
||||
/// </summary>
|
||||
public X509Certificate2 ServerCertificate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Клиентский сертификат из PFX (подпись исходящих каналов, общий deal-client).
|
||||
/// </summary>
|
||||
public X509Certificate2 ClientCertificate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Загружает сертификаты из <paramref name="options"/>: null при выключенном флаге (режим plaintext),
|
||||
/// иначе — CA + серверный + клиентский с fail-fast на битые пути/пароли.
|
||||
/// </summary>
|
||||
/// <param name="options">Опции mTLS (env DEAL_MTLS_*).</param>
|
||||
/// <returns>Набор сертификатов либо null (флаг выключен).</returns>
|
||||
/// <exception cref="InvalidOperationException">Флаг включён, а путь не задан/файл не найден/не читается.</exception>
|
||||
public static MtlsCertificates? Load(MtlsOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
X509Certificate2 ca = LoadCaFromPem(options);
|
||||
X509Certificate2 server = LoadPfx(options.ServerCertPfx, options.ServerCertPassword, MtlsOptions.ServerCertPfxEnvKey, MtlsOptions.ServerCertPasswordEnvKey, ServerRoleName);
|
||||
X509Certificate2 client = LoadPfx(options.ClientCertPfx, options.ClientCertPassword, MtlsOptions.ClientCertPfxEnvKey, MtlsOptions.ClientCertPasswordEnvKey, ClientRoleName);
|
||||
return new MtlsCertificates(ca, server, client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Серверная проверка клиентского сертификата для Kestrel (ClientCertificateValidation): сертификат
|
||||
/// обязан быть подписан нашей CA (цепочка до CaPem). Стандартные ошибки цепочки (наша CA вне системного
|
||||
/// хранилища) пересобираются кастомным билдом; иные ошибки (нет сертификата/недоступен) — отказ.
|
||||
/// </summary>
|
||||
/// <param name="certificate">Клиентский сертификат из рукопожатия (null — RequireCertificate не выполнен).</param>
|
||||
/// <param name="chain">Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).</param>
|
||||
/// <param name="sslPolicyErrors">Ошибки стандартной проверки TLS.</param>
|
||||
public bool ValidateClientCertificate(X509Certificate2? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors)
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors)
|
||||
{
|
||||
return IsTrustedByCa(certificate);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт HTTP/2-хендлер исходящего канала: клиентский сертификат + проверка CA сервера
|
||||
/// (используют процессы с исходящими gRPC-каналами — общий шаблон).
|
||||
/// </summary>
|
||||
/// <returns>Новый SocketsHttpHandler (владелец — создатель; канал GrpcChannel закроет его вместе с собой).</returns>
|
||||
public SocketsHttpHandler CreateClientHttpHandler()
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
ClientCertificates = new X509CertificateCollection { ClientCertificate },
|
||||
RemoteCertificateValidationCallback = ValidateServerCertificate,
|
||||
},
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
|
||||
// Клиентская проверка сертификата сервера (RemoteCertificateValidationCallback): имя из SAN +
|
||||
// цепочка до нашей CA; сертификаты не нашей CA/чужое имя — отказ.
|
||||
// sender: Отправитель (не используется).
|
||||
// certificate: Сертификат сервера из рукопожатия.
|
||||
// chain: Цепочка стандартной проверки (игнорируется — пересобирается на нашу CA).
|
||||
// sslPolicyErrors: Ошибки стандартной проверки TLS.
|
||||
private bool ValidateServerCertificate(object? sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors)
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Имя хоста проверяется отдельно от доверия: несовпадение SAN (подключились не к тому сервису) —
|
||||
// безусловный отказ, даже если цепочка сошлась бы на нашу CA.
|
||||
if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0
|
||||
|| (sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0)
|
||||
{
|
||||
using var leaf = new X509Certificate2(certificate);
|
||||
return IsTrustedByCa(leaf);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Строит цепочку candidate → наша CA (CustomRootTrust, без revocation) — признак «свой» сертификат.
|
||||
// candidate: Проверяемый сертификат второй стороны.
|
||||
private bool IsTrustedByCa(X509Certificate2 candidate)
|
||||
{
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(CaCertificate);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
return chain.Build(candidate);
|
||||
}
|
||||
|
||||
// Читает CA из PEM/DER (только публичный сертификат — ключ CA нужен лишь скрипту генерации).
|
||||
// options: Опции mTLS.
|
||||
private static X509Certificate2 LoadCaFromPem(MtlsOptions options)
|
||||
{
|
||||
string path = RequireExistingFile(options.CaPem, MtlsOptions.CaPemEnvKey, CaRoleName);
|
||||
try
|
||||
{
|
||||
return X509CertificateLoader.LoadCertificateFromFile(path);
|
||||
}
|
||||
catch (CryptographicException exception)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{CaRoleName} ({MtlsOptions.CaPemEnvKey}): не удалось прочитать \"{path}\" — ожидается PEM/DER X.509.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Читает PFX (серверный/клиентский) с паролем; EphemeralKeySet — ключ не оседает в хранилище ОС.
|
||||
// configuredPath: Путь из env.
|
||||
// password: Пароль PFX.
|
||||
// pathEnvKey: Env-ключ пути (для сообщения об ошибке).
|
||||
// passwordEnvKey: Env-ключ пароля (для сообщения об ошибке).
|
||||
// role: Роль сертификата (для сообщения об ошибке).
|
||||
private static X509Certificate2 LoadPfx(
|
||||
string configuredPath,
|
||||
string password,
|
||||
string pathEnvKey,
|
||||
string passwordEnvKey,
|
||||
string role)
|
||||
{
|
||||
string path = RequireExistingFile(configuredPath, pathEnvKey, role);
|
||||
try
|
||||
{
|
||||
return X509CertificateLoader.LoadPkcs12FromFile(
|
||||
path,
|
||||
password,
|
||||
X509KeyStorageFlags.EphemeralKeySet);
|
||||
}
|
||||
catch (CryptographicException exception)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{role} ({pathEnvKey}): не удалось открыть \"{path}\" — проверьте путь и пароль ({passwordEnvKey}).",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-fast: путь обязан быть задан и указывать на существующий файл.
|
||||
// configuredPath: Путь из env.
|
||||
// envKey: Env-ключ пути (для сообщения об ошибке).
|
||||
// role: Роль сертификата (для сообщения об ошибке).
|
||||
private static string RequireExistingFile(string configuredPath, string envKey, string role)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"mTLS включён (DEAL_MTLS_ENABLED=1), но не задан путь {role}: env {envKey}.");
|
||||
}
|
||||
|
||||
string path = configuredPath.Trim();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new InvalidOperationException($"{role} ({envKey}): файл не найден \"{path}\".");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user