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

Первый коммит: модульный монолит ядра (.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,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Deal.Grpc.Hosting — общая серверная обвязка gRPC-хостов Deal-сервисов (замечание C31 code-review).
telegram/ai/ml-сервисы имеют независимые sln, но раньше копировали ~700 строк обвязки каждая:
интерцепторы (ServiceTokenInterceptor/RpcCallLoggingInterceptor), конфигурация mTLS
(MtlsOptions/MtlsCertificates), Serilog-настройка процесса (DealLogging) и общие блоки
Host/Program (GrpcServer — Kestrel/AddGrpc/health; GrpcHostEnvironment — порт и fail-closed mTLS
в Production). Общий код живёт здесь один раз — правки не расходятся по трём процессам.
(В ядре Deal.Api — своя копия обвязки; это вне зоны сервисов.)
Сборка: 0 warnings / 0 errors (TreatWarningsAsErrors — тот же режим, что у сервисов).
-->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AssemblyName>Deal.Grpc.Hosting</AssemblyName>
<RootNamespace>Deal.Grpc.Hosting</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- ASP.NET Core shared framework: WebApplication/Kestrel/health-абстракции серверной обвязки
(проект-библиотека — FrameworkReference вместо Web SDK). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). -->
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
<PackageReference Include="Grpc.AspNetCore.HealthChecks" Version="2.83.0" />
<!-- Serilog для DealLogging (UseSerilog + File/Console/CompactJsonFormatter). -->
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<!-- Метрики OpenTelemetry → Prometheus (этап 12, пакет A; DealMetricsHosting): хостинг OTel,
инструментация входящих ASP.NET Core-запросов, исходящих HTTP-клиентов и экспортёр /metrics
в формате Prometheus; GrpcNetClient — трейс-инструментация исходящих gRPC-вызовов (метрик в
ней нет; держим для будущего трейсинга). Версии — 1.17.x (Prometheus-экспортёр и gRPC-клиент
выпускаются только pre-release-линией; остальные пакеты 1.17.0 stable). -->
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.17.0-beta.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,122 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Events;
using Serilog.Formatting.Compact;
namespace Deal.Grpc.Hosting;
/// <summary>
/// Serilog-конфигурация процесса Deal-сервиса (Ruling 7/9, план Task 14; общий шаблон — C31).
///
/// Консоль — JSON в prod-стиле (CompactJsonFormatter: одна JSON-строка на событие, поля @t/@mt/@l —
/// парсинг Loki/Promtail) либо текст в Development; плюс rolling-файл data/logs/deal-&lt;процесс&gt;.json
/// под ContentRoot (/app в контейнере). Уровень/каталог переопределяются env: DEAL_LOG_LEVEL,
/// DEAL_LOGS_DIR.
/// </summary>
/// <remarks>
/// Конфигурация кодом, а не секцией appsettings: у сервиса appsettings.json нет (весь конфиг — env,
/// Ruling 13), поэтому единый код-набор с env-переопределениями не расходится между процессами.
/// Секреты не логируются (Ruling 13); OTel/метрики в этапе 7 не добавляются (Ruling 7) — стек:
/// Serilog-логи → docker-логи → Promtail → Loki → Grafana.
///
/// Вызов — из Program.cs процесса (entry point): <c>DealLogging.Configure(builder, "имя_процесса")</c>
/// ДО <c>builder.Build()</c>. Интеграционные тесты поднимают хост через *ServiceHost.Create БЕЗ этого
/// вызова (логирование — забота production-точки входа), поэтому тесты не пишут файлы-логи.
/// </remarks>
public static class DealLogging
{
// Env-ключ минимального уровня Serilog (Debug/Information/Warning/Error; дефолт Information).
private const string MinimumLevelEnvKey = "DEAL_LOG_LEVEL";
// Env-ключ каталога rolling-файлов (дефолт data/logs под ContentRoot).
private const string LogsDirectoryEnvKey = "DEAL_LOGS_DIR";
// Каталог логов по умолчанию (относительно ContentRoot).
private const string DefaultLogsSubdirectory = "data/logs";
// Шаблон имени rolling-файла (Serilog добавляет дату): deal-telegram-20260908.json.
private const string LogFileNameTemplate = "deal-{0}-.json";
// Сколько rolling-файлов хранится (суток).
private const int RetainedFileCount = 30;
// Текстовая разметка консоли в Development (цвета — дефолтной темой Serilog).
private const string DevelopmentConsoleTemplate =
"{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}";
// Категория Grpc.AspNetCore: не ниже Information (внутренние Debug-события вызовов не дублируют access-лог).
private const string GrpcCategory = "Grpc";
// Дефолтный уровень при пустом/невалидном env DEAL_LOG_LEVEL.
private const LogEventLevel DefaultMinimumLevel = LogEventLevel.Information;
/// <summary>
/// Подключает Serilog к хосту (builder.Host.UseSerilog). Регистрация отложенная: конфигурация
/// логгера применяется при builder.Build(), когда среда/конфигурация (env) уже собраны.
/// </summary>
/// <param name="builder">Билдер WebApplication процесса (до Build).</param>
/// <param name="processName">Имя процесса для имени файла-лога (telegram/ai/ml/…).</param>
public static void Configure(WebApplicationBuilder builder, string processName)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrWhiteSpace(processName);
builder.Host.UseSerilog((context, loggerConfiguration) =>
Apply(loggerConfiguration, context.HostingEnvironment, context.Configuration, processName));
}
// Собирает LoggerConfiguration процесса: уровень/фильтры, rolling-файл, консоль.
// loggerConfiguration: Конфигурация Serilog (до CreateLogger).
// environment: Окружение хоста (Development — текстовая консоль).
// configuration: Конфигурация хоста (env DEAL_LOG_*).
// processName: Имя процесса (суффикс имени rolling-файла).
private static void Apply(
LoggerConfiguration loggerConfiguration,
IHostEnvironment environment,
IConfiguration configuration,
string processName)
{
loggerConfiguration
.MinimumLevel.Is(ParseMinimumLevel(configuration[MinimumLevelEnvKey]))
.MinimumLevel.Override(GrpcCategory, LogEventLevel.Information)
.Enrich.FromLogContext();
string logsDirectory = ResolveLogsDirectory(environment.ContentRootPath, configuration[LogsDirectoryEnvKey]);
Directory.CreateDirectory(logsDirectory);
string logFilePath = Path.Combine(
logsDirectory,
string.Format(LogFileNameTemplate, processName));
loggerConfiguration.WriteTo.File(
new CompactJsonFormatter(),
logFilePath,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: RetainedFileCount);
if (environment.IsDevelopment())
{
loggerConfiguration.WriteTo.Console(outputTemplate: DevelopmentConsoleTemplate);
}
else
{
loggerConfiguration.WriteTo.Console(new CompactJsonFormatter());
}
}
// Каталог rolling-файлов: env DEAL_LOGS_DIR либо data/logs под ContentRoot процесса.
// contentRootPath: ContentRoot хоста (/app в контейнере).
// configuredDirectory: Значение env DEAL_LOGS_DIR (null/пусто — дефолт).
// Возвращает: Абсолютный путь каталога логов.
private static string ResolveLogsDirectory(string contentRootPath, string? configuredDirectory)
=> string.IsNullOrWhiteSpace(configuredDirectory)
? Path.Combine(contentRootPath, DefaultLogsSubdirectory)
: configuredDirectory.Trim();
// Разбирает env DEAL_LOG_LEVEL; пустое/невалидное значение — DefaultMinimumLevel.
// rawValue: Сырое значение env.
// Возвращает: Уровень Serilog.
private static LogEventLevel ParseMinimumLevel(string? rawValue)
=> Enum.TryParse(rawValue, ignoreCase: true, out LogEventLevel parsedLevel)
? parsedLevel
: DefaultMinimumLevel;
}
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry;
using OpenTelemetry.Metrics;
namespace Deal.Grpc.Hosting;
/// <summary>
/// Общая настройка метрик Deal-сервисов (этап 12, пакет A): OpenTelemetry → экспортёр Prometheus,
/// эндпоинт <c>/metrics</c> в отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464 по умолчанию).
/// </summary>
/// <remarks>
/// <para>
/// gRPC-сервисы слушают HTTP/2 (см. <see cref="GrpcServer.ConfigureKestrelHttp2Endpoint"/>), а
/// Prometheus scrape'ит обычным HTTP/1.1-запросом GET — поэтому метрики вынесены на отдельный
/// Kestrel-эндпоинт с <see cref="HttpProtocols.Http1"/>: тот же процесс, тот же DI, но отдельный порт.
/// Порт не публикуется наружу — scrape идёт внутри compose-сети от сервиса <c>prometheus</c>.
/// </para>
/// <para>
/// Сервисы вызывают ровно две строки: <see cref="AddDealMetrics"/> — на этапе сборки хоста (до
/// <c>builder.Build()</c>, обычно из <c>configureBuilder</c>-хука Program.cs), и
/// <see cref="MapDealMetrics"/> — после сборки. Инструментация (входящие ASP.NET Core/gRPC,
/// исходящие HTTP/gRPC) даёт метрики RPS/латентности/ошибок без ручного кода; прикладные метрики
/// (токены, аудит, очереди) добавляет ядро своим meter'ом <see cref="MeterName"/>.
/// </para>
/// </remarks>
public static class DealMetricsHosting
{
/// <summary>
/// Имя meter'а прикладных метрик Deal (общий префикс с ядром: <c>deal.*</c>).
/// </summary>
public const string MeterName = "Deal";
/// <summary>
/// Порт эндпоинта <c>/metrics</c> по умолчанию (конвенция OpenTelemetry Prometheus).
/// </summary>
public const int DefaultMetricsPort = 9464;
// Env-ключ порта метрик (переопределяет DefaultMetricsPort).
private const string MetricsPortEnvKey = "METRICS_PORT";
/// <summary>
/// Порт эндпоинта метрик: env <c>METRICS_PORT</c> (заданное нечисловое значение игнорируется),
/// иначе <see cref="DefaultMetricsPort"/>. Локальный запуск нескольких процессов на хосте без
/// compose требует разных значений (в compose порты контейнеров изолированы).
/// </summary>
/// <param name="defaultPort">Дефолтный порт (обычно <see cref="DefaultMetricsPort"/>).</param>
/// <returns>Порт HTTP/1.1-эндпоинта метрик.</returns>
public static int ResolveMetricsPort(int defaultPort)
=> int.TryParse(Environment.GetEnvironmentVariable(MetricsPortEnvKey), out int port) && port > 0
? port
: defaultPort;
/// <summary>
/// Регистрирует OTel-метрики и Kestrel-эндпоинт метрик (HTTP/1.1, 0.0.0.0:<paramref name="metricsPort"/>).
/// Вызывать до <c>builder.Build()</c>.
/// </summary>
/// <param name="builder">Билдер хоста сервиса.</param>
/// <param name="metricsPort">Порт HTTP/1.1-эндпоинта метрик.</param>
public static void AddDealMetrics(WebApplicationBuilder builder, int metricsPort)
{
ArgumentNullException.ThrowIfNull(builder);
// Отдельный HTTP/1.1-эндпоинт для scrape: gRPC-порт остаётся строго HTTP/2 (см. remarks класса).
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ListenAnyIP(metricsPort, listen => listen.Protocols = HttpProtocols.Http1);
});
// Инструментация входящих запросов (http.server.*: RPS/латентность/ошибки по route) и исходящих
// HTTP-клиентов (http.client.*) + экспортёр Prometheus. AddMeter — прикладные метрики.
// Исходящие gRPC-вызовы (пакет Instrumentation.GrpcNetClient) дают трейс-инструментацию, а не
// метрики — в MeterProviderBuilder не добавляются (для клиентских метрик gRPC-хопа хватает
// серверной стороны соответствующего сервиса).
builder.Services
.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter(MeterName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddPrometheusExporter());
}
/// <summary>
/// Мапит эндпоинт <c>/metrics</c> (формат Prometheus). Вызывать после <c>builder.Build()</c>.
/// </summary>
/// <param name="app">Собранное приложение сервиса.</param>
public static void MapDealMetrics(WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
app.MapPrometheusScrapingEndpoint();
}
}
@@ -0,0 +1,68 @@
namespace Deal.Grpc.Hosting;
/// <summary>
/// Общие стартовые проверки/разбор env для Program.cs Deal-сервисов (C31): порт Kestrel
/// (GRPC_PORT → PORT → дефолт), окружение ASP.NET Core и fail-closed mTLS в Production
/// (замечание code-review: отсутствие/опечатка DEAL_MTLS_ENABLED не должны давать «тихого» plaintext).
/// </summary>
public static class GrpcHostEnvironment
{
// Env-ключ порта gRPC (контейнер).
private const string GrpcPortEnvVarName = "GRPC_PORT";
// Env-ключ порта (общий конвенциональный env хостинг-платформ).
private const string PortEnvVarName = "PORT";
// Env-ключ окружения ASP.NET Core (fail-closed mTLS: Production требует DEAL_MTLS_ENABLED=1).
private const string AspNetCoreEnvironmentVarName = "ASPNETCORE_ENVIRONMENT";
// Значение окружения Production (строгое сравнение — см. IsProductionEnvironment).
private const string ProductionEnvironmentValue = "Production";
// Текст отказа fail-closed: Production требует mTLS (сертификаты deploy/certs, scripts/mtls-certs.sh).
private const string MtlsRequiredInProductionDetail =
"Production требует mTLS: задайте DEAL_MTLS_ENABLED=1 и env DEAL_MTLS_* (сертификаты deploy/certs, генерация — scripts/mtls-certs.sh)";
/// <summary>
/// Порт Kestrel процесса: env GRPC_PORT (контейнер), затем PORT (общий env хостинг-платформ),
/// иначе дефолт сервиса (Ruling 12, compose.dev.yml).
/// </summary>
/// <param name="defaultPort">Дефолтный порт сервиса.</param>
public static int ResolveGrpcPort(int defaultPort)
=> ParsePort(Environment.GetEnvironmentVariable(GrpcPortEnvVarName))
?? ParsePort(Environment.GetEnvironmentVariable(PortEnvVarName))
?? defaultPort;
/// <summary>
/// Парсит порт из env-строки; пустое/нечисловое значение — null (перебор следующего источника).
/// </summary>
/// <param name="rawValue">Сырое значение env.</param>
public static int? ParsePort(string? rawValue)
=> int.TryParse(rawValue, out int parsedPort) ? parsedPort : null;
/// <summary>
/// True — окружение Production (ASPNETCORE_ENVIRONMENT; незаданный env Production-ом не считается —
/// dev-локальный запуск без переменной остаётся на plaintext, как раньше).
/// </summary>
public static bool IsProductionEnvironment()
=> string.Equals(
Environment.GetEnvironmentVariable(AspNetCoreEnvironmentVarName),
ProductionEnvironmentValue,
StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Fail-closed-гард транспорта: при ASPNETCORE_ENVIRONMENT=Production и выключенном mTLS —
/// отказ на старте с понятным текстом (Development и прочие не-prod окружения: plaintext
/// + service-token допустимы, Ruling 2). Проверять после создания хоста (env уже собраны).
/// </summary>
/// <param name="mtlsOptions">Опции mTLS процесса (из env DEAL_MTLS_*).</param>
/// <exception cref="InvalidOperationException">Production без mTLS.</exception>
public static void RequireMtlsInProduction(MtlsOptions mtlsOptions)
{
ArgumentNullException.ThrowIfNull(mtlsOptions);
if (IsProductionEnvironment() && !mtlsOptions.Enabled)
{
throw new InvalidOperationException(MtlsRequiredInProductionDetail);
}
}
}
@@ -0,0 +1,115 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Deal.Grpc.Hosting;
/// <summary>
/// Общие серверные блоки gRPC-хостов Deal-сервисов (C31): mTLS-набор, Kestrel HTTP/2-эндпоинт,
/// AddGrpc с интерцепторами и gRPC-health. Host-фабрики сервисов (TelegramServiceHost/AiServiceHost/
/// MlServiceHost) собирают эти блоки здесь один раз, затем регистрируют свою доменную логику.
/// </summary>
public static class GrpcServer
{
// Имя gRPC-health-проверки готовности (без неё health-сервис отвечает UNKNOWN, а не SERVING).
private const string ReadyHealthCheckName = "ready";
/// <summary>
/// Потолок входящего gRPC-сообщения (серверный лимит на границе, замечание code-review; 4 МБ —
/// запросы контрактов сервисов помещаются с запасом).
/// </summary>
public const int DefaultMaxReceiveMessageSize = 4 * 1024 * 1024;
/// <summary>
/// Загружает сертификаты mTLS из env (DEAL_MTLS_*, Ruling 6/Task 13) и регистрирует набор
/// в DI: null при выключенном флаге (plaintext + service-token, dev); при включённом —
/// fail-fast на битые пути/пароли. Возвращённый экземпляр используют Kestrel и (в процессах
/// с клиентской ролью) исходящие каналы.
/// </summary>
/// <param name="builder">Билдер хоста (конфигурация env + DI).</param>
/// <returns>Набор сертификатов либо null (mTLS выключен).</returns>
public static MtlsCertificates? LoadMtlsCertificates(WebApplicationBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(
MtlsOptions.FromConfiguration(builder.Configuration));
if (mtlsCertificates is not null)
{
builder.Services.AddSingleton(mtlsCertificates);
}
return mtlsCertificates;
}
/// <summary>
/// Настраивает единственный Kestrel-эндпоинт HTTP/2 на 0.0.0.0:grpcPort: plaintext (dev, Ruling 2)
/// либо mTLS при переданном наборе сертификатов (серверный сертификат + требование клиентского
/// с проверкой через нашу CA, Ruling 6).
/// </summary>
/// <param name="builder">Билдер хоста (WebHost для ConfigureKestrel).</param>
/// <param name="grpcPort">TCP-порт Kestrel.</param>
/// <param name="mtlsCertificates">Набор сертификатов mTLS (null — plaintext).</param>
public static void ConfigureKestrelHttp2Endpoint(
WebApplicationBuilder builder,
int grpcPort,
MtlsCertificates? mtlsCertificates)
{
ArgumentNullException.ThrowIfNull(builder);
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.Listen(IPAddress.Any, grpcPort, listen =>
{
listen.Protocols = HttpProtocols.Http2;
if (mtlsCertificates is not null)
{
listen.UseHttps(https =>
{
https.ServerCertificate = mtlsCertificates.ServerCertificate;
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
});
}
});
});
}
/// <summary>
/// Регистрирует AddGrpc с общей серверной обвязкой: access-лог ПЕРВЫМ (логирует и отклонённые
/// вызовы), затем проверка service-token (Ruling 1) на каждом Deal-RPC; grpc.health.v1.Health
/// освобождён от токена и access-лога (см. ServiceTokenInterceptor/RpcCallLoggingInterceptor).
/// Плюс потолок входящего сообщения <see cref="DefaultMaxReceiveMessageSize"/>.
/// </summary>
/// <param name="services">DI сервисов хоста.</param>
public static IServiceCollection AddDealGrpcServer(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddGrpc(grpc =>
{
grpc.MaxReceiveMessageSize = DefaultMaxReceiveMessageSize;
grpc.Interceptors.Add<RpcCallLoggingInterceptor>();
grpc.Interceptors.Add<ServiceTokenInterceptor>();
});
return services;
}
/// <summary>
/// Регистрирует стандартный gRPC-health (Grpc.HealthCheck): healthcheck контейнера (Ruling 12)
/// с явной проверкой <c>ready</c> — без неё health-сервис отвечает UNKNOWN, а не SERVING.
/// </summary>
/// <param name="services">DI сервисов хоста.</param>
/// <param name="readyDetail">Текст готовности проверки (имя хоста в логах healthcheck).</param>
public static IServiceCollection AddReadyHealthCheck(this IServiceCollection services, string readyDetail)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentException.ThrowIfNullOrWhiteSpace(readyDetail);
services
.AddGrpcHealthChecks()
.AddCheck(ReadyHealthCheckName, () => HealthCheckResult.Healthy(readyDetail));
return services;
}
}
@@ -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;
}
}
@@ -0,0 +1,110 @@
using Microsoft.Extensions.Configuration;
namespace Deal.Grpc.Hosting;
/// <summary>
/// Конфигурация mTLS-транспорта внутреннего gRPC (Ruling 6, план Task 13; общий шаблон — C31).
/// </summary>
/// <remarks>
/// Только env (Ruling 13: секреты/пути сертификатов не читаются из appsettings): флаг
/// <c>DEAL_MTLS_ENABLED</c> и пути/пароли <c>DEAL_MTLS_*</c> из Ruling 6. Dev-дефолт — выключено
/// (<see cref="Enabled"/> = false): процесс остаётся на plaintext + service-token (Ruling 2 этапа 6);
/// PROD включает флаг env из compose-prod (Task 14; файлы монтируются из deploy/certs/, генерация —
/// scripts/mtls-certs.sh). Env-схема общая для процессов Deal (Ruling 6): серверный PFX — для своего
/// Kestrel-gRPC; клиентский PFX задаётся единообразно и используется процессами с исходящими
/// каналами (общий deal-client); CA — для проверки второй стороны.
/// </remarks>
public sealed class MtlsOptions
{
/// <summary>
/// Env-ключ флага: 1/true включает mTLS (как DEAL_DEMO=1).
/// </summary>
public const string EnabledEnvKey = "DEAL_MTLS_ENABLED";
/// <summary>
/// Env-ключ пути к PFX серверного сертификата процесса (Kestrel-gRPC).
/// </summary>
public const string ServerCertPfxEnvKey = "DEAL_MTLS_SERVER_CERT_PFX";
/// <summary>
/// Env-ключ пароля серверного PFX.
/// </summary>
public const string ServerCertPasswordEnvKey = "DEAL_MTLS_SERVER_CERT_PASSWORD";
/// <summary>
/// Env-ключ пути к PFX клиентского сертификата (общий deal-client исходящих каналов).
/// </summary>
public const string ClientCertPfxEnvKey = "DEAL_MTLS_CLIENT_CERT_PFX";
/// <summary>
/// Env-ключ пароля клиентского PFX.
/// </summary>
public const string ClientCertPasswordEnvKey = "DEAL_MTLS_CLIENT_CERT_PASSWORD";
/// <summary>
/// Env-ключ пути к PEM dev-CA (проверка сертификата второй стороны).
/// </summary>
public const string CaPemEnvKey = "DEAL_MTLS_CA_PEM";
/// <summary>
/// True — транспорт внутренних gRPC-эндпоинтов и исходящих каналов под mTLS.
/// </summary>
public bool Enabled { get; init; }
/// <summary>
/// Путь к PFX серверного сертификата процесса (см. <see cref="ServerCertPfxEnvKey"/>).
/// </summary>
public string ServerCertPfx { get; init; } = string.Empty;
/// <summary>
/// Пароль серверного PFX (см. <see cref="ServerCertPasswordEnvKey"/>).
/// </summary>
public string ServerCertPassword { get; init; } = string.Empty;
/// <summary>
/// Путь к PFX клиентского сертификата (см. <see cref="ClientCertPfxEnvKey"/>).
/// </summary>
public string ClientCertPfx { get; init; } = string.Empty;
/// <summary>
/// Пароль клиентского PFX (см. <see cref="ClientCertPasswordEnvKey"/>).
/// </summary>
public string ClientCertPassword { get; init; } = string.Empty;
/// <summary>
/// Путь к PEM-файлу dev-CA (см. <see cref="CaPemEnvKey"/>).
/// </summary>
public string CaPem { get; init; } = string.Empty;
/// <summary>
/// Читает опции из конфигурации хоста (env-ключи DEAL_MTLS_*, только env — Ruling 13).
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
/// <returns>Опции mTLS (флаг выключен — остальные поля пустые).</returns>
public static MtlsOptions FromConfiguration(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
return new MtlsOptions
{
Enabled = IsEnabled(configuration[EnabledEnvKey]),
ServerCertPfx = Trimmed(configuration[ServerCertPfxEnvKey]),
ServerCertPassword = configuration[ServerCertPasswordEnvKey] ?? string.Empty,
ClientCertPfx = Trimmed(configuration[ClientCertPfxEnvKey]),
ClientCertPassword = configuration[ClientCertPasswordEnvKey] ?? string.Empty,
CaPem = Trimmed(configuration[CaPemEnvKey]),
};
}
/// <summary>
/// Разбирает значение флага DEAL_MTLS_ENABLED: «1»/«true» (без учёта регистра) — включено.
/// </summary>
/// <param name="rawValue">Сырое значение env (null/пусто — выключено).</param>
public static bool IsEnabled(string? rawValue)
=> string.Equals(rawValue, "1", StringComparison.Ordinal)
|| string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
// Обрезает путь конфигурации (env-значения с пробелами/кавычками не передаются в файловые API).
// rawValue: Сырое значение env.
private static string Trimmed(string? rawValue)
=> rawValue is null ? string.Empty : rawValue.Trim();
}
@@ -0,0 +1,143 @@
using System.Diagnostics;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging;
namespace Deal.Grpc.Hosting;
/// <summary>
/// Access-лог RPC Deal-сервисов (Ruling 7, план Task 14; общий шаблон трёх сервисов — C31): каждый
/// вызов (кроме gRPC-health) — одна структурированная строка «метод → статус за N мс».
/// </summary>
/// <remarks>
/// Регистрируется ПЕРВЫМ в цепочке AddGrpc (до ServiceTokenInterceptor): логируются и отклонённые
/// вызовы (401) — access-лог должен видеть отказы. Значения запросов не логируются (в RPC — тексты/
/// промпты/ключи), секреты не пишутся (Ruling 13). gRPC-health (docker healthcheck ~5 с) пропускается —
/// иначе лог был бы зашумлён инфраструктурными пробами.
/// Access-лог ведётся для всех видов RPC (unary/клиентский/серверный/дуплексный стриминг): каждый
/// handler-метод исполняется через общий <see cref="LogAsync"/>. «Прочие» сбои реализации (не
/// отмена и не RpcException) логируются как Unknown и переводятся в RpcException — мимо лога они
/// больше не уходят (замечание code-review).
/// </remarks>
public sealed class RpcCallLoggingInterceptor : Interceptor
{
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
private readonly ILogger<RpcCallLoggingInterceptor> _logger;
/// <summary>
/// Создаёт интерцептор access-лога gRPC-вызовов.
/// </summary>
/// <param name="logger">Логгер (Serilog, Ruling 7).</param>
public RpcCallLoggingInterceptor(ILogger<RpcCallLoggingInterceptor> logger)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
}
/// <summary>
/// Логирует unary-RPC: время вызова и итоговый gRPC-статус (успех либо статус исключения).
/// </summary>
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(request, context));
/// <summary>
/// Логирует client-streaming-RPC: access-строка пишется после завершения потока/вызова.
/// </summary>
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(requestStream, context));
/// <summary>
/// Логирует server-streaming-RPC: access-строка пишется после завершения потока/вызова.
/// </summary>
public override Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(request, responseStream, context));
/// <summary>
/// Логирует дуплексный RPC: access-строка пишется после завершения потока/вызова.
/// </summary>
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
=> LogAsync(context, () => continuation(requestStream, responseStream, context));
// Исполняет вызов под access-логом: health пропускается; успех — OK, отмена клиента — Cancelled,
// RpcException — код статуса исключения, прочие сбои реализации — Unknown + RpcException.
// TResult: Тип результата вызова.
// context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика.
private async Task<TResult> LogAsync<TResult>(ServerCallContext context, Func<Task<TResult>> invoke)
{
if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
{
return await invoke().ConfigureAwait(false);
}
long startedAt = Stopwatch.GetTimestamp();
try
{
TResult response = await invoke().ConfigureAwait(false);
LogCall(context, startedAt, null);
return response;
}
catch (OperationCanceledException)
{
// Клиент отменил вызов (дисконнект/дедлайн) — статус Cancelled.
LogCall(context, startedAt, StatusCode.Cancelled);
throw;
}
catch (RpcException rpcException)
{
LogCall(context, startedAt, rpcException.Status.StatusCode);
throw;
}
catch (Exception exception)
{
// «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем
// строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован).
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method);
LogCall(context, startedAt, StatusCode.Unknown);
throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
}
}
// Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
// context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика.
private Task LogAsync(ServerCallContext context, Func<Task> invoke)
=> LogAsync(context, async () =>
{
await invoke().ConfigureAwait(false);
return true;
});
// Пишет одну строку access-лога: полное имя RPC-метода, статус, длительность.
// context: Контекст вызова (метод).
// startedAt: Метка времени старта вызова (Stopwatch.GetTimestamp).
// statusCode: Итоговый gRPC-статус; null — успех (OK).
private void LogCall(ServerCallContext context, long startedAt, StatusCode? statusCode)
{
long elapsedMs = (long)Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds;
_logger.LogInformation(
"gRPC {RpcMethod}: {GrpcStatus} за {DurationMs} мс",
context.Method,
statusCode?.ToString() ?? StatusCode.OK.ToString(),
elapsedMs);
}
}
@@ -0,0 +1,148 @@
using System.Security.Cryptography;
using System.Text;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Configuration;
namespace Deal.Grpc.Hosting;
/// <summary>
/// Серверный интерцептор service-token (Ruling 1; общий шаблон трёх Deal-сервисов — C31).
///
/// Каждый RPC Deal-сервиса обязан нести gRPC-metadata «service-token», равный ожидаемому значению
/// из env DEAL_SERVICE_TOKEN (общий токен сервисов в compose, Ruling 12). Отсутствие или
/// несовпадение токена — отказ UNAUTHENTICATED до вызова метода сервиса. Стандартный
/// grpc.health.v1.Health токеном НЕ проверяется: это liveness инфраструктуры (docker healthcheck,
/// Ruling 12), данных тенантов он не отдаёт.
///
/// Fail-closed (замечание ревью Task 2 учтено): если DEAL_SERVICE_TOKEN не задан/пуст — любой
/// Deal-RPC отклоняется всегда. Явный гард обязателен: сравнение строк без него пропустило бы
/// запрос с пустым значением metadata («» == «»), а env-провайдер конфигурации возвращает пустую
/// строку вместо null для незаданного ключа.
///
/// Проверка выполняется для ВСЕХ видов RPC (unary/клиентский/серверный/дуплексный стриминг):
/// метод <see cref="EnsureAuthorized"/> вызывается из каждого handler-а (замечание code-review).
/// </summary>
public sealed class ServiceTokenInterceptor : Interceptor
{
/// <summary>
/// Ключ gRPC-metadata с токеном сервиса (контракт — README src/contracts).
/// </summary>
public const string ServiceTokenMetadataKey = "service-token";
// Префикс методов стандартного gRPC-health, освобождённых от проверки токена.
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Env-ключ ожидаемого токена (только env; ключи/секреты не логируются — Ruling 13).
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
// Деталь отказа — общий текст для трёх сервисов этапа (шаблон T2/T3/T4).
private const string RejectionDetail = "service-token отсутствует или неверен";
private readonly byte[] _expectedTokenBytes;
/// <summary>
/// Создаёт интерцептор. Ожидаемый токен читается из конфигурации (env DEAL_SERVICE_TOKEN)
/// в момент старта хоста; смена токена требует рестарта (как остальной env-конфиг). Токен
/// хранится в UTF-8-байтах для constant-time сравнения (<see cref="CryptographicOperations"/>).
/// </summary>
/// <param name="configuration">Конфигурация хоста (env-провайдер WebApplicationBuilder).</param>
public ServiceTokenInterceptor(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
_expectedTokenBytes = Encoding.UTF8.GetBytes(configuration[ServiceTokenEnvKey] ?? string.Empty);
}
/// <summary>
/// Проверяет токен для unary-RPC и передаёт вызов дальше.
/// </summary>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
return await continuation(request, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для client-streaming-RPC и передаёт вызов дальше.
/// </summary>
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
return await continuation(requestStream, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для server-streaming-RPC и передаёт вызов дальше.
/// </summary>
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
await continuation(request, responseStream, context).ConfigureAwait(false);
}
/// <summary>
/// Проверяет токен для дуплексного RPC и передаёт вызов дальше.
/// </summary>
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
{
EnsureAuthorized(context);
await continuation(requestStream, responseStream, context).ConfigureAwait(false);
}
// Проверка токена для любого вида RPC: сначала пропускаются методы gRPC-health (безопасны), затем
// сверяется metadata «service-token» с ожидаемым значением; несовпадение — UNAUTHENTICATED.
// context: Контекст вызова (метод и metadata из заголовков).
private void EnsureAuthorized(ServerCallContext context)
{
if (context.Method.StartsWith(HealthMethodPrefix, StringComparison.Ordinal))
{
return;
}
// Fail-closed: env-токен не задан — Deal-RPC отклоняется, даже если запрос нёс «пустой» токен
// (иначе «» == «» прошло бы сравнение ниже). Health уже пропущен выше — остаётся живым.
if (_expectedTokenBytes.Length == 0)
{
throw Rejection();
}
string? actualToken = context.RequestHeaders.GetValue(ServiceTokenMetadataKey);
if (!TokenMatches(actualToken, _expectedTokenBytes))
{
throw Rejection();
}
}
// Сравнивает токен с ожидаемым constant-time (FixedTimeEquals по UTF-8-байтам): раннего выхода по
// содержимому нет — время сравнения не зависит от совпадения префикса (замечание code-review).
// actualToken: Токен из metadata (null — заголовка нет).
// expectedTokenBytes: Ожидаемый токен в UTF-8-байтах.
private static bool TokenMatches(string? actualToken, byte[] expectedTokenBytes)
{
if (actualToken is null)
{
return false;
}
byte[] actualTokenBytes = Encoding.UTF8.GetBytes(actualToken);
return CryptographicOperations.FixedTimeEquals(actualTokenBytes, expectedTokenBytes);
}
// Создаёт отказ UNAUTHENTICATED с общим текстом детали.
private static RpcException Rejection()
=> new(new Status(StatusCode.Unauthenticated, RejectionDetail));
}