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(options => options.UseNpgsql(connectionString)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); 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( (serviceProvider, options) => { var tenantContext = serviceProvider.GetRequiredService(); if (!tenantContext.HasTenant) { throw new InvalidOperationException( "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия " + "(ITenantContext.HasTenant == false)."); } var connectionStringProvider = serviceProvider.GetRequiredService(); 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() ?? new MlServiceOptions(); builder.Services.AddSingleton(mlOptions); AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions(); builder.Services.AddSingleton(aiOptions); TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? 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(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); RateLimitOptions rateLimitOptions = builder.Configuration .GetSection(rateLimitSectionName) .Get() ?? new RateLimitOptions(); builder.Services.AddSingleton(rateLimitOptions); // Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер // (public.rate_limit_counters). Активен только при Enabled (no-op иначе). builder.Services.AddScoped(); builder.Services.AddScoped(); if (rateLimitOptions.Enabled) { builder.Services.AddDealRateLimiter(rateLimitOptions); } builder.Services.AddGrpc(grpc => { grpc.Interceptors.Add(); grpc.Interceptors.Add(); if (rateLimitOptions.Enabled) { grpc.Interceptors.Add(); } }); if (rateLimitOptions.Enabled) { builder.Services.AddSingleton(provider => IngressRateLimitInterceptor.CreateLimiter( provider.GetRequiredService(), rateLimitOptions.GrpcIngressPerMinute)); } builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services .AddGrpcHealthChecks() .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов")); builder.Services.AddHttpClient( client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds)); builder.Services.AddHttpClient( client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds)); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); if (!mlOptions.UseLocal) { builder.Services.AddHostedService(); } builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); DataRetentionOptions dataRetentionOptions = builder.Configuration .GetSection(dataRetentionSectionName) .Get() ?? new DataRetentionOptions(); builder.Services.AddSingleton(dataRetentionOptions); builder.Services.AddHostedService(); // Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*). builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName)); builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName)); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping); SecurityOptions securityOptions = builder.Configuration .GetSection(securitySectionName) .Get() ?? new SecurityOptions(); builder.Services.AddSingleton(securityOptions); ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration .GetSection(forwardedHeadersSectionName) .Get() ?? 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()); 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(); app.UseCors(corsPolicyName); app.UseMiddleware(); app.UseMiddleware(); if (rateLimitOptions.Enabled) { app.UseRateLimiter(); } app.UseMiddleware(); 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().DisableRateLimiting(); app.MapGrpcService().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 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 ParseHttpAddresses(string? urlsConfig) { var addresses = new List(); 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 { /// /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig /// /// Секция ForwardedHeaders конфигурации. /// Опции для app.UseForwardedHeaders. 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; } }