Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -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;
}
}