# 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 │ └───────────────┬───────────────────────────────┬──────────────────────────┘ │ 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](domain-model.md#уведомления-и-аудит-без-диспетчера-доменных-событий)). - **Entities**: `Node`, `Inbound`, `VpnConfig`, `ActivationRequest`, `ClientApp`, `AuditLog`, `TelegramLinkToken`, `TelegramLoginRequest`, `TrafficSample` (см. [domain-model.md](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`/ > `IdentityRole`), а домен ссылается на пользователя/роль только по `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](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`, `Error` с `ErrorType`) вместо исключений для управляемых сценариев. ### 3. `PnvPanel.Infrastructure` Технические детали и реализации портов. - **Persistence**: `AppDbContext : IdentityDbContext`, реализует `IAppDbContext`; `IEntityTypeConfiguration` для маппингов; миграции EF Core. Репозиториев нет — хендлеры работают через `IAppDbContext` напрямую (`DbSet` + 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` (`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` > определён там же — не было смысла тащить эту связку через слой. `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](api-design.md). `SupportEndpoints`/`AdminSupportEndpoints` — первые в проекте с `multipart/form-data` (`[FromForm]` + `IFormFileCollection`, `.DisableAntiforgery()` — антифоржери-мидлварь в пайплайне не подключена, но ASP.NET Core минимал-API требует явно снять требование для form-эндпоинтов). Каждый эндпоинт аннотирован `.Produces()`, чтобы 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`; выполняются в транзакции (UnitOfWorkBehavior). - **Запросы** только читают; могут ходить в БД проекциями (`Select` в DTO) без загрузки сущностей целиком. - Диспетчер — **собственный тонкий `ISender`**: резолвит хендлер команды/запроса из DI и прогоняет через pipeline behaviors. Без внешних CQRS-библиотек (MediatR/и т.п.). Абстракции — `ICommand`, `IQuery`, `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](api-design.md)). ## Интеграция с 3x-ui (ThreeXui.Net) `ThreeXui.Net` конфигурируется на **один** `BaseAddress`, а у нас **несколько нод**. Поэтому: - Порт `IXuiPanelGateway` инкапсулирует все операции с панелями и принимает `Node`: `ListInboundsAsync`, `AddClientAsync`, `UpdateClientAsync`, `RemoveClientAsync`, `GetClientTrafficAsync`, `BuildConnectionStringAsync`, `ProbeAsync`, `ValidateBaseAddress`, `InvalidateClient(nodeId)` (вызывается после смены креденшлов ноды). - `XuiPanelGateway` — единственная реализация, держит `ConcurrentDictionary>` (ключ — `NodeId`), создавая клиента из расшифрованных `NodeCredentials` лениво при первом обращении к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`. - Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу. - Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды). - **Дрейф с 3x-ui активно не реконсилируется**: `TrafficSyncService` при недоступной ноде или при отсутствии клиента в ответе панели (`GetClientTrafficAsync`) просто пропускает его в этом цикле синхронизации — не помечает конфиг рассинхронизованным и не шлёт алерт. Если клиента удалили прямо в 3x-ui в обход панели, локальная запись `VpnConfig` продолжит существовать до следующего явного действия пользователя/админа (`Revoke`/`Rotate`), которое обнаружит несоответствие по ответу гейтвея. Активной сверки/алертинга по дрейфу нет. ## Telegram-бот (presentation-адаптер) Бот — **второй канал доставки** поверх той же Application-логики, что и REST API (не содержит бизнес-правил). Полное описание — в [telegram-bot.md](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](api-design.md#signalr--hub-hubspanel). `newsPublished` — единственное широковещательное событие (`Clients.All`), а не по группе — новости видны всем без исключения. - Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`, реализация в `Api/Hubs/`), вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую. ## Фоновые задачи - **TrafficSyncService** — периодически (`PeriodicTimer`) обходит активные ноды, тянет трафик по клиентам через `IXuiPanelGateway.GetClientTrafficAsync`, пишет `VpnConfig.UpdateTraffic(...)` и `TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)). - **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`, шлёт `nodeStatusChanged` группе `admins`. - **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика). - Реализованы как обычные `BackgroundService` + `PeriodicTimer`, без внешнего джоб-раннера (см. [tech-stack.md](tech-stack.md)). ## Сидирование и старт При старте приложения выполняется идемпотентный сидинг (`DbInitializer`), управляемый переменными окружения (см. [`.env.example`](../.env.example)): - **Системные роли**: `admin` (без лимита конфигов) и `user` (`MaxConfigs = Roles__DefaultUserMaxConfigs`, по умолчанию 3). - **Учётка администратора**: создаётся из `AdminSeed__Username` / `AdminSeed__Password`, если ещё нет; сразу активирована и с ролью `admin`. Seed-админ **не привязывается к Telegram автоматически** — привязка делается вручную в UI, как у любого пользователя. - **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из [`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD. Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе **нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении. Отдельно от сидинга — **Telegram id админов** (`Telegram__AdminTelegramUserIds`, через запятую) читаются `TelegramOptions` **напрямую при каждой проверке** (не пишутся в БД): именно этот список авторизует нажатие «Активировать/Отклонить» в боте и определяет, кому слать уведомления о новых запросах активации. ## RBAC — динамические роли и активация - `AppRole` расширяет `IdentityRole` полем `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` (`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 sdk` — `dotnet 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) │ └─────────────────────────────────────────────────────┘ ```