Files
Deal/src/core/Deal.Api/Program.cs
T
Rustam Khalimov f4737b9797 Расширить детектор подозрительной активности
Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS.
2026-09-11 18:15:20 +03:00

534 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Sockets;
using System.Text.Encodings.Web;
using Deal.Api.Configuration;
using Deal.Api.Endpoints;
using Deal.Api.Events;
using Deal.Api.Hosting;
using Deal.Api.Logging;
using Deal.Api.Middleware;
using Deal.Api.Observability;
using Deal.Api.Services;
using Deal.Api.Sources;
using Deal.Api.Telegram;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Infrastructure.Integrations.Storage.Services;
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Services;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.SharedKernel.Tenants.Abstractions;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
using CookieOptions = Deal.Api.Configuration.CookieOptions;
const string cookiesSectionName = "Cookies";
const string operatorCookiesSectionName = "OperatorCookies";
const string corsPolicyName = "cors";
const string servicesSectionName = "Services:Ml";
const string aiServicesSectionName = "Services:Ai";
const string telegramServicesSectionName = "Services:Telegram";
// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет
// TenantProvisioningService на старте, runtime-контекст их не выполняет).
const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory";
const int defaultIngressPort = 5082;
const string ingressPortEnvKey = "GRPC_INGRESS_PORT";
// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings).
const string serverUrlsKey = "urls";
// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000).
const string defaultHttpUrl = "http://localhost:5000";
const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET";
const string rateLimitSectionName = "RateLimit";
const string dataRetentionSectionName = "DataRetention";
const string securitySectionName = "Security";
const string forwardedHeadersSectionName = "ForwardedHeaders";
const string coreProcessName = "core";
var builder = WebApplication.CreateBuilder(args);
DealLogging.Configure(builder, coreProcessName);
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));
builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<ConnectionStringProvider>();
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration);
MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions);
if (mtlsCertificates is not null)
{
builder.Services.AddSingleton(mtlsCertificates);
}
builder.WebHost.ConfigureKestrel(kestrel =>
{
BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]);
int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort;
kestrel.ListenAnyIP(ingressPort, 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;
});
}
});
});
// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка
// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware)
// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса
// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton.
// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации.
builder.Services.AddDbContext<TenantDbContext>(
(serviceProvider, options) =>
{
var tenantContext = serviceProvider.GetRequiredService<ITenantContext>();
if (!tenantContext.HasTenant)
{
throw new InvalidOperationException(
"TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия "
+ "(ITenantContext.HasTenant == false).");
}
var connectionStringProvider = serviceProvider.GetRequiredService<ConnectionStringProvider>();
options.UseNpgsql(
connectionStringProvider.ForTenant(tenantContext.TenantId),
npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable));
},
contextLifetime: ServiceLifetime.Scoped,
optionsLifetime: ServiceLifetime.Scoped);
builder.Services.AddTenantsModule();
TokenLimitDefaults tenantLimitDefaults = new(
ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod);
builder.Services.AddDealPersistence(tenantLimitDefaults);
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
builder.Services.AddSingleton(mlOptions);
AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get<AiServiceOptions>() ?? new AiServiceOptions();
builder.Services.AddSingleton(aiOptions);
TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get<TelegramServiceOptions>() ?? new TelegramServiceOptions();
builder.Services.AddSingleton(telegramOptions);
builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates);
builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates));
builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath);
builder.Services.AddSettingsModule();
builder.Services.AddKanbanModule();
builder.Services.AddPipelineModule();
builder.Services.AddTelegramModule();
builder.Services.AddDiscoveryModule();
builder.Services.AddScoped<TgStatusService>();
builder.Services.AddScoped<TelegramKeysService>();
builder.Services.AddSingleton<TelegramBackfillScheduler>();
builder.Services.AddScoped<AdminTickOrchestrator>();
builder.Services.AddScoped<FtsMaintenance>();
builder.Services.AddSingleton<SseBroker>();
builder.Services.AddSingleton<StorageToastPublisher>();
builder.Services.AddSingleton<PipelinePumpGate>();
RateLimitOptions rateLimitOptions = builder.Configuration
.GetSection(rateLimitSectionName)
.Get<RateLimitOptions>() ?? new RateLimitOptions();
builder.Services.AddSingleton(rateLimitOptions);
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
builder.Services.AddScoped<LoginAttemptGuard>();
builder.Services.AddScoped<SuspiciousActivityReporter>();
if (rateLimitOptions.Enabled)
{
builder.Services.AddDealRateLimiter(rateLimitOptions);
}
builder.Services.AddGrpc(grpc =>
{
grpc.Interceptors.Add<RpcCallLoggingInterceptor>();
grpc.Interceptors.Add<IngressServiceTokenInterceptor>();
if (rateLimitOptions.Enabled)
{
grpc.Interceptors.Add<IngressRateLimitInterceptor>();
}
});
if (rateLimitOptions.Enabled)
{
builder.Services.AddSingleton(provider =>
IngressRateLimitInterceptor.CreateLimiter(
provider.GetRequiredService<IServiceScopeFactory>(),
rateLimitOptions.GrpcIngressPerMinute));
}
builder.Services.AddScoped<TelegramIngressService>();
builder.Services.AddScoped<IngressTenantResolver>();
builder.Services.AddScoped<SourceIngressGrpcService>();
builder.Services
.AddGrpcHealthChecks()
.AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов"));
builder.Services.AddHttpClient<IAiConnectionChecker, AiConnectionChecker>(
client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds));
builder.Services.AddHttpClient<IRatesSource, CbrRateSource>(
client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds));
builder.Services.AddSingleton<RatesRefreshScheduler>();
builder.Services.AddHostedService<TenantBootstrapService>();
builder.Services.AddHostedService<OperatorBootstrapHostedService>();
builder.Services.AddHostedService<StorageTickScheduler>();
builder.Services.AddHostedService<BudgetAlertScheduler>();
builder.Services.AddHostedService<PipelineWorkerScheduler>();
if (!mlOptions.UseLocal)
{
builder.Services.AddHostedService<MlOutboxFlushScheduler>();
}
builder.Services.AddHostedService<DiscoveryWorkerScheduler>();
builder.Services.AddSingleton<RuntimeDepthsCollector>();
builder.Services.AddHostedService<DealMetricsCollector>();
DataRetentionOptions dataRetentionOptions = builder.Configuration
.GetSection(dataRetentionSectionName)
.Get<DataRetentionOptions>() ?? new DataRetentionOptions();
builder.Services.AddSingleton(dataRetentionOptions);
builder.Services.AddHostedService<DataRetentionScheduler>();
// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*).
builder.Services.Configure<CookieOptions>(builder.Configuration.GetSection(cookiesSectionName));
builder.Services.Configure<OperatorCookieOptions>(builder.Configuration.GetSection(operatorCookiesSectionName));
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping);
SecurityOptions securityOptions = builder.Configuration
.GetSection(securitySectionName)
.Get<SecurityOptions>() ?? new SecurityOptions();
builder.Services.AddSingleton(securityOptions);
ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration
.GetSection(forwardedHeadersSectionName)
.Get<ForwardedHeadersConfig>() ?? new ForwardedHeadersConfig();
builder.Services.AddSingleton(forwardedHeadersConfig);
builder.Services.AddCors(options =>
options.AddPolicy(corsPolicyName, cors =>
{
cors.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
if (securityOptions.AllowedOrigins.Length == 0)
{
cors.SetIsOriginAllowed(_ => true);
}
else
{
cors.WithOrigins(securityOptions.AllowedOrigins);
}
}));
var app = builder.Build();
DealMetricsHosting.MapDealMetrics(app);
// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен,
// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не
// молча работает без лимитов/с открытым CORS.
if (app.Environment.IsProduction())
{
if (!rateLimitOptions.Enabled)
{
throw new InvalidOperationException(
"Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом).");
}
if (securityOptions.AllowedOrigins.Length == 0)
{
throw new InvalidOperationException(
"Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке).");
}
}
app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService<IFileStorage>());
app.Logger.LogInformation(
"ML-интеграция: {Mode} ({Endpoint})",
mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service",
mlOptions.Endpoint);
app.Logger.LogInformation(
"AI-интеграция: {Mode} ({Endpoint})",
aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service",
aiOptions.Endpoint);
app.Logger.LogInformation(
"Telegram-гейт: {Mode} ({Endpoint})",
telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service",
telegramOptions.Endpoint);
app.Logger.LogInformation(
"Транспорт внутреннего gRPC: {Transport}",
mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)");
if (forwardedHeadersConfig.Enabled)
{
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
}
app.UseMiddleware<HttpAccessLogMiddleware>();
app.UseCors(corsPolicyName);
app.UseMiddleware<SessionMiddleware>();
app.UseMiddleware<OperatorSessionMiddleware>();
if (rateLimitOptions.Enabled)
{
app.UseRateLimiter();
}
app.UseMiddleware<OriginGuardMiddleware>();
app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
app.MapAuthEndpoints();
app.MapOperatorAuthEndpoints();
app.MapOperatorAuditEndpoints();
app.MapOperatorAnalyticsEndpoints();
app.MapOperatorInvitesEndpoints();
app.MapOperatorTenantsEndpoints();
app.MapOperatorLimitsEndpoints();
app.MapOperatorHealthEndpoints();
app.MapOperatorSettingsEndpoints();
app.MapOperatorMaintenanceEndpoints();
app.MapJoinEndpoint();
app.MapSettingsEndpoints();
app.MapAiCheckEndpoint();
app.MapRatesEndpoints();
app.MapMlEndpoints();
app.MapFilterTesterEndpoints();
app.MapContainersEndpoints();
app.MapCardsEndpoints();
app.MapCardDetailsEndpoints();
app.MapStorageEndpoints();
app.MapEventsEndpoint();
app.MapAiSuggestEndpoints();
app.MapPipelineEndpoints();
app.MapTelegramEndpoints();
app.MapTelegramQrImageEndpoint();
app.MapDiscoveryEndpoints();
app.MapGrpcService<TelegramIngressService>().DisableRateLimiting();
app.MapGrpcService<SourceIngressGrpcService>().DisableRateLimiting();
app.MapGrpcHealthChecksService().DisableRateLimiting();
app.Run();
// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ──
// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных
// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из
// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге.
static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig)
{
List<Uri> addresses = ParseHttpAddresses(urlsConfig);
if (addresses.Count == 0)
{
addresses.Add(new Uri(defaultHttpUrl));
}
foreach (Uri address in addresses)
{
// Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::»
// (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени).
bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
string host = address.Host.Trim('[', ']');
if (host is "0.0.0.0" or "*" or "+" or "::")
{
kestrel.Listen(IPAddress.Any, address.Port, listen =>
{
if (isHttps)
{
listen.UseHttps();
}
});
}
else if (isHttps)
{
kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps());
}
else
{
kestrel.ListenLocalhost(address.Port);
}
}
}
// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются.
static List<Uri> ParseHttpAddresses(string? urlsConfig)
{
var addresses = new List<Uri>();
if (string.IsNullOrWhiteSpace(urlsConfig))
{
return addresses;
}
foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
addresses.Add(uri);
}
}
return addresses;
}
// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта).
static int? ParsePort(string? rawValue)
=> int.TryParse(rawValue, out int parsedPort) ? parsedPort : null;
long ResolveDefaultAiBudget(IConfiguration configuration)
{
string? rawValue = configuration[defaultAiBudgetEnvKey];
return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0
? parsedBudget
: TokenBudgetDefaults.DefaultBudgetTokens;
}
public partial class Program
{
/// <summary>
/// Строит опции UseForwardedHeaders из ForwardedHeadersConfig
/// </summary>
/// <param name="config">Секция ForwardedHeaders конфигурации.</param>
/// <returns>Опции для app.UseForwardedHeaders.</returns>
public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config)
{
ArgumentNullException.ThrowIfNull(config);
var options = new ForwardedHeadersOptions
{
// Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из
// X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи.
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
// Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки.
ForwardLimit = 1,
};
// Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы
// перечисление в KnownProxies/KnownIPNetworks было единственным источником истины.
options.KnownProxies.Clear();
options.KnownIPNetworks.Clear();
foreach (string proxy in config.KnownProxies)
{
if (!IPAddress.TryParse(proxy, out IPAddress? address))
{
throw new InvalidOperationException(
$"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\".");
}
options.KnownProxies.Add(address);
}
foreach (string network in config.KnownNetworks)
{
if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork))
{
throw new InvalidOperationException(
$"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" "
+ "(ожидается CIDR, напр. 172.16.0.0/12).");
}
options.KnownIPNetworks.Add(parsedNetwork);
}
if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0)
{
// Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять
// ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного
// конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD
// перечисляет Caddy адресами/подсетями в конфиге.
options.KnownProxies.Add(IPAddress.Loopback);
options.KnownProxies.Add(IPAddress.IPv6Loopback);
}
return options;
}
// Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся
// подсеть одного адреса (IPv4 — /32, IPv6 — /128).
// value: Строка конфига (ForwardedHeaders:KnownNetworks).
// network: Разобранная подсеть при успехе.
// Возвращает: true — запись валидна.
private static bool TryParseCidr(string value, out System.Net.IPNetwork network)
{
network = default;
int slashIndex = value.IndexOf('/');
string addressPart = slashIndex >= 0 ? value[..slashIndex] : value;
if (!IPAddress.TryParse(addressPart, out IPAddress? address))
{
return false;
}
int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
int prefixLength = maxPrefixLength;
if (slashIndex >= 0)
{
string lengthPart = value[(slashIndex + 1)..];
if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
{
return false;
}
}
network = new System.Net.IPNetwork(address, prefixLength);
return true;
}
}