using System.Net; using System.Text.Json.Serialization; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using PnvPanel.Api.Common; using PnvPanel.Api.Endpoints; using PnvPanel.Api.Hubs; using PnvPanel.Api.Telegram; using PnvPanel.Application; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Infrastructure; using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Persistence; using PnvPanel.Infrastructure.Telegram; using Scalar.AspNetCore; using Serilog; using Telegram.Bot; var builder = WebApplication.CreateBuilder(args); // Структурное логирование (Serilog), конфигурация из appsettings/env. builder.Services.AddSerilog( (services, configuration) => configuration .ReadFrom.Configuration(builder.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() ); // За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно // перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя // прокси (спуфинг rate-limiting по IP, аудит-лога, Secure-cookie). По умолчанию (без конфигурации) // остаётся дефолт ASP.NET Core — доверие только loopback; для прод-топологии прокси задаётся через // ForwardedHeaders__KnownProxies / ForwardedHeaders__KnownNetworks (см. .env.example). builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; foreach ( var proxy in builder .Configuration.GetSection("ForwardedHeaders:KnownProxies") .Get() ?? [] ) options.KnownProxies.Add(IPAddress.Parse(proxy)); foreach ( var network in builder .Configuration.GetSection("ForwardedHeaders:KnownNetworks") .Get() ?? [] ) { var parts = network.Split('/'); options.KnownIPNetworks.Add( new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1])) ); } }); builder.Services.AddHttpContextAccessor(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddSignalR(); // В Api, не в Infrastructure — реализации нужен IHubContext, а Hub определён здесь же. builder.Services.AddSingleton(); // Telegram-бот: presentation-адаптер, хостится в процессе Api (long polling). Клиент регистрируем // всегда (даже с пустым токеном) — TelegramBotHostedService сам решает не стартовать без токена, // а TelegramNotifier — не слать сообщения. TelegramBotClient(...) при этом валидирует формат токена // и падает на пустой строке, поэтому при пустом BotToken подставляем синтаксически валидную заглушку — // реальный HTTP-вызов через неё никогда не происходит (все вызывающие места сами проверяют BotToken). builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; var token = string.IsNullOrWhiteSpace(options.BotToken) ? "0:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" : options.BotToken; if (string.IsNullOrWhiteSpace(options.ProxyUrl)) return new TelegramBotClient(token); // Прокси (обычно socks5://) для запросов к Bot API — на случай, если Telegram недоступен напрямую // с сети сервера. Пусто (по умолчанию) — без прокси, прямое подключение (см. .env.example). var proxyUri = new Uri(options.ProxyUrl); var proxy = new WebProxy(proxyUri); if (!string.IsNullOrEmpty(proxyUri.UserInfo)) { var credentials = proxyUri.UserInfo.Split(':', 2); proxy.Credentials = new NetworkCredential( credentials[0], credentials.Length > 1 ? credentials[1] : string.Empty ); } sp.GetRequiredService>() .LogInformation( "Telegram bot using proxy {Scheme}://{Host}:{Port}", proxyUri.Scheme, proxyUri.Host, proxyUri.Port ); var handler = new SocketsHttpHandler { Proxy = proxy, UseProxy = true }; return new TelegramBotClient(token, new HttpClient(handler)); }); // Scoped — зависит от IIdentityService (scoped), не Singleton. builder.Services.AddScoped(); // Singleton — кэширует username бота (getMe) на весь процесс, не из ручного env (см. TelegramBotInfo). builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter( RateLimiting.AuthPolicy, limiterOptions => { // Настраиваемо через конфиг, чтобы интеграционные тесты (общий TestServer/host на весь // collection, все запросы — от одного "клиента") могли поднять лимит и не ловить 429. limiterOptions.PermitLimit = builder.Configuration.GetValue( "RateLimiting:AuthPermitLimit", 20 ); limiterOptions.Window = TimeSpan.FromMinutes(1); limiterOptions.QueueLimit = 0; } ); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); // Энумы сериализуются строками ("Vless", "Active", ...), не числами — самодокументируемый JSON, // корректные строковые литералы при генерации TS-типов из OpenAPI-схемы (см. docs/frontend.md). builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()) ); builder.Services.AddProblemDetails(); builder.Services.AddOpenApi(); builder.Services.AddHealthChecks().AddDbContextCheck(); var app = builder.Build(); // Без персистентного пути key-ring живёт только в памяти контейнера — после пересоздания // расшифровать уже сохранённые пароли нод будет невозможно. Предупреждаем громко, не молчим. if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"])) { app.Logger.LogWarning( "DataProtection:KeyRingPath is not set — node secret encryption keys are not persistent " + "and will be lost when the container is recreated. Mount a volume and set the path in production." ); } // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. await app.Services.ApplyMigrationsAsync(); await app.Services.SeedDataAsync(); app.UseForwardedHeaders(); app.UseSerilogRequestLogging(); app.UseExceptionHandler(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); // OpenAPI-схема (/openapi/v1.json) + современный UI Scalar (/scalar). app.MapOpenApi(); app.MapScalarApiReference(); app.MapHealthChecks("/health"); app.MapAuthEndpoints(); app.MapActivationEndpoints(); app.MapRoleEndpoints(); app.MapNodeEndpoints(); app.MapInboundEndpoints(); app.MapConfigEndpoints(); app.MapSubscriptionEndpoints(); app.MapAppEndpoints(); app.MapNewsEndpoints(); app.MapAdminUserEndpoints(); app.MapAdminStatsEndpoints(); app.MapAdminAppEndpoints(); app.MapAdminNewsEndpoints(); app.MapSupportEndpoints(); app.MapAdminSupportEndpoints(); app.MapTelegramEndpoints(); app.MapHub("/hubs/panel"); // Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов. app.UseDefaultFiles(); app.UseStaticFiles(); app.MapFallbackToFile("index.html"); app.Run(); /// Делает неявный класс Program доступным для WebApplicationFactory<Program> в интеграционных тестах. public partial class Program;