- Replaced instances of the previous messaging system with LiteCqrs across various application components, enhancing the CQRS implementation. - Updated dependency injection to register LiteCqrs services and behaviors, streamlining command and query handling. - Adjusted multiple command and query handlers to align with the new messaging framework, ensuring consistent functionality and improved maintainability. - Added LiteCqrs package reference in the project file for better dependency management.
35 KiB
Architecture
Обзор
PnvPanel — backend на ASP.NET Core (.NET 10) по принципам Clean Architecture с CQRS, и SPA-фронтенд на React + Vite. Backend хранит проекцию домена в PostgreSQL и оркестрирует панели 3x-ui через библиотеку ThreeXui.Net. Живые обновления — по SignalR.
┌──────────────────────────────────────────────────────────────────────────┐
│ React SPA (Vite + TS) │
│ TanStack Query/Router · shadcn/ui · @microsoft/signalr · zod │
└───────────────┬───────────────────────────────┬──────────────────────────┘
│ REST (JSON, JWT Bearer) │ WebSocket (SignalR)
┌───────────────▼───────────────────────────────▼──────────────────────────┐
│ PnvPanel.Api (Presentation) │
│ Minimal API endpoints · SignalR Hubs · Middleware · DI composition root │
└───────────────┬────────────────────────────────────────────────────────── ┘
│ ICommand / IQuery (свой диспетчер)
┌───────────────▼──────────────────────────────────────────────────────────┐
│ PnvPanel.Application │
│ Command/Query handlers · Validators · DTOs · Ports (interfaces) · │
│ Pipeline behaviors · Result<T> │
└───────────────┬───────────────────────────────┬──────────────────────────┘
│ implements ports │ uses
┌───────────────▼───────────────┐ ┌────────────▼──────────────────────────┐
│ PnvPanel.Infrastructure │ │ PnvPanel.Domain │
│ EF Core (Npgsql) · Identity · │ │ Entities · Value Object · Enums · │
│ JWT · XuiPanelGateway · │◄──┤ Domain Exceptions │
│ Background sync · Telegram │ │ (no external dependencies) │
└───────────────┬────────────────┘ └───────────────────────────────────────┘
(SignalR-хаб/пуш — в PnvPanel.Api, см. ниже)
│
┌───────────▼──────────┐ ┌──────────────────────────┐
│ PostgreSQL │ │ 3x-ui panels (nodes) │
│ (Npgsql / EF Core) │ │ via ThreeXui.Net (HTTP) │
└──────────────────────┘ └──────────────────────────┘
Слои (Clean Architecture)
Зависимости направлены внутрь: Api → Infrastructure → Application → Domain.
Внутренние слои не знают о внешних. Инверсия зависимостей — через интерфейсы (порты) в
Application, реализуемые в Infrastructure.
1. PnvPanel.Domain
Ядро без внешних зависимостей. Диспетчера доменных событий нет — уведомления и аудит вызываются напрямую из CQRS-хендлеров (см. domain-model.md).
- Entities:
Node,Inbound,VpnConfig,ActivationRequest,ClientApp,AuditLog,TelegramLinkToken,TelegramLoginRequest,TrafficSample(см. domain-model.md). - Value Objects:
NodeCredentials(логин + зашифрованный пароль ноды) — единственный VO; connection string строитIXuiPanelGatewayна лету, лимиты трафика не реализованы. - Enums:
VpnProtocol,ConfigStatus,NodeStatus,ActivationStatus,AuditSource,TelegramLoginStatus,OsPlatform. - Domain Exceptions:
DomainException— брошенный при нарушении инварианта в самой сущности (например,Revoke()уже отозванного конфига); хендлеры такие нарушения не ожидают в норме. - Инварианты и бизнес-правила инкапсулированы в сущностях (rich domain model: приватные сеттеры, фабричные методы, поведенческие методы), а не в хендлерах.
AppUser/AppRole(Identity) живут вInfrastructure(наследуютIdentityUser<Guid>/IdentityRole<Guid>), а домен ссылается на пользователя/роль только поGuid, чтобы не тащить Identity в ядро.
2. PnvPanel.Application
Сценарии приложения через CQRS.
- Commands / Queries + их Handlers (
ICommandHandler<,>/IQueryHandler<,>— свои интерфейсы), организованы по фичам (Auth/Login/,Configs/Create/,Admin/Nodes/, ...). - Ports (интерфейсы):
IAppDbContext,IXuiPanelGateway,ICurrentUser,IIdentityService,ISecretProtector,IRealtimeNotifier,ITelegramNotifier,IRoleService,IFileStorage(вложения тикетов поддержки — диск в контейнере, см.Infrastructure/Storage/DiskFileStorage). - Validators: FluentValidation на команды, где есть что проверять помимо типов (не на все — см. backend-conventions.md).
- DTO: плоские
record, конвертация из сущностей — статический методFromDomain(...)на самом DTO, без маппера (Mapster/AutoMapper). - Pipeline behaviors (порядок: Logging → Validation → RequireActivation → UnitOfWork):
LoggingBehavior,ValidationBehavior,RequireActivationBehavior(403Auth.NotActivatedдля запросов с маркеромIRequiresActivation— конфиги, новости, каталог приложений, тикеты поддержки),UnitOfWorkBehavior(транзакция +SaveChangesAsyncна команду). ОтдельногоAuthorizationBehaviorдля ролей нет — роль проверяется черезRequireAuthorization()/RequireRole(...)на эндпоинте либо явной проверкой в начале хендлера (например, «инбаунд доступен роли пользователя»). - Result: явная модель успеха/ошибки (
Result/Result<T>,ErrorсErrorType) вместо исключений для управляемых сценариев.
3. PnvPanel.Infrastructure
Технические детали и реализации портов.
- Persistence:
AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>, реализуетIAppDbContext;IEntityTypeConfiguration<T>для маппингов; миграции EF Core. Репозиториев нет — хендлеры работают черезIAppDbContextнапрямую (DbSet<T>+ LINQ). - Identity & Auth: ASP.NET Core Identity,
JwtTokenService(access + refresh),RefreshTokenService(хранение/ротация/отзыв refresh-токенов),RoleService,DbInitializer(сидинг). - 3x-ui интеграция:
XuiPanelGateway : IXuiPanelGatewayповерхThreeXui.Net; кэш клиентов per-node внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет). - Background jobs:
TrafficSyncService,NodeHealthCheckService,TrafficRetentionService,BillingService(BackgroundService+PeriodicTimer). - Secrets:
DataProtectionSecretProtector : ISecretProtector(шифрование паролей нод at-rest, ASP.NET Core Data Protection, key-ring на томеdp_keys). - Telegram:
TelegramNotifier : ITelegramNotifier— отправка DM-уведомлений черезITelegramBotClient. - Storage:
DiskFileStorage : IFileStorage— вложения тикетов поддержки, файлы на диске под GUID-именем (FileStorage:RootPath, томticket_uploadsв docker-compose, какdp_keys).
SignalR-пуш физически лежит в
PnvPanel.Api/Hubs/, не вInfrastructure.SignalRRealtimeNotifier : IRealtimeNotifierнуженIHubContext<PanelHub>, а самPanelHubопределён там же — не было смысла тащить эту связку через слой.Applicationвсё равно видит только портIRealtimeNotifier, так что граница зависимостей не нарушена.
4. PnvPanel.Api (Presentation)
Композиционный корень и транспорт.
- Minimal API-эндпоинты, сгруппированные по фичам — файлы в
Endpoints/(AuthEndpoints,ActivationEndpoints,ConfigEndpoints,AppEndpoints,SubscriptionEndpoints,TelegramEndpoints,AdminUserEndpoints,AdminAppEndpoints,AdminStatsEndpoints,NodeEndpoints,InboundEndpoints,RoleEndpoints,SupportEndpoints,AdminSupportEndpoints); полный список маршрутов — api-design.md.SupportEndpoints/AdminSupportEndpoints— первые в проекте сmultipart/form-data([FromForm]+IFormFileCollection,.DisableAntiforgery()— антифоржери-мидлварь в пайплайне не подключена, но ASP.NET Core минимал-API требует явно снять требование для form-эндпоинтов). Каждый эндпоинт аннотирован.Produces<T>(), чтобы OpenAPI-схема полностью описывала тело ответа (нужно дляpnpm gen:apiна фронте). - SignalR Hubs:
PanelHub(Hubs/). - Telegram-бот:
TelegramBotHostedService+PnvBotUpdateHandlerвTelegram/(см. отдельный раздел). - Статика SPA: раздача собранного фронта из
wwwroot+ SPA-fallback (единый контейнер). - Ошибки: встроенные
AddProblemDetails()+UseExceptionHandler()(ASP.NET Core, без кастомного middleware) конвертируют необработанные исключения вapplication/problem+json. - DI:
AddApplication()(Application),AddInfrastructure()(Infrastructure) + прямая регистрация вProgram.csдля того, что специфично Api-слою (SignalR, Telegram-клиент, rate limiting). - OpenAPI: нативный
Microsoft.AspNetCore.OpenApi(/openapi/v1.json) +Scalar.AspNetCoreUI (/scalar) — без Swashbuckle.
CQRS
- Команды меняют состояние, возвращают
Result/Result<T>; выполняются в транзакции (UnitOfWorkBehavior). - Запросы только читают; могут ходить в БД проекциями (
Selectв DTO) без загрузки сущностей целиком. - Диспетчер —
ISenderизLiteCqrs.Net(собственная лёгкая CQRS-библиотека, альтернатива MediatR с явным разделением Command/Query — сосед-репозиторий вD:\Github, пока подключёнProjectReference'ом, не через NuGet): резолвит хендлер команды/запроса из DI (dynamic-free кэшированный диспетчер) и прогоняет через pipeline behaviors. Абстракции —ICommand<T>,IQuery<T>,ICommandHandler<,>,IQueryHandler<,>,IPipelineBehavior<,>— все изLiteCqrs; PnvPanel-специфичные behaviors (ValidationBehavior,RequireActivationBehavior,UnitOfWorkBehavior) и маркерIRequiresActivationостаются вApplication/Common/Behaviors.
Пример потока «создать конфиг» (backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs):
POST /api/configs
→ CreateVpnConfigCommand (IRequiresActivation)
→ ValidationBehavior (FluentValidation — формат inboundId/label)
→ RequireActivationBehavior (403 Auth.NotActivated, если аккаунт не активирован)
→ CreateVpnConfigCommandHandler
· проверяет роль инбаунда (доменная проверка)
· SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
· пересчитывает текущее число активных конфигов и сверяет с AppUser.ConfigQuota
· IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId
· VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext
· при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync)
→ UnitOfWorkBehavior (commit транзакции)
→ 200 OK VpnConfigDto { id, label, protocol, location, usedUpBytes, usedDownBytes,
expiresAt, status, createdAt }
Ссылка подключения в ответ создания не входит — фронт запрашивает её отдельно,
GET /api/configs/{id}/link, по кнопке на карточке конфига (см. api-design.md).
Интеграция с 3x-ui (ThreeXui.Net)
ThreeXui.Net конфигурируется на один BaseAddress, а у нас несколько нод. Поэтому:
- Порт
IXuiPanelGatewayинкапсулирует все операции с панелями и принимаетNode:ListInboundsAsync,AddClientAsync,UpdateClientAsync,RemoveClientAsync,GetClientTrafficAsync,BuildConnectionStringAsync,ProbeAsync,ValidateBaseAddress,InvalidateClient(nodeId)(вызывается после смены креденшлов ноды). XuiPanelGateway— единственная реализация, держитConcurrentDictionary<Guid, Lazy<IXuiClient>>(ключ —NodeId), создавая клиента из расшифрованныхNodeCredentialsлениво при первом обращении к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самойThreeXui.Net.- Ошибки панели маппятся в доменные/
Result-ошибки; недоступная нода →NodeStatus.Offline, а не исключение наружу. - Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды).
- Синхронизация инбаундов ноды (
SyncNodeCommandHandler) реконсилирует пропажу инбаунда с панели: без привязанных конфигов запись удаляется, с конфигами — помечаетсяIsAvailable=false(детали и инварианты — domain-model.md). BuildConnectionStringAsyncпередаёт вThreeXui.Net(XuiConnectionStringRequest.ForcedFingerprint: "firefox") принудительный TLS-fingerprint клиента для tls/reality-ссылок vless/trojan/vmess, независимо от того, что задано вstreamSettingsноды; shadowsocks (без TLS) и ссылки без security библиотека не трогает. Для транспортаxhttpдополнительно передаётсяForcedPacketEncoding: "xudp"(не 3x-ui-настройка, а требование части клиентских приложений). Правится в одном месте — применяется и к сайту, и к боту, и к подписке.- Дрейф с 3x-ui активно не реконсилируется:
TrafficSyncServiceпри недоступной ноде или при отсутствии клиента в ответе панели (GetClientTrafficAsync) просто пропускает его в этом цикле синхронизации — не помечает конфиг рассинхронизованным и не шлёт алерт. Если клиента удалили прямо в 3x-ui в обход панели, локальная записьVpnConfigпродолжит существовать до следующего явного действия пользователя/админа (Revoke/Rotate), которое обнаружит несоответствие по ответу гейтвея. Активной сверки/алертинга по дрейфу нет.
Telegram-бот (presentation-адаптер)
Бот — второй канал доставки поверх той же Application-логики, что и REST API (не содержит бизнес-правил). Полное описание — в telegram-bot.md. Ключевое для архитектуры:
- Хостится в процессе Api как
BackgroundService(TelegramBotHostedService) — это условие для упаковки «фронт+бек в одном контейнере». Транспорт — только long polling (ITelegramBotClient.ReceiveAsync); webhook рассматривался, но не реализован — конфигурацииTelegram:Mode/WebhookUrlв коде нет. - Обращения к домену — только через собственный
ISender, теми же командами/запросами, что и веб (GetMyConfigsQuery,LinkTelegramCommand,ApproveTelegramLoginCommand, ...).Telegram.Botне проникает в Application/Domain. - Passwordless-вход: бот подтверждает
TelegramLoginRequest, после чего Api выпускает те же JWT/refresh, что и обычный логин (единые правила сессий). Требует предварительной привязки Telegram.
Realtime (SignalR)
- Хаб
PanelHub(/hubs/panel), авторизация по тому же JWT. - Группы (
GroupNamesвApi/Hubs/PanelHub.cs):user:{userId}(личные события),admins(события нод/системы/активации) — пользователь при подключении добавляется в своюuser:{userId}и, если он админ, дополнительно вadmins. - События сервер→клиент:
configTrafficUpdated,configStatusChanged,nodeStatusChanged,activationRequested,userActivated,newsPublished— точные payload'ы см. api-design.md.newsPublished— единственное широковещательное событие (Clients.All), а не по группе — новости видны всем без исключения. - Пуш выполняет
SignalRRealtimeNotifier(портIRealtimeNotifier, реализация вApi/Hubs/), вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую.
Фоновые задачи
- TrafficSyncService — периодически (
PeriodicTimer) обходит активные ноды, тянет трафик по клиентам черезIXuiPanelGateway.GetClientTrafficAsync, пишетVpnConfig.UpdateTraffic(...)иTrafficSample, шлётconfigTrafficUpdated. Трафик используется только для отображения — лимиты и автоотключение по превышению не реализованы (см. domain-model.md). - NodeHealthCheckService — health-probe нод (
IXuiPanelGateway.ProbeAsync), обновляетNodeStatus, шлётnodeStatusChangedгруппеadmins. Дополнительно, если у нодыNotifyOnStatusChange = true, при каждом переходе Online↔Offline шлёт админам ещё и Telegram-уведомление (ITelegramNotifier.NotifyAdminsNodeStatusChangedAsync) — опция включается индивидуально на ноду (PUT /api/admin/nodes/{id}), по умолчанию выключена. - TrafficRetentionService — чистит
TrafficSampleстарше N дней (TTL-ретеншн истории трафика). - BillingService — раз в час обходит пользователей с billing-ролью (
AppRole.BillingEnabled): гасит конфиги при просрочке оплаты (VpnConfig.Suspend()), шлёт предупреждение за 3 дня до истечения. Пропускает пользователей сPaymentRequestвAwaitingConfirmation— конфиги не гасятся, пока админ не подтвердит/отклонит заявку (см. domain-model.md). - Реализованы как обычные
BackgroundService+PeriodicTimer, без внешнего джоб-раннера (см. tech-stack.md).
Сидирование и старт
При старте приложения выполняется идемпотентный сидинг (DbInitializer), управляемый переменными
окружения (см. .env.example):
- Системные роли:
admin(без лимита конфигов) иuser(MaxConfigs = Roles__DefaultUserMaxConfigs, по умолчанию 3). - Учётка администратора: создаётся из
AdminSeed__Username/AdminSeed__Password, если ещё нет; сразу активирована и с рольюadmin. Seed-админ не привязывается к Telegram автоматически — привязка делается вручную в UI, как у любого пользователя. - Каталог приложений (
ClientApp): если таблица пуста — сидируется изseed/client-apps.json(стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD.
Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе
нет — задавайте сильный AdminSeed__Password сразу; сменить пароль можно в приложении.
Отдельно от сидинга — Telegram id админов (Telegram__AdminTelegramUserIds, через запятую)
читаются TelegramOptions напрямую при каждой проверке (не пишутся в БД): именно этот список
авторизует нажатие «Активировать/Отклонить» в боте и определяет, кому слать уведомления о новых
запросах активации.
RBAC — динамические роли и активация
AppRoleрасширяетIdentityRole<Guid>полемMaxConfigs. У пользователя ровно одна роль; квота =MaxConfigsего роли (admin— без лимита). Админ создаёт роли и меняет роль пользователя.- Активация:
AppUser.IsActivated;ActivationRequest(с комментарием) обрабатывается админом на сайте (Approve/Reject-команды) или в Telegram.ApproveActivationставитIsActivated = trueи шлёт realtime-пуш пользователю. - Ролевой доступ к инбаундам:
Inbound.AllowedRoles(M:N сAppRole); при создании конфига доменный инвариант проверяет активацию, квоту и пересечение роли пользователя сAllowedRoles. - Понижение роли — грандфазеринг: смена роли на меньшую квоту разрешена; существующие конфиги сохраняются, создание новых блокируется до входа в квоту.
- Блокировка пользователя:
IsBlocked = true→ вход запрещён + все конфигиDisabled(отключение клиентов в 3x-ui); разблокировка — обратная операция. Пишется вAuditLog. - Уведомления: запросы активации → группа
admins(SignalR) + Telegram (поTelegram__AdminTelegramUserIds); решения/блокировки → пользователю (SignalR + Telegram-DM, если привязан).
Безопасность
- AuthN: ASP.NET Core Identity + JWT, вход по
UserName(email в системе не используется). Access-token — короткий TTL (in-memory на клиенте); refresh-token — httpOnly Secure cookie, ротация при использовании, хранение хэша в БД. - Восстановление пароля: через привязанный Telegram (passwordless-вход → смена пароля); без
привязки — сброс админом (
ResetUserPasswordCommand). Email/SMTP не используются. Смена пароля вошедшим —POST /api/auth/change-password. - AuthZ: именованных policy нет — либо
.RequireAuthorization()(любой вошедший) или.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))на группе эндпоинтов, либо явная проверка внутри хендлера (владение конфигом — сравнениеVpnConfig.UserIdсICurrentUser). Активация — не в хендлере, а в pipeline behavior (RequireActivationBehavior, срабатывает на запросах с маркеромIRequiresActivation: конфиги, новости, каталог приложений) —AuthErrors.NotActivated. - Секреты нод: шифруются
ISecretProtector(Data Protection) перед сохранением; в API/логи не попадают. - CSRF: явного анти-CSRF токена нет — все мутации API читают авторизацию только из
Authorization: Bearer(JS должен явно прочитать access-token из памяти и подставить заголовок, чужой сайт этого сделать не может). Refresh-cookie (pnv_refresh_token) — единственное, что браузер шлёт автоматически; онаHttpOnly,SameSite=Strict,Path=/api/auth, иSecureвыставляется поHttpContext.Request.IsHttps(учитываетForwardedHeadersза прокси) — этого достаточно, т.к. сама по себе она не даёт мутировать данные, только обменивается на access-token эндпоинтом/api/auth/refresh. - Brute-force: Identity lockout по числу неудачных входов; rate-limit на
/auth/*(настраиваемый лимит,RateLimiting:AuthPermitLimit, по умолчанию 20 запросов/мин). - Rate limiting: встроенный
RateLimiter.NET, один fixed-window лимит (RateLimiting:AuthPermitLimit, по умолчанию 20/мин) применён к/api/auth/*,/api/auth/telegram/*и/sub/{token}; остальные эндпоинты (в т.ч. создание конфигов) им не покрыты. - Валидация входа: FluentValidation + жёсткая типизация DTO; ошибки — единый
ProblemDetails. - CORS: не настроен вообще (
AddCors/UseCorsв коде нет) — фронт и бек всегда один origin: в проде раздаются из одного образа, в dev Vite проксирует/api//hubs, так что браузер никогда не делает кросс-origin запрос. Отдельного allowlist-конфига для CORS сейчас не существует. - Аудит: значимые действия (активация, блокировка, смена роли, отзыв, ноды/инбаунды) пишутся в
AuditLog(append-only) с источникомWeb/Telegram/System.
Обработка ошибок
- Управляемые ошибки →
Result/Result<T>(ResultExtensions.ToHttpResult) → маппингErrorTypeв HTTP-статус +application/problem+json(400/401/403/404/409/422). - Непредвиденные исключения → встроенные
AddProblemDetails()+UseExceptionHandler()→ 500 без утечки деталей +Serilogrequest-логирование (UseSerilogRequestLogging, обогащение —Enrich.FromLogContext()). СквозногоCorrelationId/явного обогащенияUserId/NodeId/ConfigIdв логах пока нет — задел на будущее, а не то, на что стоит полагаться при расследовании инцидентов сегодня. - Доменные исключения (нарушение инвариантов) —
DomainException, ожидаются только как баг, а не штатный путь (штатные отказы — черезResult.Failure, не исключения).
Развёртывание (единый контейнер приложения)
По требованию — один контейнер на всё приложение (фронт + бек + бот) и отдельный контейнер PostgreSQL:
- Единый образ: ASP.NET Core (
PnvPanel.Api) обслуживает REST (/api), SignalR (/hubs), хостит Telegram-бота (long polling) и раздаёт статику React-SPA (UseStaticFiles+ SPA-fallback наindex.htmlдля клиентских маршрутов). Фронт и бек — один origin, база API — относительный/api. - Multi-stage Dockerfile:
node— сборка фронта (pnpm build) →dist/.dotnet sdk—dotnet publishApi; статика фронта копируется вwwwroot.dotnet aspnetruntime — финальный образ запускает Api.
- docker-compose: сервис
app(этот образ) + сервисdb(PostgreSQL). Всё приложение — вapp. - TLS — внешний: HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB администратора),
вне нашего compose;
appвнутри отдаёт HTTP. Приложение доверяетX-Forwarded-Proto/ForчерезForwardedHeaders-middleware — иначе Secure-cookie и определение схемы за прокси работать не будут. Отдельный nginx/Caddy в compose не вводим. - Миграции: применяются автоматически на старте приложения. При масштабировании на несколько инстансов миграции стоит вынести в отдельный шаг/джобу.
- Конфигурация через
appsettings.{Env}.json+ переменные окружения / secrets (строка подключения, JWT-ключ, ключ шифрования секретов,Telegram:BotToken).
[ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose
│ HTTP + X-Forwarded-*
┌────────────────── docker-compose ──────────────────┐
│ app (единый образ) db (postgres) │
│ ├─ REST /api └─ том с данными │
│ ├─ SignalR /hubs/panel │
│ ├─ Telegram bot (long polling) │
│ └─ статика SPA (wwwroot, fallback → index.html) │
└─────────────────────────────────────────────────────┘