Files
PnvPanel/docs/architecture.md
T
Leonid Pershin fb320fbb31
CI / Backend (build + test) (push) Failing after 55s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Update package versions and enhance IXuiPanelGateway interface
- Updated the version of `ThreeXui.Net` to 1.0.2 in `Directory.Packages.props`.
- Enhanced the `IXuiPanelGateway` interface to include detailed documentation on the new `ForcedFingerprint` and `ForcedPacketEncoding` parameters for the `BuildConnectionStringAsync` method, clarifying their roles in client application interactions.
- Refactored `XuiPanelGateway` to implement the new parameters, ensuring compatibility with client requirements for TLS fingerprinting and packet encoding based on transport type.
- Updated architecture documentation to reflect changes in connection string handling and the implications for client applications.
2026-07-23 00:24:02 +03:00

34 KiB
Raw Blame History

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 (403 Auth.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.AspNetCore UI (/scalar) — без Swashbuckle.

CQRS

  • Команды меняют состояние, возвращают Result / Result<T>; выполняются в транзакции (UnitOfWorkBehavior).
  • Запросы только читают; могут ходить в БД проекциями (Select в DTO) без загрузки сущностей целиком.
  • Диспетчер — собственный тонкий ISender: резолвит хендлер команды/запроса из DI и прогоняет через pipeline behaviors. Без внешних CQRS-библиотек (MediatR/и т.п.). Абстракции — ICommand<T>, IQuery<T>, ICommandHandler<,>, IQueryHandler<,>, IPipelineBehavior<,>.

Пример потока «создать конфиг» (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)) — сериализует параллельные создания
        · пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs
        · 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.
  • 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 без утечки деталей + Serilog request-логирование (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:
    1. node — сборка фронта (pnpm build) → dist/.
    2. dotnet sdkdotnet publish Api; статика фронта копируется в wwwroot.
    3. dotnet aspnet runtime — финальный образ запускает 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)     │
└─────────────────────────────────────────────────────┘