Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -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-<процесс>.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));
|
||||
}
|
||||
Reference in New Issue
Block a user