Refactor environment configuration and update documentation for MVP status
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s

- Removed deprecated Telegram user ID configuration from `.env.example` and added a new setting for admin Telegram user IDs.
- Updated `CLAUDE.md` to reflect the current MVP status, detailing completed features and testing coverage.
- Enhanced `README.md` with quick start instructions for Docker setup and clarified project status.
- Revised API design documentation to include updated error handling and request/response structures.
- Improved frontend documentation to outline the project structure and technologies used.
This commit is contained in:
Leonid Pershin
2026-07-02 14:12:50 +03:00
parent 7e8435ee76
commit cdd67f8e2b
14 changed files with 896 additions and 616 deletions
+6 -14
View File
@@ -25,9 +25,6 @@ DataProtection__KeyRingPath=/app/keys
# Логин в систему — по username. Email в системе не используется. # Логин в систему — по username. Email в системе не используется.
AdminSeed__Username=admin AdminSeed__Username=admin
AdminSeed__Password=change-me-strong-admin-password AdminSeed__Password=change-me-strong-admin-password
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
AdminSeed__TelegramUserIds=123456789
# ── Роли по умолчанию ───────────────────────────────────────────────────── # ── Роли по умолчанию ─────────────────────────────────────────────────────
# Квота конфигов для системной роли "user" (выдаётся при регистрации). # Квота конфигов для системной роли "user" (выдаётся при регистрации).
@@ -38,19 +35,14 @@ Roles__DefaultUserMaxConfigs=3
# RateLimiting__AuthPermitLimit=20 # RateLimiting__AuthPermitLimit=20
# ── Telegram-бот ────────────────────────────────────────────────────────── # ── Telegram-бот ──────────────────────────────────────────────────────────
# Если BotToken пуст — бот не стартует, панель работает без него. # Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling
# (webhook не реализован, отдельного режима/URL для него нет).
Telegram__BotToken= Telegram__BotToken=
Telegram__BotUsername=PnvPanelBot Telegram__BotUsername=PnvPanelBot
Telegram__Mode=LongPolling # Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации)
# Для Mode=Webhook: # и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
# Telegram__WebhookUrl=https://panel.example.com/tg/webhook # Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI.
# Telegram__WebhookSecret=change-me-webhook-secret Telegram__AdminTelegramUserIds=123456789
# ── Приложение ────────────────────────────────────────────────────────────
# Публичный URL сайта (для deep-link'ов бота и ссылок).
App__PublicSiteUrl=https://panel.example.com
# CORS-источники (для dev; в проде фронт и бек — один origin).
App__CorsOrigins=http://localhost:5173
# ── ASP.NET Core ────────────────────────────────────────────────────────── # ── ASP.NET Core ──────────────────────────────────────────────────────────
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
+29 -15
View File
@@ -11,8 +11,10 @@
хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек + хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек +
бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose. бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose.
> **Статус: проектирование.** Код ещё не написан. Актуальны только документация и этот файл. > **Статус: MVP реализован и работает.** Бэкенд (M0–M8) и фронтенд полностью собраны, покрыты
> При старте реализации следуй [`docs/roadmap.md`](docs/roadmap.md) (этапы M0…M6). > тестами (134 бэкенд-теста), единый Docker-образ и docker-compose стек проверены живьём. История
> этапов — [`docs/roadmap.md`](docs/roadmap.md); там же — раздел Backlog с тем, что осознанно
> оставлено за рамками MVP (тарифы, лимиты трафика/срока на конфиг, полное самообслуживание в боте и т.д.).
## Документация (single source of truth) ## Документация (single source of truth)
@@ -28,9 +30,12 @@
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий - **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий
диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR, диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR,
FluentValidation, Mapster, **Serilog** (логирование). FluentValidation, **Serilog** (логирование). Маппинг DTO — вручную (`FromDomain(...)`), Mapster в
- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind CSS v4, проект не попал. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI, без Swashbuckle.
Zustand, react-hook-form + zod, @microsoft/signalr, Recharts. Пакетный менеджер — pnpm. - **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn-стиль поверх Radix +
Tailwind CSS v4, Zustand (только auth-стор), react-hook-form + zod, @microsoft/signalr. Пакетный
менеджер — pnpm, линтер — oxlint. `recharts`/`@tanstack/react-table` установлены, но не
используются в MVP (статистика — карточками, таблицы — руками).
- **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling). - **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling).
- **Инфра**: единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose. - **Инфра**: единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose.
@@ -95,11 +100,14 @@
Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом
(`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать. (`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать.
- **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа - **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа
(username/пароль/Telegram id) из переменных окружения; каталог приложений `ClientApp` (если пуст) — (`AdminSeed__Username`/`AdminSeed__Password`) из переменных окружения; каталог приложений `ClientApp`
из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — [`.env.example`](.env.example); (если пуст) — из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env —
при добавлении новой настройки обновляй и его. Секреты (пароль админа, JWT-ключ, BotToken) — только через env/secret-store. [`.env.example`](.env.example); при добавлении новой настройки обновляй и его. Секреты (пароль
- Telegram id админов (`AdminSeed__TelegramUserIds`) авторизуют админ-действия в боте и получают админа, JWT-ключ, `Telegram__BotToken`) — только через env/secret-store.
уведомления о запросах активации. - Telegram id админов — **отдельно от сидинга**, `Telegram__AdminTelegramUserIds` (через запятую),
читается `TelegramOptions` напрямую при каждой проверке, не пишется в БД. Именно он авторизует
админ-кнопки в боте и адресует уведомления о запросах активации. Seed-админ **не** привязывается к
Telegram автоматически — привязка делается вручную в UI, как у любого пользователя.
## Telegram-бот ## Telegram-бот
@@ -128,13 +136,19 @@
Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко: Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко:
- Команды `<Verb><Noun>Command`, запросы `<Get/List><Noun>Query`, + `Handler`/`Validator`. DTO — суффикс `Dto`. - Команды `<Verb><Noun>Command`, запросы `<Get/List><Noun>Query`, + `Handler`/`Validator` (валидатор —
не для каждой команды, только где есть что проверить). Application DTO — суффикс `Dto`
(`FromDomain(...)` конвертирует из сущности); тела запросов Api-слоя — суффикс `Body`; тела ответов,
которых нет как Application DTO — суффикс `ResponseDto`.
- Application организована **по фичам** (feature folders) внутри слоёв. - Application организована **по фичам** (feature folders) внутри слоёв.
- Один публичный тип на файл, имя файла = имя типа. Async-методы — суффикс `Async` + `CancellationToken`. - Один публичный тип на файл, имя файла = имя типа (кроме вспомогательных `Body`/`ResponseDto`
- Секреты не логировать; логи структурные (Serilog) с `UserId`/`NodeId`/`ConfigId`/`CorrelationId`. records — они живут в том же файле, что и класс эндпоинтов). Async-методы — суффикс `Async` + `CancellationToken`.
- Ошибки API — единый `ProblemDetails`. - Секреты не логировать; логи — Serilog (`UseSerilogRequestLogging` + `Enrich.FromLogContext()`).
Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId` пока нет — не полагайся на него при
расследовании, пока не добавлено.
- Ошибки API — единый `application/problem+json` (без Swashbuckle — нативный `Microsoft.AspNetCore.OpenApi`).
## Команды (ожидаемые — появятся по мере создания проектов) ## Команды
Backend (из `backend/`): Backend (из `backend/`):
```bash ```bash
+29 -2
View File
@@ -21,6 +21,28 @@
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind | | Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind |
| Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose | | Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose |
## Быстрый старт
Единый Docker-образ (API + бот + статика SPA) + PostgreSQL:
```bash
cp .env.example .env # заполнить AdminSeed__Password, Jwt__SigningKey и т.д.
docker compose up -d --build
# → http://localhost:8080 (админ — логин/пароль из .env, AdminSeed__Username/Password)
```
Локальная разработка (без Docker для приложения — только `db`):
```bash
# backend, из backend/
dotnet build && dotnet test
dotnet run --project src/PnvPanel.Api
# frontend, из frontend/
pnpm install
pnpm dev # проксирует /api, /hubs на localhost:8080
```
## Документация ## Документация
Проектная документация лежит в [`docs/`](docs/README.md): Проектная документация лежит в [`docs/`](docs/README.md):
@@ -33,14 +55,19 @@
- [Frontend](docs/frontend.md) — стек фронтенда и структура - [Frontend](docs/frontend.md) — стек фронтенда и структура
- [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход - [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход
- [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты - [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты
- [Roadmap](docs/roadmap.md) — этапы разработки - [Roadmap](docs/roadmap.md) — этапы разработки (ретроспектива) + backlog
Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example). Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example).
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md). Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md).
## Статус ## Статус
🚧 Проектирование. Кодовая база ещё не создана — на этом этапе зафиксированы архитектура и план. **MVP реализован.** Backend (Clean Architecture, CQRS, 134 теста) и frontend (React SPA, полная
админка) готовы, покрывают весь [roadmap](docs/roadmap.md) M0–M8: регистрация/активация/роли,
управление нодами 3x-ui и inbound'ами, самообслуживание конфигами, realtime по SignalR, Telegram-бот
(привязка + passwordless-вход + уведомления), админ-статистика и аудит. Единый Docker-образ и
docker-compose стек проверены end-to-end. Что осталось за рамками MVP осознанно — раздел Backlog в
[roadmap.md](docs/roadmap.md#backlog-после-mvp).
## Лицензия ## Лицензия
+8 -3
View File
@@ -1,16 +1,21 @@
# PnvPanel — Документация # PnvPanel — Документация
Индекс проектной документации. Читать в этом порядке для погружения: **MVP реализован** (backend M0M8 + полный frontend). Документация ниже описывает систему как она
реально построена, со сверенными по коду деталями — не первоначальный план. Расхождения с ранним
замыслом отмечены явно там, где это важно (например, домен изначально закладывал доменные события —
в реализации от них отказались в пользу прямых вызовов из CQRS-хендлеров, см. [architecture.md](architecture.md)).
Индекс. Читать в этом порядке для погружения:
1. **[Product Vision & Scope](vision.md)** — продукт, роли, пользовательские сценарии, границы MVP. 1. **[Product Vision & Scope](vision.md)** — продукт, роли, пользовательские сценарии, границы MVP.
2. **[Architecture](architecture.md)** — Clean Architecture, слои, CQRS, интеграция с 3x-ui, realtime, безопасность, фоновые задачи. 2. **[Architecture](architecture.md)** — Clean Architecture, слои, CQRS, интеграция с 3x-ui, realtime, безопасность, фоновые задачи.
3. **[Domain Model](domain-model.md)** — сущности, value objects, связи, инварианты, доменные события. 3. **[Domain Model](domain-model.md)** — сущности, value objects, связи, инварианты, уведомления/аудит.
4. **[Tech Stack (ADR)](tech-stack.md)** — принятые решения по технологиям и их обоснование. 4. **[Tech Stack (ADR)](tech-stack.md)** — принятые решения по технологиям и их обоснование.
5. **[Backend Conventions](backend-conventions.md)** — структура решения, паттерны, соглашения по коду. 5. **[Backend Conventions](backend-conventions.md)** — структура решения, паттерны, соглашения по коду.
6. **[Frontend](frontend.md)** — стек, структура, работа с API и realtime. 6. **[Frontend](frontend.md)** — стек, структура, работа с API и realtime.
7. **[Telegram Bot](telegram-bot.md)** — бот: ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram. 7. **[Telegram Bot](telegram-bot.md)** — бот: ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram.
8. **[API Design](api-design.md)** — контракты REST и SignalR. 8. **[API Design](api-design.md)** — контракты REST и SignalR.
9. **[Roadmap](roadmap.md)** — этапы (milestones) и порядок реализации. 9. **[Roadmap](roadmap.md)** — ретроспектива по этапам (milestones) + backlog.
## Принятые решения ## Принятые решения
+146 -117
View File
@@ -1,181 +1,210 @@
# API Design # API Design
REST поверх HTTP/JSON, авторизация — `Authorization: Bearer <access-token>` (кроме публичных). REST поверх HTTP/JSON, авторизация — `Authorization: Bearer <access-token>` (кроме публичных
Ошибки — `application/problem+json` (`ProblemDetails`). Пагинация — `?page=&pageSize=`, эндпоинтов). Ошибки — `application/problem+json`. Пагинация — `?page=&pageSize=`, ответ `PagedList<T>`
ответ `PagedList<T>` (`items`, `total`, `page`, `pageSize`). Все даты — ISO-8601 UTC. (`items`, `total`, `page`, `pageSize`) — используется не везде, см. таблицы ниже. Все даты — ISO-8601 UTC.
Тела запросов/ответов — camelCase JSON; енумы сериализуются строками (`"Active"`, не `0`).
Базовый префикс: `/api` (**без версионирования в MVP** — единый фронт+бек; версии введём при Базовый префикс: `/api` (**без версионирования в MVP**). Схема генерируется нативным
необходимости). Ниже — контракт MVP (может уточняться при реализации). `Microsoft.AspNetCore.OpenApi` (`/openapi/v1.json`) и Scalar UI (`/scalar`) — каждый эндпоинт
аннотирован `.Produces<T>()`, так что схема полностью описывает и тела запросов, и тела ответов.
Ниже — полный контракт, сверенный построчно с кодом (`backend/src/PnvPanel.Api/Endpoints/*.cs`).
## Auth ## Auth
| Метод | Путь | Роль | Описание | | Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | --------------------------- | ------ | ---------------------------------------------------- | | ----- | --------------------------- | ------ | ------------------------------------------ | -------------------------------------- |
| POST | `/api/auth/register` | — | Регистрация `{ username, password }` | | POST | `/api/auth/register` | — | `{ userName, password }` | `{ id, userName }` |
| POST | `/api/auth/login` | — | Вход `{ username, password }` → access (body) + refresh (httpOnly cookie) | | POST | `/api/auth/login` | — | `{ userName, password }` | `{ accessToken, expiresAt, user }` + refresh в httpOnly cookie |
| POST | `/api/auth/refresh` | — | Обновление access по refresh-cookie (ротация) | | POST | `/api/auth/refresh` | — | — (refresh из cookie) | то же, что login; ротация cookie |
| POST | `/api/auth/logout` | user | Отзыв refresh-токена | | POST | `/api/auth/logout` | user | — | `204 No Content` |
| POST | `/api/auth/change-password` | user | Смена пароля `{ currentPassword, newPassword }` | | POST | `/api/auth/change-password` | user | `{ currentPassword, newPassword }` | `204 No Content` |
| GET | `/api/auth/me` | user | Текущий профиль + роль + `isActivated` + `telegramLinked` | | GET | `/api/auth/me` | user | — | `{ id, userName, role, isActivated, telegramLinked }` |
| DELETE| `/api/auth/me` | user | Самоудаление аккаунта (отзыв всех конфигов + удаление данных; аудит анонимизируется) | | DELETE| `/api/auth/me` | user | — | `204 No Content` |
> **Вход по username.** Email в системе не используется. Забыт пароль: `user`/ответ `/me`**`role` строкой** (одна роль, не массив). Группа `/api/auth/*` под общим
> при привязанном Telegram — восстановление через бота; иначе — сброс админом (см. Admin). rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 запросов/мин).
> **Вход по username.** Email в системе не используется. Забыт пароль: при привязанном
> Telegram — вход без пароля через бота и смена пароля в настройках; иначе — сброс админом (см. Admin).
## Auth — Telegram (привязка и passwordless-вход) ## Auth — Telegram (привязка и passwordless-вход)
| Метод | Путь | Роль | Описание | Группа `/api/auth/telegram/*`, тот же rate-limit, что и `/api/auth/*`.
| ----- | --------------------------------------------- | ---- | -------------------------------------------------------------- |
| POST | `/api/auth/telegram/link-token` | user | Создать токен привязки → `{ deepLink, qr, expiresAt }` |
| POST | `/api/auth/telegram/unlink` | user | Отвязать Telegram от аккаунта |
| POST | `/api/auth/telegram/login-request` | — | Инициировать вход → `{ requestId, deepLink, qr, expiresAt }` |
| GET | `/api/auth/telegram/login-request/{id}` | — | Статус запроса; при `Approved` выдаёт access + refresh-cookie |
`GET …/login-request/{id}` (поллинг; альтернатива — событие SignalR) → варианты ответа: | Метод | Путь | Роль | Тело ответа |
| ----- | --------------------------------------------- | ---- | ------------------------------------------------------ |
| POST | `/api/auth/telegram/link-token` | user | `{ deepLink, expiresAt }` |
| POST | `/api/auth/telegram/unlink` | user | `204 No Content` |
| POST | `/api/auth/telegram/login-request` | — | `{ requestId, deepLink, expiresAt }` |
| GET | `/api/auth/telegram/login-request/{id}` | — | см. ниже |
`deepLink``null`, если `Telegram:BotUsername` не настроен (бот не привязан к инстансу), иначе
`https://t.me/<bot>?start=link_<token>` / `?start=login_<requestId>`. **QR backend не рендерит**
фронт строит QR из `deepLink` сам (`qrcode.react`).
`GET …/login-request/{id}` (поллинг) → варианты ответа:
```json ```json
// ожидание // ожидание / отклонено / истекло — accessToken/expiresAt/user всегда null, кроме Approved
{ "status": "Pending" } { "status": "Pending", "accessToken": null, "expiresAt": null, "user": null }
// подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос Consumed // подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос помечается Consumed
{ "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "roles": ["User"] } } { "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "userName": "…", "role": "user", "isActivated": true, "telegramLinked": true } }
// отклонено / истекло
{ "status": "Rejected" } // | "Expired"
``` ```
`status` — одно из `Pending`/`Approved`/`Rejected`/`Expired`/`Consumed`.
> Сами апдейты Telegram (`/start`, кнопки) обрабатывает in-process бот (long polling), а не HTTP-эндпоинты. > Апдейты Telegram (`/start`, кнопки, `/configs`) обрабатывает in-process бот (long polling), а не
> Контракты команд бота — в [telegram-bot.md](telegram-bot.md). > HTTP-эндпоинты. Команды бота — в [telegram-bot.md](telegram-bot.md).
## Configs (пользователь) ## Configs (пользователь)
| Метод | Путь | Роль | Описание | Группа `/api` (не вложена дальше), `RequireAuthorization()`.
| ------ | --------------------------------- | ---- | ------------------------------------------------- |
| GET | `/api/inbounds/available` | user | Инбаунды, доступные роли пользователя (для выбора при создании) |
| GET | `/api/configs` | user | Список своих конфигов (пагинация) |
| POST | `/api/configs` | user | Создать конфиг `{ inboundId, label?, deviceLimit? }` (проверки: активирован, квота роли, доступ роли к инбаунду) |
| PATCH | `/api/configs/{id}` | user | Изменить `{ label?, deviceLimit? }` (deviceLimit → `limitIp` в 3x-ui) |
| GET | `/api/configs/{id}` | user | Детали конфига (метка, трафик, устройства, статус) |
| GET | `/api/configs/{id}/link` | user | Connection string + subscriptionUrl + QR-payload |
| POST | `/api/configs/{id}/rotate` | user | Перевыпустить конфиг (новый UUID/ссылка; квоту не тратит) |
| DELETE | `/api/configs/{id}` | user | Отозвать конфиг (удаляет клиента в 3x-ui) |
| GET | `/api/subscription` | user | URL агрегированной подписки пользователя (все активные конфиги) |
`POST /api/configs``201 Created`: | Метод | Путь | Тело запроса | Тело ответа |
```json | ------ | --------------------------------- | ------------------------------------ | --------------------------------------- |
{ | GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` |
"id": "…", "protocol": "Vless", "location": "DE", | GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }`**без пагинации**, весь список сразу |
"link": "vless://…", "subscriptionUrl": "https://…/sub/…", | POST | `/api/configs` | `{ inboundId, label?, deviceLimit? }`| `VpnConfigDto` (`200 OK`, не 201) |
"trafficLimitBytes": 53687091200, "expiresAt": "2026-08-01T00:00:00Z", | PATCH | `/api/configs/{id}` | `{ label?, deviceLimit? }` | `VpnConfigDto` |
"status": "Active" | POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) |
} | DELETE | `/api/configs/{id}` | — | `204 No Content` |
``` | GET | `/api/configs/{id}/link` | — | `{ connectionString, subscriptionUrl }` |
| GET | `/api/subscription` | — | `{ subscriptionUrl }` |
**Нет отдельного `GET /api/configs/{id}`** — детали конфига берутся из списка `GET /api/configs`.
`VpnConfigDto`: `{ id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes, expiresAt,
status, createdAt }`. `expiresAt` в MVP всегда `null` (лимиты по сроку не реализованы — см.
[domain-model.md](domain-model.md)). Ссылка подключения **не приходит вместе с созданием** — фронт
запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из
`connectionString`.
`POST /api/configs` без активации → `403` (`Configs.NotActivated`); сверх квоты роли → `409`
(`Configs.QuotaExceeded`).
## Apps — каталог приложений ## Apps — каталог приложений
| Метод | Путь | Роль | Описание | | Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | -------------------------- | ----- | ---------------------------------------------------- | | ------ | -------------------------- | ----- | --------------------------------------------------------------------------------- | ------------- |
| GET | `/api/apps` | user | Включённые приложения, **сгруппированы по ОС** (для страницы инструкций) | | GET | `/api/apps` | user | — | `Record<OsPlatform, ClientAppDto[]>` |
| GET | `/api/admin/apps` | admin | Все приложения (вкл. выключенные) | | GET | `/api/admin/apps` | admin | — | `AdminAppDto[]` (вкл. выключенные) |
| POST | `/api/admin/apps` | admin | Добавить `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder? }` | | POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder }` | `AdminAppDto` |
| PUT | `/api/admin/apps/{id}` | admin | Изменить приложение (в т.ч. `isEnabled`) | | PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled }` | `AdminAppDto` |
| DELETE | `/api/admin/apps/{id}` | admin | Удалить приложение | | DELETE | `/api/admin/apps/{id}` | admin | | `204 No Content` |
`GET /api/apps` → пример: `GET /api/apps` → пример (только `isEnabled == true`, ОС без приложений в ответе отсутствует):
```json ```json
{ {
"Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "iconUrl": null } ], "Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "description": null, "iconUrl": null } ],
"iOS": [ { "id": "…", "name": "Hiddify", "downloadUrl": "https://…", "iconUrl": null } ] "IOS": [ { "id": "…", "name": "Hiddify", "downloadUrl": "https://…", "description": null, "iconUrl": null } ]
} }
``` ```
Значение `OsPlatform` в C#/JSON`IOS` (не `iOS`).
## Activation (пользователь) ## Activation (пользователь)
| Метод | Путь | Роль | Описание | | Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | --------------------------- | ---- | ------------------------------------------------------ | | ----- | --------------------------- | ---- | ----------------- | -------------------------------------------------------- |
| GET | `/api/activation/status` | user | Статус активации + текущий `Pending`-запрос (если есть)| | GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
| POST | `/api/activation/request` | user | Запросить активацию `{ comment? }` (напр. «я Никита») | | POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
`GET /api/configs` для неактивированного пользователя вернёт пустой список; `POST /api/configs`
до активации → `403` (или `409` с кодом `NotActivated`).
## Admin — Activation, Roles ## Admin — Activation, Roles
| Метод | Путь | Роль | Описание | | Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | ----------------------------------------------- | ----- | --------------------------------------------------- | | ------ | ----------------------------------------------- | ----- | ----------------------- | ------------- |
| GET | `/api/admin/activation-requests` | admin | Список запросов активации (фильтр по статусу) | | GET | `/api/admin/activation-requests` | admin | query: `statusFilter?, page=1, pageSize=20` | `PagedList<ActivationRequestAdminDto>` |
| POST | `/api/admin/activation-requests/{id}/approve` | admin | Одобрить → пользователь активирован | | POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
| POST | `/api/admin/activation-requests/{id}/reject` | admin | Отклонить `{ reason? }` | | POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
| GET | `/api/admin/roles` | admin | Список ролей с квотами | | GET | `/api/admin/roles` | admin | | `RoleDto[]` |
| POST | `/api/admin/roles` | admin | Создать роль `{ name, maxConfigs }` | | POST | `/api/admin/roles` | admin | `{ name, maxConfigs }` | `RoleDto` |
| PUT | `/api/admin/roles/{id}` | admin | Изменить роль (напр. `maxConfigs`) | | PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs }` | `RoleDto` |
| DELETE | `/api/admin/roles/{id}` | admin | Удалить роль (нельзя системные `admin`/`user`) | | DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
| PATCH | `/api/admin/users/{id}/role` | admin | Сменить роль пользователю `{ roleId }` (ровно одна) | | PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` |
| PATCH | `/api/admin/users/{id}/activation` | admin | Активировать/деактивировать напрямую `{ isActivated }`|
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
approve/reject над `ActivationRequest`.
## Admin — Nodes ## Admin — Nodes
| Метод | Путь | Роль | Описание | | Метод | Путь | Роль | Тело запроса | Тело ответа |
| ------ | ----------------------------- | ----- | ----------------------------------------- | | ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/admin/nodes` | admin | Список нод + статусы | | GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
| POST | `/api/admin/nodes` | admin | Подключить ноду `{ name, baseAddress, username, password, location }` | | POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `NodeDto` |
| PUT | `/api/admin/nodes/{id}` | admin | Изменить ноду (в т.ч. `isEnabled`) | | PUT | `/api/admin/nodes/{id}` | admin | `{ name, location?, isEnabled, username?, password? }` | `NodeDto` |
| DELETE | `/api/admin/nodes/{id}` | admin | Удалить ноду | | DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` |
| POST | `/api/admin/nodes/{id}/sync` | admin | Пересинхронизировать inbounds с 3x-ui | | POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
| POST | `/api/admin/nodes/{id}/probe` | admin | Проверить доступность | | POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, status }` |
`DELETE /api/admin/nodes/{id}` удаляет её инбаунды каскадно **без проверки существующих конфигов**
на них — известный пробел (см. [tech-stack.md](tech-stack.md)), а не осознанная защита.
`username`/`password` в `PUT` — оба опциональны; креденшлы меняются, только если заданы **оба**.
## Admin — Inbounds ## Admin — Inbounds
| Метод | Путь | Роль | Описание | | Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | -------------------------------------- | ----- | ---------------------------------------- | | ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/admin/inbounds` | admin | Список inbounds (по нодам) + `allowedRoleIds`, `displayName` | | GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` |
| PUT | `/api/admin/inbounds/{id}/publish` | admin | Опубликовать/снять `{ isPublished, displayName?, allowedRoleIds[], maxClients? }` | | PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds?, maxClients? }` | `InboundDto` |
## Admin — Users & Stats ## Admin — Users & Stats
| Метод | Путь | Роль | Описание | | Метод | Путь | Роль | Тело запроса | Тело ответа |
| ----- | --------------------------------- | ----- | ----------------------------------------- | | ------ | ---------------------------------------- | ----- | --------------------------- | ------------- |
| GET | `/api/admin/users` | admin | Пользователи (пагинация, поиск) | | GET | `/api/admin/users` | admin | query: `page, pageSize, search?` | `PagedList<UserSummaryDto>` |
| PATCH | `/api/admin/users/{id}/block` | admin | Блокировать/разблокировать `{ isBlocked }` (при блоке — отключить конфиги в 3x-ui) | | PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` |
| POST | `/api/admin/users/{id}/reset-password` | admin | Сбросить пароль пользователю без привязки Telegram (выдать временный/задать новый) | | PATCH | `/api/admin/users/{id}/unblock` | admin | — | `204 No Content` |
| GET | `/api/admin/users/{id}/configs` | admin | Конфиги пользователя | | POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` |
| DELETE| `/api/admin/configs/{id}` | admin | Принудительно отозвать любой конфиг | | GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` |
| GET | `/api/admin/stats` | admin | Сводная статистика (пользователи, конфиги, трафик) | | DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) |
| GET | `/api/admin/audit` | admin | Журнал действий (`AuditLog`, пагинация, фильтры) | | GET | `/api/admin/stats` | admin | — | `StatsDto` |
| GET | `/api/admin/audit` | admin | query: `page, pageSize` | `PagedList<AuditLogDto>` |
**Блокировка/разблокировка — два отдельных эндпоинта без тела**, не один переключатель `isBlocked`.
`StatsDto`: `{ totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes,
totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — считается на лету при запросе,
не кэшируется.
## Public — Subscription ## Public — Subscription
| Метод | Путь | Роль | Описание | | Метод | Путь | Роль | Ответ |
| ----- | ----------------- | ---- | ---------------------------------------------------------------- | | ----- | ----------------- | ---- | ---------------------------------------------------------------- |
| GET | `/sub/{token}` | — | Подписка (base64-список ссылок). Токен — либо `AppUser.SubscriptionToken` (**все активные конфиги юзера**), либо `VpnConfig.SubscriptionToken` (**один конфиг**). Без `/api`. | | GET | `/sub/{token}` | — | `text/plain`, base64 от списка connection strings, `\n`-разделены |
Rate-limited; отключённые/отозванные конфиги в выдачу не попадают; неизвестный/погашенный токен → 404. Вне `/api` (публичный эндпоинт для VPN-клиентов), под тем же rate-limit'ом, что и `/api/auth/*`.
Ответ отдаёт заголовок **`Subscription-Userinfo`** (`upload`/`download`/`total`/`expire`) — клиенты Токен — либо `AppUser.SubscriptionToken` (**все активные конфиги юзера**), либо
(v2rayN/Nekoray и т.п.) показывают остаток трафика/срок. Также `profile-update-interval`. `VpnConfig.SubscriptionToken` (**один конфиг**); пробуются по очереди, первый успешный — в ответе.
Неизвестный/погашенный токен → `404`. Заголовки ответа:
`Subscription-Userinfo: upload=<up>; download=<down>; total=<up+down>; expire=<unix|0>` и
`Profile-Update-Interval: 12` — их читают клиенты (v2rayN/Nekoray и т.п.), чтобы показать остаток.
## SignalR — Hub `/hubs/panel` ## SignalR — Hub `/hubs/panel`
Авторизация — тем же JWT (query `access_token` или заголовок). Группы: `user:{userId}`, `admins`. Авторизация — тем же JWT. Группы: `user:{userId}` (личные события), `admins` (админам).
### Server → Client ### Server → Client
| Событие | Payload | Кому | | Событие | Payload | Кому |
| ---------------------- | ------------------------------------------------------------- | ------------ | | ---------------------- | ------------------------------------------------------------- | ------------ |
| `configTrafficUpdated` | `{ configId, usedUpBytes, usedDownBytes, limitBytes }` | владельцу | | `configTrafficUpdated` | `{ configId, usedUpBytes, usedDownBytes }` | владельцу |
| `configStatusChanged` | `{ configId, status }` | владельцу | | `configStatusChanged` | `{ configId, status }` | владельцу |
| `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` | | `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` |
| `activationRequested` | `{ requestId, userId, username, comment, createdAt }` | `admins` | | `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` |
| `userActivated` | `{ userId }` | владельцу | | `userActivated` | `{ userId }` | владельцу |
### Client → Server ### Client → Server
MVP — клиент только слушает (группировка по пользователю на сервере при подключении по `UserId` из JWT). Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
`UserId` из JWT (плюс `admins`, если роль админская).
## Коды ошибок ## Коды ошибок
| Код | Когда | | Код | Когда |
| --- | -------------------------------------------------- | | --- | -------------------------------------------------------------------- |
| 400 | Ошибка валидации (`errors` в ProblemDetails) | | 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
| 401 | Нет/просрочен токен | | 401 | Нет/просрочен/невалиден access-токен |
| 403 | Нет прав (роль/владение/не активирован/роль без доступа к инбаунду) | | 403 | Нет прав по роли, либо `Configs.NotActivated` |
| 404 | Ресурс не найден | | 404 | Ресурс не найден |
| 409 | Конфликт домена (превышена квота роли, дубликат, уже есть Pending-запрос активации) | | 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации |
| 422 | Нарушение инварианта домена | | 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
| 429 | Rate limit | | 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
| 502 | Ошибка/недоступность ноды 3x-ui (при необходимости)| | 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
`502`/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится `Result.Failure` и
маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.
+147 -80
View File
@@ -25,10 +25,11 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
│ implements ports │ uses │ implements ports │ uses
┌───────────────▼───────────────┐ ┌────────────▼──────────────────────────┐ ┌───────────────▼───────────────┐ ┌────────────▼──────────────────────────┐
│ PnvPanel.Infrastructure │ │ PnvPanel.Domain │ │ PnvPanel.Infrastructure │ │ PnvPanel.Domain │
│ EF Core (Npgsql) · Identity · │ │ Entities · Value Objects · Domain │ EF Core (Npgsql) · Identity · │ │ Entities · Value Object · Enums ·
│ JWT · XuiPanelGateway · │◄──┤ Events · Enums · Domain Exceptions │ │ JWT · XuiPanelGateway · │◄──┤ Domain Exceptions
│ Background sync · SignalR push│ │ (no external dependencies) │ │ Background sync · Telegram │ │ (no external dependencies) │
└───────────────┬────────────────┘ └───────────────────────────────────────┘ └───────────────┬────────────────┘ └───────────────────────────────────────┘
(SignalR-хаб/пуш — в PnvPanel.Api, см. ниже)
┌───────────▼──────────┐ ┌──────────────────────────┐ ┌───────────▼──────────┐ ┌──────────────────────────┐
│ PostgreSQL │ │ 3x-ui panels (nodes) │ │ PostgreSQL │ │ 3x-ui panels (nodes) │
@@ -43,51 +44,83 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
`Application`, реализуемые в `Infrastructure`. `Application`, реализуемые в `Infrastructure`.
### 1. `PnvPanel.Domain` ### 1. `PnvPanel.Domain`
Ядро без внешних зависимостей (маркерный интерфейс доменных событий `IDomainEvent` — свой, в `Domain/Common`). Ядро без внешних зависимостей. Никакого диспетчера доменных событий нет — это сознательное упрощение
относительно исходного плана, см. ниже.
- **Entities**: `Node`, `Inbound`, `VpnConfig`, `Plan` (см. [domain-model.md](domain-model.md)). - **Entities**: `Node`, `Inbound`, `VpnConfig`, `ActivationRequest`, `ClientApp`, `AuditLog`,
- **Value Objects**: `TrafficLimit`, `NodeCredentials`, `ConnectionLink` и т.п. `TelegramLinkToken`, `TelegramLoginRequest`, `TrafficSample` (см. [domain-model.md](domain-model.md)).
- **Enums**: `VpnProtocol`, `ConfigStatus`, `NodeStatus`. - **Value Objects**: `NodeCredentials` (логин + зашифрованный пароль ноды). Это единственный VO —
- **Domain Events**: `VpnConfigCreated`, `VpnConfigRevoked`, `TrafficLimitReached`, `NodeWentOffline`. `TrafficLimit`/`ConnectionLink` из раннего плана не понадобились (лимиты трафика — backlog,
- **Domain Exceptions**: `DomainException` и специализированные (`ConfigQuotaExceededException`). connection string строит `IXuiPanelGateway` на лету).
- Инварианты и бизнес-правила инкапсулированы в сущностях (rich domain model), а не в хендлерах. - **Enums**: `VpnProtocol`, `ConfigStatus`, `NodeStatus`, `ActivationStatus`, `AuditSource`,
`TelegramLoginStatus`, `OsPlatform`.
- **Domain Exceptions**: `DomainException` — брошенный при нарушении инварианта в самой сущности
(например, `Revoke()` уже отозванного конфига); хендлеры такие нарушения не ожидают в норме.
- Инварианты и бизнес-правила инкапсулированы в сущностях (rich domain model: приватные сеттеры,
фабричные методы, поведенческие методы), а не в хендлерах.
> `AppUser` (Identity) живёт в `Infrastructure` (зависит от `IdentityUser`), а домен ссылается > `AppUser`/`AppRole` (Identity) живут в `Infrastructure` (наследуют `IdentityUser<Guid>`/
> на пользователя по `UserId` (Guid), чтобы не тащить Identity в ядро. > `IdentityRole<Guid>`), а домен ссылается на пользователя/роль только по `Guid`, чтобы не тащить
> Identity в ядро.
### 2. `PnvPanel.Application` ### 2. `PnvPanel.Application`
Сценарии приложения через CQRS. Сценарии приложения через CQRS.
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы). - **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы),
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IJwtTokenService`, организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
`ISecretProtector`, `IRealtimeNotifier`, `IDateTime`. - **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
- **Validators**: FluentValidation на каждую команду/запрос. `ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`.
- **DTOs** и профили маппинга (Mapster). - **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
- **Pipeline behaviors**: `ValidationBehavior`, `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция на команду), `AuthorizationBehavior`. [backend-conventions.md](backend-conventions.md)).
- **Result<T>**: явная модель успеха/ошибки вместо исключений для управляемых сценариев. - **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
DTO. Mapster из исходного плана не пригодился — при таком числе полей ручной маппинг читается
не хуже конфига маппера и не создаёт лишней зависимости.
- **Pipeline behaviors**: `ValidationBehavior`, `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция +
`SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` нет — авторизация (роль,
активация) — это либо `RequireAuthorization()`/`RequireRole(...)` на эндпоинте, либо явная проверка
в начале хендлера (например, «инбаунд доступен роли пользователя»).
- **Result<T>**: явная модель успеха/ошибки (`Result`/`Result<T>`, `Error` с `ErrorType`) вместо
исключений для управляемых сценариев.
### 3. `PnvPanel.Infrastructure` ### 3. `PnvPanel.Infrastructure`
Технические детали и реализации портов. Технические детали и реализации портов.
- **Persistence**: `AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>`, реализует `IAppDbContext`; - **Persistence**: `AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>`, реализует `IAppDbContext`;
`IEntityTypeConfiguration<T>` для маппингов; миграции EF Core; репозитории только там, где нужны `IEntityTypeConfiguration<T>` для маппингов; миграции EF Core. Репозиториев нет — хендлеры работают
(в основном хендлеры работают через `IAppDbContext` напрямую). через `IAppDbContext` напрямую (`DbSet<T>` + LINQ).
- **Identity & Auth**: ASP.NET Core Identity, `JwtTokenService` (access + refresh), хранение refresh-токенов. - **Identity & Auth**: ASP.NET Core Identity, `JwtTokenService` (access + refresh), `RefreshTokenService`
- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; фабрика клиентов per-node (см. ниже). (хранение/ротация/отзыв refresh-токенов), `RoleService`, `DbInitializer` (сидинг).
- **Realtime**: `SignalRRealtimeNotifier : IRealtimeNotifier` (пуш в хабы). - **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; кэш клиентов per-node
- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService` (`BackgroundService` + `PeriodicTimer`). внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет).
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest). - **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`.
> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.**
> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext<PanelHub>`, а сам `PanelHub`
> определён там же — не было смысла тащить эту связку через слой. `Application` всё равно видит
> только порт `IRealtimeNotifier`, так что граница зависимостей не нарушена.
### 4. `PnvPanel.Api` (Presentation) ### 4. `PnvPanel.Api` (Presentation)
Композиционный корень и транспорт. Композиционный корень и транспорт.
- **Minimal API** эндпоинты, сгруппированные по фичам (`MapAuthEndpoints`, `MapConfigEndpoints`, `MapAdminEndpoints`). - **Minimal API**-эндпоинты, сгруппированные по фичам — 12 файлов в `Endpoints/`
- **SignalR Hubs**: `PanelHub`. (`AuthEndpoints`, `ActivationEndpoints`, `ConfigEndpoints`, `AppEndpoints`, `SubscriptionEndpoints`,
- **Telegram-бот**: `TelegramBotHostedService` + хендлеры апдейтов в `Telegram/` (см. отдельный раздел). `TelegramEndpoints`, `AdminUserEndpoints`, `AdminAppEndpoints`, `AdminStatsEndpoints`, `NodeEndpoints`,
`InboundEndpoints`, `RoleEndpoints`); полный список маршрутов — [api-design.md](api-design.md).
Каждый эндпоинт аннотирован `.Produces<T>()`, чтобы OpenAPI-схема полностью описывала тело ответа
(нужно для `pnpm gen:api` на фронте).
- **SignalR Hubs**: `PanelHub` (`Hubs/`).
- **Telegram-бот**: `TelegramBotHostedService` + `PnvBotUpdateHandler` в `Telegram/` (см. отдельный раздел).
- **Статика SPA**: раздача собранного фронта из `wwwroot` + SPA-fallback (единый контейнер). - **Статика SPA**: раздача собранного фронта из `wwwroot` + SPA-fallback (единый контейнер).
- **Middleware**: обработка исключений → ProblemDetails, корреляция запросов, rate limiting. - **Ошибки**: встроенные `AddProblemDetails()` + `UseExceptionHandler()` (ASP.NET Core, без кастомного
- **DI**: `AddApplication()`, `AddInfrastructure()`, `AddApiServices()` — сборка всех слоёв. middleware) конвертируют необработанные исключения в `application/problem+json`.
- **OpenAPI**: Swashbuckle + Scalar UI; генерация схемы для codegen фронта. - **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 ## CQRS
@@ -97,36 +130,44 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
через pipeline behaviors. Без внешних CQRS-библиотек (MediatR/и т.п.). Абстракции — `ICommand<T>`, через pipeline behaviors. Без внешних CQRS-библиотек (MediatR/и т.п.). Абстракции — `ICommand<T>`,
`IQuery<T>`, `ICommandHandler<,>`, `IQueryHandler<,>`, `IPipelineBehavior<,>`. `IQuery<T>`, `ICommandHandler<,>`, `IQueryHandler<,>`, `IPipelineBehavior<,>`.
Пример потока «создать конфиг»: Пример потока «создать конфиг» (`backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs`):
``` ```
POST /api/configs POST /api/configs
→ CreateVpnConfigCommand → CreateVpnConfigCommand
→ ValidationBehavior (FluentValidation) → ValidationBehavior (FluentValidation — формат inboundId/label/deviceLimit)
AuthorizationBehavior (роль/владение) CreateVpnConfigCommandHandler
→ CreateVpnConfigHandler · проверяет активацию + роль инбаунда (доменные проверки)
· проверяет квоту пользователя (домен) · SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
· IXuiPanelGateway.AddClientAsync(node, inbound, spec) // 3x-ui · пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs
· создаёт VpnConfig, сохраняет через IAppDbContext · IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId
· публикует VpnConfigCreated (domain event) · VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext
→ UnitOfWorkBehavior (commit) · при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync)
→ 201 Created { id, link, subscriptionUrl } → UnitOfWorkBehavior (commit транзакции)
→ 200 OK VpnConfigDto { id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes,
expiresAt, status, createdAt }
``` ```
Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно,
`GET /api/configs/{id}/link`, по кнопке на карточке конфига (см. [api-design.md](api-design.md)).
## Интеграция с 3x-ui (ThreeXui.Net) ## Интеграция с 3x-ui (ThreeXui.Net)
`ThreeXui.Net` конфигурируется на **один** `BaseAddress`, а у нас **несколько нод**. Поэтому: `ThreeXui.Net` конфигурируется на **один** `BaseAddress`, а у нас **несколько нод**. Поэтому:
- Порт `IXuiPanelGateway` инкапсулирует все операции с панелями и принимает `Node` (или его id): - Порт `IXuiPanelGateway` инкапсулирует все операции с панелями и принимает `Node`:
`ListInboundsAsync`, `AddClientAsync`, `UpdateClientAsync`, `RemoveClientAsync`, `ListInboundsAsync`, `AddClientAsync`, `UpdateClientAsync`, `RemoveClientAsync`,
`GetClientTrafficAsync`, `BuildConnectionStringAsync`, `ProbeAsync`. `GetClientTrafficAsync`, `BuildConnectionStringAsync`, `ProbeAsync`, `ValidateBaseAddress`,
- `XuiPanelGateway` держит **фабрику/кэш `IXuiClient` per-node** (ключ — `NodeId`), создавая клиента `InvalidateClient(nodeId)` (вызывается после смены креденшлов ноды).
из расшифрованных `NodeCredentials` через `XuiHttpClientFactory`/`HttpClient`. Cookie-session и - `XuiPanelGateway` — единственная реализация, держит `ConcurrentDictionary<Guid, Lazy<IXuiClient>>`
авто-переавторизация на 401 обеспечиваются самой библиотекой. (ключ — `NodeId`), создавая клиента из расшифрованных `NodeCredentials` лениво при первом обращении
к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`.
- Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу. - Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу.
- Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды). - Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды).
- **Реконсиляция дрейфа**: 3x-ui — источник правды по клиентам. При синхронизации сверяем проекцию - **Дрейф с 3x-ui в MVP не реконсилируется активно**: `TrafficSyncService` при недоступной ноде или
с панелью: клиент удалён/изменён напрямую в 3x-ui → помечаем конфиг рассинхронизованным при отсутствии клиента в ответе панели (`GetClientTrafficAsync`) просто пропускает его в этом цикле
(`Disabled`/флаг) и логируем; не «воскрешаем» молча. Наши записи о трафике/статусах обновляем из панели. синхронизации — не помечает конфиг рассинхронизованным и не шлёт алерт. Если клиента удалили прямо
в 3x-ui в обход панели, локальная запись `VpnConfig` продолжит существовать до следующего
явного действия пользователя/админа (`Revoke`/`Rotate`), которое обнаружит несоответствие по ответу
гейтвея. Активная сверка/алертинг по дрейфу — задел на будущее, не реализовано.
## Telegram-бот (presentation-адаптер) ## Telegram-бот (presentation-адаптер)
@@ -134,7 +175,9 @@ POST /api/configs
бизнес-правил). Полное описание — в [telegram-bot.md](telegram-bot.md). Ключевое для архитектуры: бизнес-правил). Полное описание — в [telegram-bot.md](telegram-bot.md). Ключевое для архитектуры:
- Хостится **в процессе Api** как `BackgroundService` (`TelegramBotHostedService`) — это условие - Хостится **в процессе Api** как `BackgroundService` (`TelegramBotHostedService`) — это условие
для упаковки «фронт+бек в одном контейнере». Транспорт — **long polling** (MVP), webhook — опция. для упаковки «фронт+бек в одном контейнере». Транспорт — только **long polling**
(`ITelegramBotClient.ReceiveAsync`); webhook рассматривался, но не реализован — конфигурации
`Telegram:Mode`/`WebhookUrl` в коде нет.
- Обращения к домену — только через собственный `ISender`, теми же командами/запросами, что и веб - Обращения к домену — только через собственный `ISender`, теми же командами/запросами, что и веб
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). `Telegram.Bot` (`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). `Telegram.Bot`
не проникает в Application/Domain. не проникает в Application/Domain.
@@ -144,21 +187,25 @@ POST /api/configs
## Realtime (SignalR) ## Realtime (SignalR)
- Хаб `PanelHub` (`/hubs/panel`), авторизация по тому же JWT. - Хаб `PanelHub` (`/hubs/panel`), авторизация по тому же JWT.
- **Группы**: `user:{userId}` (личные события), `admins` (события нод/системы). - **Группы** (`GroupNames` в `Api/Hubs/PanelHub.cs`): `user:{userId}` (личные события), `admins`
- **События сервер→клиент** (см. [api-design.md](api-design.md)): `configTrafficUpdated`, (события нод/системы/активации) — пользователь при подключении добавляется в свою `user:{userId}`
`configStatusChanged`, `nodeStatusChanged`. и, если он админ, дополнительно в `admins`.
- Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`), вызываемый из хендлеров и - **События сервер→клиент**: `configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`,
фоновых сервисов — Application-слой не зависит от SignalR напрямую. `activationRequested`, `userActivated` — точные payload'ы см. [api-design.md](api-design.md#signalr--hub-hubspanel).
- Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`, реализация в `Api/Hubs/`),
вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую.
## Фоновые задачи ## Фоновые задачи
- **TrafficSyncService** — периодически (`PeriodicTimer`) обходит активные ноды, тянет трафик по - **TrafficSyncService** — периодически (`PeriodicTimer`) обходит активные ноды, тянет трафик по
клиентам, обновляет `VpnConfig`, пишет `TrafficSample` (для графиков), шлёт realtime-события, клиентам через `IXuiPanelGateway.GetClientTrafficAsync`, пишет `VpnConfig.UpdateTraffic(...)` и
помечает превышения (`TrafficLimitReached`). `TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты
- **NodeHealthCheckService** — health-probe нод, обновляет `NodeStatus`, оповещает `admins`. и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)).
- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`,
шлёт `nodeStatusChanged` группе `admins`.
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика). - **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
- Для MVP — встроенный `BackgroundService`; при росте нагрузки — вынести в Hangfire/Quartz - Реализованы как обычные `BackgroundService` + `PeriodicTimer`, без внешнего джоб-раннера — для
(см. [tech-stack.md](tech-stack.md)). нагрузки MVP этого достаточно (см. [tech-stack.md](tech-stack.md)).
## Сидирование и старт ## Сидирование и старт
@@ -167,15 +214,19 @@ POST /api/configs
- **Системные роли**: `admin` (без лимита конфигов) и `user` (`MaxConfigs = Roles__DefaultUserMaxConfigs`, по умолчанию 3). - **Системные роли**: `admin` (без лимита конфигов) и `user` (`MaxConfigs = Roles__DefaultUserMaxConfigs`, по умолчанию 3).
- **Учётка администратора**: создаётся из `AdminSeed__Username` / `AdminSeed__Password`, если ещё нет; - **Учётка администратора**: создаётся из `AdminSeed__Username` / `AdminSeed__Password`, если ещё нет;
сразу активирована и с ролью `admin`. сразу активирована и с ролью `admin`. Seed-админ **не привязывается к Telegram автоматически**
- **Telegram id админов** (`AdminSeed__TelegramUserIds`) — авторизуют админ-действия в боте и привязка делается вручную в UI, как у любого пользователя.
адресуют уведомления (например, запросы на активацию).
- **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из - **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из
[`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD. [`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD.
Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе
**нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении. **нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении.
Отдельно от сидинга — **Telegram id админов** (`Telegram__AdminTelegramUserIds`, через запятую)
читаются `TelegramOptions` **напрямую при каждой проверке** (не пишутся в БД): именно этот список
авторизует нажатие «Активировать/Отклонить» в боте и определяет, кому слать уведомления о новых
запросах активации.
## RBAC — динамические роли и активация ## RBAC — динамические роли и активация
- `AppRole` расширяет `IdentityRole<Guid>` полем `MaxConfigs`. **У пользователя ровно одна роль**; - `AppRole` расширяет `IdentityRole<Guid>` полем `MaxConfigs`. **У пользователя ровно одна роль**;
@@ -189,34 +240,50 @@ POST /api/configs
сохраняются, создание новых блокируется до входа в квоту. сохраняются, создание новых блокируется до входа в квоту.
- **Блокировка пользователя**: `IsBlocked = true` → вход запрещён + все конфиги `Disabled` (отключение - **Блокировка пользователя**: `IsBlocked = true` → вход запрещён + все конфиги `Disabled` (отключение
клиентов в 3x-ui); разблокировка — обратная операция. Пишется в `AuditLog`. клиентов в 3x-ui); разблокировка — обратная операция. Пишется в `AuditLog`.
- Уведомления: запросы активации → группа `admins` (SignalR) + Telegram (по `AdminSeed__TelegramUserIds`); - Уведомления: запросы активации → группа `admins` (SignalR) + Telegram (по
решения/блокировки → пользователю (SignalR + Telegram-DM, если привязан). `Telegram__AdminTelegramUserIds`); решения/блокировки → пользователю (SignalR + Telegram-DM, если привязан).
## Безопасность ## Безопасность
- **AuthN**: ASP.NET Core Identity + JWT, **вход по `UserName`** (email в системе не используется). - **AuthN**: ASP.NET Core Identity + JWT, **вход по `UserName`** (email в системе не используется).
Access-token — короткий TTL (in-memory на клиенте); refresh-token — httpOnly Secure cookie, Access-token — короткий TTL (in-memory на клиенте); refresh-token — httpOnly Secure cookie,
ротация при использовании, хранение хэша в БД. ротация при использовании, хранение хэша в БД.
- **Восстановление пароля**: через привязанный Telegram (passwordless-вход → смена пароля, либо - **Восстановление пароля**: через привязанный Telegram (passwordless-вход → смена пароля); без
reset-флоу в боте); без привязки — сброс админом (`ResetUserPasswordCommand`). Email/SMTP не используются. привязки — сброс админом (`ResetUserPasswordCommand`). Email/SMTP не используются. Смена пароля
Смена пароля вошедшим — `POST /api/auth/change-password`. вошедшим — `POST /api/auth/change-password`.
- **AuthZ**: роли (`admin`/`user`/кастомные) + policy-based (`OwnsConfig`, `RequireAdmin`, - **AuthZ**: именованных policy нет — либо `.RequireAuthorization()` (любой вошедший) или
`RequireActivated`). `.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))` на группе эндпоинтов, либо
явная проверка внутри хендлера (владение конфигом — сравнение `VpnConfig.UserId` с `ICurrentUser`;
активация — `ConfigErrors.NotActivated`).
- **Секреты нод**: шифруются `ISecretProtector` (Data Protection) перед сохранением; в API/логи не попадают. - **Секреты нод**: шифруются `ISecretProtector` (Data Protection) перед сохранением; в API/логи не попадают.
- **CSRF**: refresh-cookie — `SameSite=Strict/Lax`, `Secure`, `HttpOnly`; для cookie-based refresh — - **CSRF**: явного анти-CSRF токена нет — все мутации API читают авторизацию только из
анти-CSRF токен. Мутации — только по Bearer access-токену, не по cookie. `Authorization: Bearer` (JS должен явно прочитать access-token из памяти и подставить заголовок,
- **Brute-force**: Identity lockout по числу неудачных входов; rate-limit на `/auth/*`. чужой сайт этого сделать не может). Refresh-cookie (`pnv_refresh_token`) — единственное, что браузер
- **Rate limiting**: на `/auth/*`, создание/ротацию конфигов, запросы активации и Telegram (встроенный `RateLimiter` .NET). шлёт автоматически; она `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`. - **Валидация входа**: FluentValidation + жёсткая типизация DTO; ошибки — единый `ProblemDetails`.
- **CORS**: в проде фронт и бек один origin (CORS не нужен); в dev — строгий allowlist (`App__CorsOrigins`). - **CORS**: не настроен вообще (`AddCors`/`UseCors` в коде нет) — фронт и бек всегда один origin: в
проде раздаются из одного образа, в dev Vite проксирует `/api`/`/hubs`, так что браузер никогда не
делает кросс-origin запрос. Отдельного allowlist-конфига для CORS сейчас не существует.
- **Аудит**: значимые действия (активация, блокировка, смена роли, отзыв, ноды/инбаунды) пишутся в - **Аудит**: значимые действия (активация, блокировка, смена роли, отзыв, ноды/инбаунды) пишутся в
`AuditLog` (append-only) с источником `Web`/`Telegram`/`System`. `AuditLog` (append-only) с источником `Web`/`Telegram`/`System`.
## Обработка ошибок ## Обработка ошибок
- Управляемые ошибки → `Result`/`Result<T>` → маппинг в HTTP-статус + `ProblemDetails`. - Управляемые ошибки → `Result`/`Result<T>` (`ResultExtensions.ToHttpResult`) → маппинг `ErrorType` в
- Непредвиденные исключения → глобальный middleware → 500 + корреляция + структурный лог (без утечки деталей). HTTP-статус + `application/problem+json` (400/401/403/404/409/422).
- Доменные исключения (нарушение инвариантов) → 409/422 с понятным сообщением. - Непредвиденные исключения → встроенные `AddProblemDetails()` + `UseExceptionHandler()` → 500 без
утечки деталей + `Serilog` request-логирование (`UseSerilogRequestLogging`, обогащение —
`Enrich.FromLogContext()`). Сквозного `CorrelationId`/явного обогащения `UserId`/`NodeId`/`ConfigId`
в логах пока нет — задел на будущее, а не то, на что стоит полагаться при расследовании инцидентов сегодня.
- Доменные исключения (нарушение инвариантов) — `DomainException`, ожидаются только как баг, а не
штатный путь (штатные отказы — через `Result.Failure`, не исключения).
## Развёртывание (единый контейнер приложения) ## Развёртывание (единый контейнер приложения)
@@ -238,7 +305,7 @@ PostgreSQL:
- **Миграции**: применяются **автоматически на старте** приложения (в MVP; при масштабировании на - **Миграции**: применяются **автоматически на старте** приложения (в MVP; при масштабировании на
несколько инстансов — вынести в отдельный шаг/джобу). несколько инстансов — вынести в отдельный шаг/джобу).
- Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения, - Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения,
JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`, `PublicSiteUrl`). JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`).
``` ```
[ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose [ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose
+109 -57
View File
@@ -2,52 +2,74 @@
## Структура решения ## Структура решения
Solution-файл — **`PnvPanel.slnx`** (новый XML-формат dotnet CLI, не классический `.sln`).
``` ```
backend/ backend/
PnvPanel.sln PnvPanel.slnx
src/ src/
PnvPanel.Domain/ PnvPanel.Domain/
Common/ # Entity, AggregateRoot, IDomainEvent, ValueObject base Common/ # Entity (единственный базовый класс — без AggregateRoot/IDomainEvent)
Nodes/ # Node, NodeCredentials, NodeStatus, события Activation/ # ActivationRequest, ActivationStatus
Apps/ # ClientApp, OsPlatform
Audit/ # AuditLog, AuditSource
Configs/ # VpnConfig, ConfigStatus, TrafficSample
Inbounds/ # Inbound, VpnProtocol Inbounds/ # Inbound, VpnProtocol
Configs/ # VpnConfig, ConfigStatus, TrafficLimit, события Nodes/ # Node, NodeCredentials (VO), NodeStatus
Plans/ # Plan Telegram/ # TelegramLinkToken, TelegramLoginRequest, TelegramLoginStatus
Exceptions/ # DomainException и наследники Exceptions/ # DomainException
PnvPanel.Application/ PnvPanel.Application/
Common/ Common/
Behaviors/ # Validation, Logging, UnitOfWork, Authorization Behaviors/ # ValidationBehavior, LoggingBehavior, UnitOfWorkBehavior (нет Authorization-поведения)
Interfaces/ # IAppDbContext, IXuiPanelGateway, ICurrentUser, IRealtimeNotifier, ... Interfaces/ # IAppDbContext, IXuiPanelGateway, ICurrentUser, IIdentityService,
# ISecretProtector, IRealtimeNotifier, ITelegramNotifier, IRoleService
Messaging/ # ISender, ICommand<T>, IQuery<T>, ICommandHandler<,>, IQueryHandler<,>, IPipelineBehavior<,> Messaging/ # ISender, ICommand<T>, IQuery<T>, ICommandHandler<,>, IQueryHandler<,>, IPipelineBehavior<,>
Models/ # Result<T>, Error, PagedList<T> Models/ # Result, Result<T>, Error, PagedList<T>, RoleQuota
Mapping/ # Mapster-конфиги Activation/ # RequestActivationCommand, GetActivationStatusQuery (пользовательские)
Auth/ # Register/Login/Refresh (Commands, Handlers, Validators, DTOs) Admin/
Configs/ # CreateVpnConfig, EditVpnConfig, RotateVpnConfig, RevokeVpnConfig, GetMyConfigs, GetConfigLink, GetSubscription, ... Activation/ # ListActivationRequestsQuery, Approve/RejectActivationCommand
Nodes/ # RegisterNode, SyncNode, ListNodes, ... Apps/ # CRUD ClientApp
Inbounds/ # PublishInbound, ListInbounds, ... Audit/ # ListAuditLogsQuery
Apps/ # (admin) CRUD каталога ClientApp; GetApps (по ОС) для юзера Inbounds/ # ListInbounds, PublishInbound
Admin/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, GetStats, Audit, ... Nodes/ # RegisterNode, UpdateNode, DeleteNode, SyncNode, ProbeNode, ListNodes
Roles/ # ListRoles, CreateRole, UpdateRole, DeleteRole
Stats/ # GetStatsQuery
Users/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, ResetUserPassword,
# ForceRevokeConfig, GetUserConfigs
Apps/ # ListAppsQuery (по ОС, для юзера)
Auth/
ChangePassword/, DeleteMyAccount/, Login/, Logout/, Me/, Refresh/, Register/
Configs/
Create/, Edit/, Rotate/, Revoke/, GetMyConfigs/, GetConfigLink/, GetMySubscription/,
ListAvailableInbounds/
Subscriptions/ # SubscriptionDto, GetUserSubscriptionQuery, GetConfigSubscriptionQuery
Telegram/ # LinkTelegramCommand, CreateLinkTokenCommand, CreateLoginRequestCommand,
# Approve/RejectTelegramLoginCommand, GetLoginRequestStatusQuery, ...
# Telegram/Bot/ — контракт для бота (не сам Telegram.Bot)
PnvPanel.Infrastructure/ PnvPanel.Infrastructure/
Persistence/ Persistence/
AppDbContext.cs AppDbContext.cs # : IdentityDbContext<AppUser, AppRole, Guid>, IAppDbContext
Configurations/ # IEntityTypeConfiguration<T> Configurations/ # IEntityTypeConfiguration<T>
Migrations/ Migrations/
Identity/ # AppUser, AppRole, JwtTokenService, RefreshToken Identity/ # AppUser, AppRole, JwtTokenService, RefreshTokenService, RoleService,
Xui/ # XuiPanelGateway, XuiClientFactory (per-node) # DbInitializer (сидинг), IdentityService, CurrentUser
Realtime/ # SignalRRealtimeNotifier Xui/ # XuiPanelGateway (единственный файл — кэш клиентов per-node внутри него)
BackgroundJobs/ # TrafficSyncService, NodeHealthCheckService BackgroundJobs/ # TrafficSyncService, NodeHealthCheckService, TrafficRetentionService
Security/ # DataProtectionSecretProtector Security/ # DataProtectionSecretProtector
DependencyInjection.cs Telegram/ # TelegramNotifier, TelegramOptions
DependencyInjection.cs # AddInfrastructure(...)
PnvPanel.Api/ PnvPanel.Api/
Endpoints/ # AuthEndpoints, ConfigEndpoints, NodeEndpoints, AdminEndpoints, SubscriptionEndpoints Endpoints/ # 12 файлов, см. backend-conventions.md ниже и api-design.md
Hubs/ # PanelHub Hubs/ # PanelHub, SignalRRealtimeNotifier (реализация IRealtimeNotifier — здесь,
Middleware/ # ExceptionHandling, RequestCorrelation # не в Infrastructure, т.к. нужен IHubContext<PanelHub>)
Extensions/ # AddApiServices, UseApiPipeline Telegram/ # TelegramBotHostedService, PnvBotUpdateHandler, TelegramNotifier
Program.cs Common/ # RateLimiting (константы политик), ResultExtensions (Result -> IResult)
Program.cs # DI composition root, pipeline (нет отдельных Middleware/Extensions папок)
appsettings*.json appsettings*.json
tests/ tests/
PnvPanel.Domain.Tests/ PnvPanel.Domain.Tests/ # 54 теста
PnvPanel.Application.Tests/ PnvPanel.Application.Tests/ # 71 тест
PnvPanel.Integration.Tests/ # Testcontainers PostgreSQL PnvPanel.IntegrationTests/ # 9 тестов, Testcontainers.PostgreSql + WebApplicationFactory<Program>
``` ```
Организация Application — **по фичам** (feature folders), внутри слоёв Clean Architecture. Организация Application — **по фичам** (feature folders), внутри слоёв Clean Architecture.
@@ -57,27 +79,43 @@ backend/
- Классы/методы/свойства — `PascalCase`; параметры/локальные — `camelCase`; приватные поля — `_camelCase`. - Классы/методы/свойства — `PascalCase`; параметры/локальные — `camelCase`; приватные поля — `_camelCase`.
- Команды — `<Verb><Noun>Command` (`CreateVpnConfigCommand`), запросы — `<Get/List><Noun>Query`. - Команды — `<Verb><Noun>Command` (`CreateVpnConfigCommand`), запросы — `<Get/List><Noun>Query`.
- Хендлеры — `<Command/Query>Handler`; валидаторы — `<Command/Query>Validator`. - Хендлеры — `<Command/Query>Handler`; валидаторы — `<Command/Query>Validator`.
- DTO — суффикс `Dto` (`VpnConfigDto`); ответы эндпоинтов — `Response`, тела запросов — `Request`. - **DTO** (Application-слой, возвращаются из `Result<T>`) — суффикс `Dto` (`VpnConfigDto`, `NodeDto`);
конвертация из сущности — статический `FromDomain(entity, ...)` на самом DTO.
- **Тела запросов** (Api-слой, только для JSON-полей, которых нет в готовой команде) — суффикс `Body`
(`CreateConfigBody`, `UpdateNodeBody`) либо сам record команды биндится напрямую как тело
(`RegisterCommand`, `LoginCommand`).
- **Тела ответов, которых нет как Application DTO** (например, потому что Api-слой добавляет
вычисляемое поле — абсолютный URL из токена) — суффикс `ResponseDto` (`AuthResponseDto`,
`ConfigLinkResponseDto`, `TelegramLoginStatusResponseDto`), определяются прямо в файле эндпоинта.
- Async-методы — суффикс `Async`, всегда принимают `CancellationToken`. - Async-методы — суффикс `Async`, всегда принимают `CancellationToken`.
- Один публичный тип на файл; имя файла = имя типа. - Один публичный тип на файл — с исключением: Api-слой держит вспомогательные `Body`/`ResponseDto`
records в том же файле, что и класс эндпоинтов, который их использует (не выносятся отдельно).
## Паттерны ## Паттерны
- **Rich domain model**: инварианты в сущностях (приватные сеттеры, фабричные методы `Node.Create(...)`, - **Rich domain model**: инварианты в сущностях (приватные сеттеры, фабричные методы `Node.Register(...)`,
поведенческие методы `config.Revoke()`), а не анемичные DTO-сущности. поведенческие методы `config.Revoke()`), а не анемичные DTO-сущности.
- **CQRS через собственный диспетчер**: хендлеры реализуют `ICommandHandler<TCommand,TResult>` / - **CQRS через собственный диспетчер**: хендлеры реализуют `ICommandHandler<TCommand,TResult>` /
`IQueryHandler<,>`; `ISender` резолвит их из DI и прогоняет через `IPipelineBehavior<,>` `IQueryHandler<,>`; `ISender` резолвит их из DI и прогоняет через `IPipelineBehavior<,>`
(валидация, транзакция, логирование). Без внешних CQRS-библиотек. (`ValidationBehavior``LoggingBehavior``UnitOfWorkBehavior`). Без внешних CQRS-библиотек, без
- **Порты в Application, адаптеры в Infrastructure**: никакого `Npgsql`/`SignalR`/`ThreeXui.Net` в Application/Domain. доменных событий — хендлер сам вызывает нужные порты (realtime/Telegram/аудит) синхронно.
- **`IAppDbContext`** экспонирует `DbSet<>` и `SaveChangesAsync`; хендлеры пишут LINQ напрямую - **Порты в Application, адаптеры в Infrastructure/Api**: никакого `Npgsql`/`SignalR`/`ThreeXui.Net` в
(репозитории — только для сложной агрегатной логики). Application/Domain — только интерфейсы, реализации могут жить и в `Infrastructure`, и в `Api`
- **Result-модель**: команды/запросы возвращают `Result<T>`; эндпоинт маппит в HTTP (`.Match(...)`). (`IRealtimeNotifier` реализован в `Api/Hubs`, т.к. завязан на `IHubContext<PanelHub>`).
- **Транзакция на команду**: `UnitOfWorkBehavior` оборачивает выполнение команды в транзакцию. - **`IAppDbContext`** экспонирует `DbSet<>` и `SaveChangesAsync`; хендлеры пишут LINQ напрямую —
- **Валидация**: `ValidationBehavior` до хендлера; хендлер не проверяет формат ввода повторно. выделенных репозиториев нет вообще.
- **Идемпотентность**: команды к 3x-ui устойчивы к повторам; при частичном сбое — компенсация - **Result-модель**: команды/запросы возвращают `Result`/`Result<T>`; `ResultExtensions.ToHttpResult()`
(создали клиента в панели, но упала БД → удалить клиента, вернуть ошибку). мапит `Error.Type` в HTTP-статус на границе Api.
- **Оптимистичная блокировка**: на изменяемых сущностях (нода, конфиг) — `xmin`/rowversion, чтобы - **Транзакция на команду**: `UnitOfWorkBehavior` вызывает `SaveChangesAsync` после хендлера команды
параллельные правки не затирали друг друга. (не запросов) — отдельной BEGIN/COMMIT-транзакции вокруг этого в MVP нет, полагаемся на то, что
один `SaveChanges` уже атомарен для одной единицы работы.
- **Компенсация при частичном сбое**: если клиент успешно создан в 3x-ui, а `SaveChanges` в БД упал —
хендлер вызывает `RemoveClientAsync`, чтобы не оставить сироту в панели.
- **Защита от гонок на квоте — `pg_advisory_xact_lock`**, не оптимистичная блокировка: перед проверкой
квоты роли `CreateVpnConfigCommandHandler` берёт `pg_advisory_xact_lock(hashtext(userId))`
сериализует параллельные попытки создать конфиг одним и тем же пользователем в рамках транзакции.
Отдельного rowversion/`xmin` на `Node`/`VpnConfig` нет (единственный `IsConcurrencyToken` в схеме —
штатный `ConcurrencyStamp` таблиц Identity).
## Работа с 3x-ui ## Работа с 3x-ui
@@ -92,25 +130,39 @@ backend/
## Ошибки и логирование ## Ошибки и логирование
- Единый `ProblemDetails` для ошибок API; коды: 400 (валидация), 401/403 (auth), 404, 409 (конфликт домена), 422, 429 (rate limit), 500. - Единый `application/problem+json` для ошибок API (`ResultExtensions.ToProblem`); коды: 400 (валидация),
- Serilog со структурными полями (`UserId`, `NodeId`, `ConfigId`, `CorrelationId`); секреты не логировать. 401/403 (auth/не активирован), 404, 409 (конфликт домена — квота, дубликат), 422 (прочее), 429 (rate limit), 500.
- Serilog + `UseSerilogRequestLogging()`; секреты (пароли, JWT, `BotToken`) не логировать. Структурного
обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId` пока нет — см. [tech-stack.md](tech-stack.md).
## Тестирование ## Тестирование
- **Domain.Tests** — инварианты и поведение сущностей, без моков. - **PnvPanel.Domain.Tests** (54 теста) — инварианты и поведение сущностей, без моков.
- **Application.Tests** — хендлеры с подменёнными портами (NSubstitute), проверка веток `Result`. - **PnvPanel.Application.Tests** (71 тест) — хендлеры на EF Core InMemory + подменённые порты
- **Integration.Tests** — реальный PostgreSQL (Testcontainers), миграции, сквозные сценарии эндпоинтов; (NSubstitute), проверка веток `Result`. InMemory, а не Sqlite — модель использует Postgres-специфичные
3x-ui — мок гейтвея или фейковый HTTP-сервер. типы (`uuid[]`, `jsonb`), которые Sqlite не поддерживает, а InMemory просто игнорирует.
- Именование тестов: `Method_Scenario_ExpectedResult`. - **PnvPanel.IntegrationTests** (9 тестов) — реальный PostgreSQL (`Testcontainers.PostgreSql`) +
`WebApplicationFactory<Program>`, сквозные сценарии через реальный HTTP-контракт, включая проверку
`pg_advisory_xact_lock` под параллельной нагрузкой на квоту; 3x-ui подменён `FakeXuiPanelGateway`.
- Именование тестов: `Method_Scenario_ExpectedResult`. Обычные `Assert.*` из xUnit — без FluentAssertions.
## Конфигурация ## Конфигурация
- `appsettings.json` + `appsettings.{Environment}.json` + env vars (перекрывают). - `appsettings.json` + `appsettings.{Environment}.json` + env vars (перекрывают); в dev — `.env`
- Секреты (JWT-ключ, строка подключения, ключ шифрования) — user-secrets (dev) / env/secret-store (prod). через `docker-compose`'s `env_file`, локально без Docker — переменные окружения напрямую (проект
- Строго типизированные `IOptions<T>` для секций конфига; валидация опций на старте. не подключает `dotnet user-secrets``UserSecretsId` в `.csproj` нет).
- Секреты (JWT-ключ, строка подключения, `Telegram:BotToken`, пароль сид-админа) — только через env/secret-store.
- Строго типизированные `IOptions<T>` для секций конфига (`JwtOptions`, `AdminSeedOptions`,
`TelegramOptions`, `RolesOptions`, ...); явной валидации на старте (`ValidateOnStart`/data annotations)
нет — отсутствующий обязательный секрет обнаружится при первом обращении (например, `AddInfrastructure`
бросит `InvalidOperationException`, если не задан `ConnectionStrings:Default`), не раньше.
## Стиль и качество кода ## Стиль и качество кода
- `.editorconfig` + анализаторы (`Microsoft.CodeAnalysis.NetAnalyzers`), nullable reference types **включены**. - `.editorconfig`, nullable reference types **включены**; сборка в CI идёт с `-c Release` и должна
- `dotnet format` в CI; предупреждения как ошибки для наших проектов. быть без предупреждений.
- Комментарии — по необходимости (почему, а не что); публичные контракты портов документируем XML-doc. - `dotnet format` — локальная команда разработчика (см. корневой `CLAUDE.md`), **в CI не запускается**;
CI гоняет только `dotnet build`/`dotnet test` (backend) и `pnpm lint`/`typecheck`/`build` (frontend).
- Комментарии — по необходимости (почему, а не что, — см. примеры в коде: причина `pg_advisory_xact_lock`,
причина `Secure = request.IsHttps`); публичные контракты портов документируем XML-doc там, где это
не очевидно из имени.
+64 -42
View File
@@ -1,22 +1,25 @@
# Domain Model # Domain Model
Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах. Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах.
`AppUser` — часть Identity (в `Infrastructure`); домен ссылается на пользователя по `UserId : Guid`. `AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser<Guid>`/
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
Ниже — то, что реально реализовано и работает. Тарифы `Plan` и лимиты трафика на конфиг
(`TrafficLimit`) были в первоначальном плане, но остались в backlog — квота в MVP только одна:
число активных конфигов на роль (`AppRole.MaxConfigs`).
## Диаграмма связей ## Диаграмма связей
``` ```
AppUser (Identity) [+ IsActivated, TelegramUserId] AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken]
├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs) ├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs)
├─1───*─ VpnConfig ├─1───*─ VpnConfig
*─┐ └─1─ Inbound ─*─1─ Node
├─1─ Inbound ─*─1─ Node └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
│ │ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
│ └─*─ TrafficSample
├─0..1─* ActivationRequest (запрос активации у админа, с комментарием) ├─0..1─* ActivationRequest (запрос активации у админа, с комментарием)
├─1───*─ TelegramLinkToken (короткоживущие токены привязки) ├─1───*─ TelegramLinkToken (короткоживущие токены привязки)
└─0..1─* TelegramLoginRequest (passwordless-вход) └─0..1─* TelegramLoginRequest (passwordless-вход)
Plan ─1───*─ VpnConfig (опционально; квота по числу конфигов — на роли, не на Plan) VpnConfig ─*─ TrafficSample (история трафика; пишется TrafficSyncService)
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId) AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem) ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
``` ```
@@ -79,41 +82,48 @@ ClientApp (каталог приложений-клиен
| `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) | | `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) |
| `Protocol` | `VpnProtocol` | Денормализовано с inbound | | `Protocol` | `VpnProtocol` | Денормализовано с inbound |
| `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui | | `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui |
| `TrafficLimit` | `TrafficLimit` (VO) | Лимит в байтах (0 = безлимит) | | `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui |
| `UsedDownBytes` | `long` | Синхронизируется из 3x-ui | | `UsedDownBytes` | `long` | Синхронизируется из 3x-ui |
| `ExpiresAt` | `DateTimeOffset?`| null = бессрочно | | `ExpiresAt` | `DateTimeOffset?`| Зарезервировано на будущее — в MVP ничего его не выставляет, конфиг живёт бессрочно |
| `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`| | `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`|
| `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` | | `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` |
| `LastSyncAt` | `DateTimeOffset?`| | | `LastSyncAt` | `DateTimeOffset?`| |
| `CreatedAt` | `DateTimeOffset` | | | `CreatedAt` | `DateTimeOffset` | |
Инварианты и переходы: Инварианты и переходы (методы на `VpnConfig`, `backend/src/PnvPanel.Domain/Configs/VpnConfig.cs`):
- Создаётся в статусе `Active`; поля клиента в 3x-ui и запись в БД создаются атомарно (компенсация при сбое). - `Create(...)` статус `Active`, `ClientExternalId` пуст до ответа от 3x-ui; хендлер вызывает
- **Проверка квоты выполняется в транзакции с блокировкой** (иначе два параллельных создания пробьют лимит). `IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после
- `Revoke()` → удаляет клиента в 3x-ui, статус `Revoked` (запись остаётся для истории/аудита). успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация).
- `Rotate()` → перевыпуск: удаляет старого клиента в 3x-ui и создаёт нового (новый UUID/ссылка); - **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания
квоту **не тратит**. Для случая утечки ссылки. (`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли.
- `Disable()`/`Enable()` → отключение/включение клиента в 3x-ui без удаления (используется при блокировке юзера). - `Revoke()` → статус `Revoked` (запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui.
- `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui,
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
- `Rename(label)` / `SetDeviceLimit(n)` → юзер меняет метку и лимит устройств (последнее синкается в `limitIp` 3x-ui). - `Rename(label)` / `SetDeviceLimit(n)` → юзер меняет метку и лимит устройств (последнее синкается в `limitIp` 3x-ui).
- Синхронизация: если `Used ≥ TrafficLimit``LimitReached` (+ событие); если `now ≥ ExpiresAt``Expired`. - `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`). - **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`; - Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже. роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже.
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`). - Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли). - Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
### Plan — тариф (опционально, backlog) > **Не реализовано в MVP**: лимиты трафика и автоматическое истечение срока конфига. `ExpiresAt`
Шаблон лимитов трафика/срока для конфига. **Квота на число конфигов — это `AppRole.MaxConfigs`, > никогда не выставляется, `ConfigStatus.LimitReached` в значении enum есть, но код в него никогда
а не Plan.** Plan остаётся опциональным механизмом для лимитов трафика/срока и в MVP не обязателен. > не переводит конфиг — оставлено на будущее (см. `Plan` ниже и Backlog в [vision.md](vision.md)).
### Plan — тариф (backlog, не реализовано)
Планировался как шаблон лимитов трафика/срока для конфига — **квота на число конфигов уже
реализована через `AppRole.MaxConfigs`, это не Plan**. Сущности `Plan` в коде нет; таблица ниже —
эскиз на будущее, если/когда лимиты трафика/срока понадобятся.
| Поле | Тип | Заметки | | Поле | Тип | Заметки |
| ------------------ | ----------- | ------------------------------ | | ------------------ | ----------- | ------------------------------ |
| `Id` | `Guid` | PK | | `Id` | `Guid` | PK |
| `Name` | `string` | | | `Name` | `string` | |
| `TrafficLimit` | `TrafficLimit` (VO) | Байты | | `TrafficLimitBytes`| `long` | 0 = безлимит |
| `DurationDays` | `int?` | Срок действия конфига | | `DurationDays` | `int?` | Срок действия конфига |
| `MaxConfigs` | `int` | Сколько конфигов даёт тариф |
| `IsActive` | `bool` | | | `IsActive` | `bool` | |
### TrafficSample — история трафика (для графиков) ### TrafficSample — история трафика (для графиков)
@@ -139,7 +149,7 @@ ClientApp (каталог приложений-клиен
| `Id` | `Guid` | PK | | `Id` | `Guid` | PK |
| `Name` | `string` | Название, напр. «v2rayNG», «Hiddify», «NekoBox» | | `Name` | `string` | Название, напр. «v2rayNG», «Hiddify», «NekoBox» |
| `DownloadUrl` | `Uri` | Ссылка на скачивание/стор | | `DownloadUrl` | `Uri` | Ссылка на скачивание/стор |
| `OperatingSystem` | `OsPlatform` | `iOS` / `Android` / `Windows` / `MacOS` / `Linux` | | `OperatingSystem` | `OsPlatform` | `IOS` / `Android` / `Windows` / `MacOS` / `Linux` |
| `Description` | `string?` | Короткая подсказка (опц.) | | `Description` | `string?` | Короткая подсказка (опц.) |
| `IconUrl` | `string?` | Иконка (опц.) | | `IconUrl` | `string?` | Иконка (опц.) |
| `SortOrder` | `int` | Порядок внутри группы ОС | | `SortOrder` | `int` | Порядок внутри группы ОС |
@@ -253,9 +263,12 @@ UI **настойчиво напоминает** привязать его (ед
## Value Objects ## Value Objects
- **NodeCredentials** — `Username` + `ProtectedPassword` (шифротекст); равенство по значению; пароль не сериализуется наружу. - **NodeCredentials** (`Nodes/NodeCredentials.cs`)`Username` + `ProtectedPassword` (шифротекст,
- **TrafficLimit** — байты; помощники `IsUnlimited`, `IsExceededBy(used)`, форматирование в ГБ. `ISecretProtector`/ASP.NET Data Protection); пароль не сериализуется наружу.
- **ConnectionLink** — построенная ThreeXui.Net строка подключения + производные (подписка, QR-payload).
Connection string для клиента строит `IXuiPanelGateway` (обёртка над `ThreeXui.Net`) на лету при
запросе `GET /api/configs/{id}/link` — отдельного value object под это не заводили. QR-код из
готовой строки генерируется **на фронте** (`qrcode.react`), сервер картинку не рендерит.
## Enums ## Enums
@@ -266,22 +279,31 @@ enum ConfigStatus { Active, Disabled, Expired, LimitReached, Revoked }
enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed } enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
enum ActivationStatus { Pending, Approved, Rejected } enum ActivationStatus { Pending, Approved, Rejected }
enum AuditSource { Web, Telegram, System } enum AuditSource { Web, Telegram, System }
enum OsPlatform { iOS, Android, Windows, MacOS, Linux } enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
``` ```
## Доменные события ## Уведомления и аудит (без диспетчера доменных событий)
| Событие | Когда | Реакция | В `Domain` нет маркера `IDomainEvent` и диспетчера событий — упрощение относительно исходного плана.
| ----------------------- | --------------------------------------- | --------------------------------------------------- | CQRS-хендлеры сами вызывают порты `IRealtimeNotifier` / `ITelegramNotifier` и пишут `AuditLog`
| `VpnConfigCreated` | Успешно создан конфиг | Realtime-пуш владельцу; аудит | напрямую, после того как изменение состояния сохранено. Так проще проследить, что именно произойдёт
| `VpnConfigRevoked` | Конфиг отозван | Realtime-пуш; аудит | при вызове конкретной команды — не нужно искать обработчик события где-то ещё.
| `TrafficLimitReached` | `Used ≥ Limit` при синхронизации | (опц.) отключить клиента в 3x-ui; пуш; статус |
| `NodeWentOffline` | Health-probe вернул недоступность | Пуш группе `admins`; пометка статуса |
| `ActivationRequested` | Пользователь запросил активацию | Пуш `admins` + уведомление админам в Telegram |
| `UserActivated` | Админ одобрил активацию | Пуш владельцу + Telegram-DM (если привязан); аудит |
| `UserBlocked` / `UserUnblocked` | Админ (раз)блокировал пользователя | Отключить/включить конфиги в 3x-ui; пуш + Telegram-DM; аудит |
| `VpnConfigRotated` | Пользователь перевыпустил конфиг | Новый линк владельцу; аудит |
События публикуются из сущностей/хендлеров и обрабатываются `IDomainEventHandler<T>` в Application | Хендлер / фоновый сервис | Что происходит |
(диспетчеризация — собственным диспетчером после `SaveChanges`); внешние эффекты (SignalR, 3x-ui) — | ------------------------------------ | -------------------------------------------------------------------------- |
через порты, реализуемые в Infrastructure. | `CreateVpnConfigCommandHandler` | Создаёт клиента в 3x-ui + `VpnConfig` |
| `RevokeVpnConfigCommandHandler` / `RotateVpnConfigCommandHandler` | Меняют клиента в 3x-ui и запись |
| `RequestActivationCommandHandler` | Realtime `activationRequested` группе `admins` + Telegram-уведомление админам (`AdminTelegramUserIds`) |
| `ApproveActivationCommandHandler` | `AuditLog` (`ActivationApproved`); realtime `userActivated` владельцу + Telegram-DM, если привязан |
| `RejectActivationCommandHandler` | `AuditLog` (`ActivationRejected`) |
| `BlockUserCommandHandler` / `UnblockUserCommandHandler` | Отключают/включают все активные конфиги в 3x-ui; `AuditLog`; Telegram-DM владельцу |
| `ChangeUserRoleCommandHandler` | `AuditLog` (`UserRoleChanged`) |
| `ForceRevokeConfigCommandHandler` | Отзывает конфиг в 3x-ui; `AuditLog` (`ConfigForceRevoked`); Telegram-DM владельцу |
| `ResetUserPasswordCommandHandler` | `AuditLog` (`UserPasswordReset`) |
| `RegisterNodeCommandHandler` / `UpdateNodeCommandHandler` / `DeleteNodeCommandHandler` | `AuditLog` (`NodeRegistered`/`NodeUpdated`/`NodeDeleted`) |
| `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) |
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
| `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу |
SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и
[api-design.md](api-design.md#signalr--hub-hubspanel).
+98 -70
View File
@@ -1,119 +1,147 @@
# Frontend # Frontend
SPA на **React 19 + Vite + TypeScript**. Общается с бэком по REST (JWT Bearer) и получает SPA на **React 19 + Vite + TypeScript**. Общается с бэком по REST (JWT Bearer) и получает
живые обновления по SignalR. Типы API генерируются из OpenAPI-схемы бэкенда. живые обновления по SignalR.
> **Раздача из единого контейнера.** В проде собранный фронт (`dist/`) кладётся в `wwwroot` > **Раздача из единого контейнера.** В проде собранный фронт (`dist/`) кладётся в `wwwroot`
> ASP.NET Core и раздаётся тем же приложением (SPA-fallback на `index.html`). Фронт и бек — один > ASP.NET Core и раздаётся тем же приложением (SPA-fallback на `index.html`). Фронт и бек — один
> origin, база API — относительный `/api`, SignalR — `/hubs/panel`. В dev Vite-сервер проксирует > origin, база API — относительный `/api`, SignalR — `/hubs/panel`. В dev Vite-сервер проксирует
> `/api` и `/hubs` на бэкенд. Детали упаковки — [architecture.md](architecture.md#развёртывание-единый-контейнер-приложения). > `/api` и `/hubs` на бэкенд (`vite.config.ts`, цель — `http://localhost:8080` по умолчанию,
> переопределяется `VITE_API_TARGET`). Детали упаковки — [architecture.md](architecture.md#развёртывание-единый-контейнер-приложения).
## Стек ## Стек
| Задача | Выбор | | Задача | Выбор |
| ----------------- | --------------------------------------- | | ----------------- | ------------------------------------------------------------ |
| Сборка/dev | Vite | | Сборка/dev | Vite (Rolldown-based) |
| Язык | TypeScript (strict) | | Язык | TypeScript (strict) |
| Данные с сервера | TanStack Query | | Данные с сервера | TanStack Query |
| Роутинг | TanStack Router (типобезопасный) | | Роутинг | TanStack Router (файловый, `src/routes/`, кодогенерация `routeTree.gen.ts`) |
| UI-компоненты | shadcn/ui + Tailwind CSS v4 | | UI-компоненты | shadcn-стиль поверх Radix (`@radix-ui/react-*`) + Tailwind CSS v4 |
| Иконки | lucide-react | | Иконки | lucide-react |
| Клиентский стейт | Zustand (auth, тема) | | Клиентский стейт | Zustand — только auth-стор (`features/auth/store.ts`); тема — React Context + localStorage, не Zustand |
| Формы | react-hook-form + zod | | Формы | react-hook-form + zod |
| Realtime | @microsoft/signalr | | Realtime | @microsoft/signalr |
| Графики | Recharts | | QR-коды | qrcode.react (рендерит QR из готовой строки на клиенте) |
| QR-коды | qrcode.react | | Типы API | openapi-typescript (`pnpm gen:api`) — генерирует `schema.gen.ts` для сверки; фичи импортируют руками написанный `shared/api/types.ts` |
| Типы API | openapi-typescript / orval (codegen) |
| i18n | react-i18next (RU + EN) | | i18n | react-i18next (RU + EN) |
| Линт | oxlint (не ESLint) |
| Пакетный менеджер | pnpm | | Пакетный менеджер | pnpm |
Установлены, но **не используются в MVP**: `recharts` (админская статистика — карточки с цифрами,
без графиков), `@tanstack/react-table` (админские таблицы написаны руками, без TanStack Table).
Оставлены как задел, если/когда понадобятся графики трафика или сложные таблицы с сортировкой.
## Структура ## Структура
Фактическая структура (`frontend/src/`):
``` ```
frontend/ frontend/
src/ src/
app/ # провайдеры (Query, Router, Auth, Theme), корневой layout main.tsx # точка входа: QueryClientProvider > ThemeProvider > ToastProvider > RealtimeProvider > RouterProvider
routes/ # маршруты TanStack Router (login, dashboard, configs, instructions, admin/*) router.tsx # createRouter из routeTree.gen.ts
routeTree.gen.ts # сгенерировано @tanstack/router-plugin, не редактируется руками
index.css # Tailwind v4 (@import), без отдельной styles/-папки
routes/ # файловый роутинг TanStack Router
__root.tsx # шапка (лого, нав, переключатель языка/темы), Outlet
index.tsx, login.tsx, register.tsx, dashboard.tsx, instructions.tsx, settings.tsx
admin.tsx # layout админки (вкладки) + Outlet
admin/
index.tsx, activation.tsx, users.tsx, roles.tsx, nodes.tsx, apps.tsx, audit.tsx
features/ features/
auth/ # формы, хуки useLogin/useRegister, стор авторизации auth/ # api.ts, store.ts (zustand), guards.ts, LoginForm.tsx, RegisterForm.tsx
configs/ # список/создание/редактирование/детали конфигов, QR, подписка activation/ # api.ts, ActivationGate.tsx (экран "запросить активацию")
instructions/ # страница инструкций + каталог приложений по ОС configs/ # api.ts, ConfigCard.tsx, CreateConfigDialog.tsx, SubscriptionCard.tsx
nodes/ # (admin) управление нодами apps/ # api.ts, AppsCatalog.tsx (для /instructions)
apps/ # (admin) CRUD каталога приложений telegram/ # api.ts, TelegramLoginButton.tsx
admin/ # пользователи, роли, аудит, статистика settings/ # ChangePasswordForm.tsx, TelegramLinkCard.tsx, DeleteAccountSection.tsx
theme/ # провайдер темы (light/dark/system) + переключатель admin/
users/, roles/, activation/, nodes/, inbounds/, apps/, audit/, stats/ # api.ts + диалоги CRUD в каждой
theme/
ThemeProvider.tsx # React Context + localStorage (`pnv-theme`), НЕ zustand
shared/ shared/
api/ # http-клиент (fetch + JWT/refresh), сгенерированные типы, query-хуки api/
realtime/ # инициализация SignalR, подписки → инвалидация Query-кэша client.ts # apiRequest(), HttpError, access-token в памяти модуля, 401 → silent refresh → повтор
ui/ # обёртки над shadcn/ui, общие компоненты types.ts # руками написанные типы ответов/запросов (сверены со schema.gen.ts)
lib/ # утилиты, форматирование (байты, даты) schema.gen.ts # генерируется pnpm gen:api, не импортируется напрямую фичами
config/ # env, константы lib/
styles/ # tailwind, темы i18n.ts, cn.ts, format.ts
realtime/
connection.ts, RealtimeProvider.tsx
ui/
button.tsx, input.tsx, label.tsx, card.tsx, dialog.tsx, select.tsx, badge.tsx,
progress.tsx, toast-store.tsx, toaster.tsx
index.html index.html
vite.config.ts vite.config.ts
package.json package.json
``` ```
Нет `app/`-папки с провайдерами (они прямо в `main.tsx`), нет `shared/config/` (переменные окружения
читаются точечно через `import.meta.env`), нет `styles/` (один `index.css` с Tailwind).
## Дизайн / UX ## Дизайн / UX
- **Тема оформления**: светлая и тёмная (переключатель в шапке; вариант «системная»). Реализация — - **Тема оформления**: светлая/тёмная/системная (переключатель в шапке). Реализация — класс `dark` на
Tailwind `dark` (класс на `html`) + shadcn/ui; выбор сохраняется (localStorage). `<html>` + Tailwind, выбор в `localStorage` (`ThemeProvider`, React Context).
- **Современный и чистый вид**: shadcn/ui + Tailwind, адаптивность, аккуратная типографика. - **Дашборд пользователя** (`/dashboard`): карточки конфигов (протокол, локация, использованный
- **Дашборд пользователя**: карточки конфигов (протокол, локация, трафик прогресс-баром, срок, статус), трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
быстрые действия (копировать ссылку, показать QR, перевыпустить, отозвать) + карточка «Общая подписка» не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
(агрегированная ссылка/QR со всеми конфигами). Для неактивированного — экран «запросить активацию». неактивированного — `ActivationGate` вместо дашборда.
- **Создание конфига**: выбор локации/inbound (по `DisplayName`) + метка + лимит устройств - **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка + лимит устройств.
мгновенная выдача ссылки + QR + ссылка на страницу инструкций. После успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно.
- **Страница инструкций** (`/instructions`): общие шаги «как импортировать ссылку/QR» + каталог - **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`),
приложений (`GET /api/apps`), **сгруппированный по ОС**; клик по приложению открывает ссылку на сгруппированный по ОС; клик по приложению открывает ссылку на скачивание.
скачивание. Данные ведёт админ (каталог `ClientApp`). - **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
- **Редактирование конфига**: изменить метку и лимит устройств. удаление аккаунта с подтверждением (`DeleteAccountSection`).
- **Настройки аккаунта**: смена пароля, привязка/отвязка Telegram, **удаление аккаунта** (с подтверждением). - **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
- **Админка**: таблицы (TanStack Table) с пагинацией/фильтрами для нод, пользователей, конфигов, ролей, пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные `<table>`,
каталога приложений, журнала аудита; очередь запросов активации; графики трафика (Recharts). без TanStack Table. Блокировка пользователя — с подтверждением.
Блокировка пользователя — с подтверждением (гасит VPN). Управление приложениями (название, ссылка, ОС, вкл/выкл). - **Состояния**: `isLoading`/`isError`/пусто различаются явно везде (ошибка сети не выглядит как
- **Состояния**: скелетоны при загрузке, аккуратные пустые состояния и toasts на ошибки/успех. «пусто» — паттерн закреплён после находки в `ActivationGate`, распространён на все admin-списки).
## Работа с API ## Работа с API
- HTTP-клиент оборачивает `fetch`: подставляет access-token, при 401 — прозрачно обновляет через - `shared/api/client.ts`: `apiRequest<T>()` оборачивает `fetch`, подставляет access-token в
refresh-cookie и повторяет запрос; при неуспехе — разлогин. `Authorization`; на `401` — один прозрачный `POST /api/auth/refresh` и повтор запроса; при неуспехе
- Все запросы/мутации — через TanStack Query (ключи по фичам, инвалидация после мутаций). рефреша — `setUnauthorizedHandler` колбэк (разлогин).
- Типы ответов/запросов — из codegen по OpenAPI (никакого ручного дублирования DTO). - Запросы/мутации — через TanStack Query, ключи по фиче, инвалидация после мутаций.
- Типы — из `shared/api/types.ts` (см. таблицу стека выше и [tech-stack.md](tech-stack.md)).
## Авторизация на клиенте ## Авторизация на клиенте
- **Access-token** — в памяти (не в localStorage), кладётся в `Authorization: Bearer`. - **Access-token** — в памяти (модуль `client.ts`, не React state и не localStorage) — переживает
- **Refresh-token** — httpOnly Secure cookie (JS не читает), ротация на сервере. ре-рендеры, но не пережить reload (тогда его молча восстанавливает silent refresh по cookie).
- Стор авторизации (Zustand) хранит профиль/роли/`isActivated`; guard-маршруты по роли (`admin` vs обычный) - **Refresh-token** — httpOnly Secure cookie (`pnv_refresh_token`, `Path=/api/auth`), JS его не видит.
и по активации (неактивированного ведём на экран «запросить активацию»). - Стор авторизации (`features/auth/store.ts`, Zustand) хранит текущего пользователя/`isActivated`.
Guard-хуки `useRequireAuth()`/`useRequireGuest()`/`useRequireAdmin()` (`features/auth/guards.ts`)
- **Вход — по username** (email не используется). «Забыли пароль?» ведёт: при привязанном редиректят через `useNavigate()` в `useEffect`, если условие не выполнено.
Telegram — восстановление через бота; иначе — подсказка обратиться к админу. - **Вход — по username** (email не используется). «Забыли пароль?»: при привязанном Telegram —
- **Баннер привязки Telegram**: пока Telegram не привязан, показываем настойчивый, но не блокирующий вход через бота и смена пароля в настройках; иначе — обратиться к админу.
баннер/напоминание — это единственный self-service способ восстановить доступ. Настройки: смена пароля.
### Вход и привязка через Telegram ### Вход и привязка через Telegram
- **«Войти через Telegram»**: `POST /api/auth/telegram/login-request` → показать deep-link/QR на - **«Войти через Telegram»** (`TelegramLoginButton`, на `/login`): `POST .../login-request`
бота, затем ждать подтверждения (поллинг `GET /login-request/{id}` или событие SignalR). При показывает `deepLink` как QR (`qrcode.react`), поллит `GET .../login-request/{id}` до
`Approved` — сохранить access, refresh уже в cookie, редирект в панель. `Approved`/`Rejected`/`Expired`. При `Approved` — сохраняет `accessToken` в память клиента
- **«Привязать Telegram»** (в настройках, для вошедшего): `POST /api/auth/telegram/link-token` (refresh уже пришёл в cookie от бэка) и редиректит в панель.
показать deep-link/QR; статус привязки обновить по факту (поллинг/SignalR). Отвязка — `unlink`. - **«Привязать Telegram»** (`TelegramLinkCard`, в настройках): `POST .../link-token` → QR из
- QR для deep-link`qrcode.react`. `deepLink`; статус обновляется поллингом. Отвязка — `POST .../unlink`.
## Realtime ## Realtime
- Одно SignalR-подключение к `/hubs/panel` с JWT; реконнект с бэкоффом. - Одно SignalR-подключение к `/hubs/panel` с JWT (`RealtimeProvider`, `shared/realtime/connection.ts`).
- Обработчики событий (`configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`) точечно - Обработчики `configTrafficUpdated`/`configStatusChanged`/`nodeStatusChanged`/`activationRequested`/
обновляют/инвалидируют кэш TanStack Query — UI обновляется без перезагрузки. `userActivated` точечно инвалидируют/обновляют кэш TanStack Query — UI обновляется без перезагрузки.
## Скрипты (ожидаемые) ## Скрипты
```bash ```bash
pnpm dev # dev-сервер Vite pnpm dev # dev-сервер Vite
pnpm build # прод-сборка pnpm build # tsc -b && vite build (прод-сборка)
pnpm preview # предпросмотр сборки pnpm preview # предпросмотр prod-сборки
pnpm lint # ESLint pnpm lint # oxlint
pnpm typecheck # tsc --noEmit pnpm typecheck # tsc -b
pnpm gen:api # генерация типов из OpenAPI-схемы бэкенда pnpm gen:api # openapi-typescript по /openapi/v1.json живого бэкенда -> schema.gen.ts
``` ```
`pnpm gen:api` требует запущенный бэкенд на `http://localhost:8080` (или `docker compose up`).
+67 -44
View File
@@ -1,75 +1,98 @@
# Roadmap # Roadmap
Порядок реализации по этапам (milestones). Каждый этап — работоспособный инкремент. **MVP полностью реализован** — все этапы M0–M8 закрыты. Ниже — ретроспектива по этапам (как было
задумано → что реально сделано, с честными пометками о расхождениях) и раздел [Backlog](#backlog-после-mvp)
с тем, что осталось за рамками MVP осознанно.
## M0 — Каркас и инфраструктура ## M0 — Каркас и инфраструктура
- Solution + 4 проекта (Domain/Application/Infrastructure/Api), ссылки по Clean Architecture. - Solution (`PnvPanel.slnx`) + 4 проекта (Domain/Application/Infrastructure/Api), ссылки по Clean Architecture.
- `Directory.Build.props`, `.editorconfig`, nullable + анализаторы, `dotnet format` в CI. - `Directory.Build.props`, `.editorconfig`, nullable включены. `dotnet format` — локальная команда,
- EF Core + Npgsql, первая миграция. в CI **не** запускается (CI гоняет только build/test).
- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn/ui + TanStack Query/Router; **тема light/dark/system** (провайдер + переключатель); i18n (RU/EN); dev-прокси `/api`,`/hubs` на бэк. - EF Core + Npgsql, миграции.
- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn-стиль поверх Radix + TanStack Query/Router;
**тема light/dark/system** (провайдер + переключатель); i18n (RU/EN); dev-прокси `/api`,`/hubs` на бэк.
- **Единый контейнер**: multi-stage Dockerfile (node → dotnet publish → aspnet), Api раздаёт SPA из - **Единый контейнер**: multi-stage Dockerfile (node → dotnet publish → aspnet), Api раздаёт SPA из
`wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL); `ForwardedHeaders` `wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL); `ForwardedHeaders`
(TLS — внешним прокси); авто-применение миграций на старте. (TLS — внешним прокси); авто-применение миграций на старте.
- Health-check `/health`, Serilog, OpenAPI + Scalar. - Health-check `/health`, Serilog, нативный OpenAPI (`Microsoft.AspNetCore.OpenApi`) + Scalar (без Swashbuckle).
- **CI (GitHub Actions)**: `dotnet build/test` + `pnpm build/lint/typecheck` (без деплоя). - **CI (GitHub Actions)**: `dotnet build/test` + `pnpm build/lint/typecheck` (без деплоя).
- **Готово, когда**: единый образ поднимается в docker-compose рядом с postgres, отдаёт заглушку SPA и `/health`, есть базовая миграция, CI зелёный. - **Готово, когда**: единый образ поднимается в docker-compose рядом с postgres, отдаёт SPA и `/health`,
есть миграции, CI зелёный. ✅ Достигнуто — включая полную проверку `docker compose up` end-to-end.
## M1 — Аутентификация и сидинг ## M1 — Аутентификация и сидинг
- ASP.NET Core Identity (`AppUser`/`AppRole` c `MaxConfigs`); `DbInitializer`: системные роли - ASP.NET Core Identity (`AppUser`/`AppRole` c `MaxConfigs`); `DbInitializer`: системные роли
`admin`/`user` и учётка админа + Telegram id админов из env ([`.env.example`](../.env.example)). `admin`/`user` и учётка админа из env ([`.env.example`](../.env.example)).
- **Вход по username** (email не используется); JWT access + refresh (httpOnly cookie, ротация, хранение - **Вход по username** (email не используется); JWT access + refresh (httpOnly cookie, ротация,
хэшей), CSRF на refresh, Identity lockout, rate-limit на `/auth/*`; смена пароля. `Secure` по факту HTTPS-запроса, хранение хэшей), Identity lockout, rate-limit на `/auth/*`;
смена пароля. Явного анти-CSRF токена нет — обоснование в [architecture.md](architecture.md#безопасность).
- Регистрация: новый пользователь → роль `user`, `IsActivated = false`. - Регистрация: новый пользователь → роль `user`, `IsActivated = false`.
- Фронт: страницы login/register (username), стор авторизации, refresh-flow, guard-маршруты. - Фронт: страницы login/register (username), стор авторизации, refresh-flow, guard-маршруты.
- **Готово, когда**: регистрация/вход/refresh/logout по username работают, админ засидан, новый юзер неактивен. - **Готово, когда**: регистрация/вход/refresh/logout по username работают, админ засидан, новый юзер неактивен. ✅ Достигнуто.
## M2 — Роли и активация ## M2 — Роли и активация
- Домен: динамические роли (CRUD `admin`, квота `MaxConfigs`), `ActivationRequest`. - Домен: динамические роли (CRUD, квота `MaxConfigs`), `ActivationRequest`.
- Команды/запросы: CreateRole/UpdateRole/DeleteRole, ChangeUserRole (одна роль), RequestActivation (с комментарием), - Команды/запросы: CreateRole/UpdateRole/DeleteRole, ChangeUserRole (одна роль), RequestActivation
ApproveActivation/RejectActivation. (с комментарием), ApproveActivation/RejectActivation.
- Эндпоинты активации (user + admin) и ролей; policy `RequireActivated`. - Эндпоинты активации (user + admin) и ролей; проверка активации/роли — inline в хендлерах
и `RequireAuthorization(...)` на эндпоинте, без отдельных именованных policy.
- Фронт: экран «запросить активацию» (с комментарием), админ-очередь запросов, управление ролями/назначением. - Фронт: экран «запросить активацию» (с комментарием), админ-очередь запросов, управление ролями/назначением.
- **Готово, когда**: юзер запрашивает активацию с комментарием, админ на сайте активирует; роли с квотами работают. - **Готово, когда**: юзер запрашивает активацию с комментарием, админ на сайте активирует; роли с квотами работают. ✅ Достигнуто.
## M3 — Ноды и публикация inbounds (по ролям) ## M3 — Ноды и публикация inbounds (по ролям)
- Домен `Node`/`Inbound` (+ `AllowedRoles`, `DisplayName`); порт `IXuiPanelGateway` + `XuiPanelGateway` - Домен `Node`/`Inbound` (+ `AllowedRoles`, `DisplayName`); порт `IXuiPanelGateway` + единственная
(per-node клиент, ThreeXui.Net); шифрование секретов нод (`ISecretProtector`). реализация `XuiPanelGateway` (кэш клиента per-node внутри неё, `ThreeXui.Net`); шифрование секретов
- Команды/запросы: RegisterNode, SyncNode, Probe, ListNodes, ListInbounds, PublishInbound (с выбором ролей). нод (`ISecretProtector`/ASP.NET Data Protection).
- Команды/запросы: RegisterNode, UpdateNode, DeleteNode, SyncNode, ProbeNode, ListNodes, ListInbounds,
PublishInbound (с выбором ролей).
- Админка нод/инбаундов на фронте (публикация с `displayName` и `allowedRoleIds`). - Админка нод/инбаундов на фронте (публикация с `displayName` и `allowedRoleIds`).
- **Готово, когда**: админ подключает реальную 3x-ui и публикует inbound «Германия (Trojan)» для выбранных ролей. - **Готово, когда**: админ подключает реальную 3x-ui и публикует inbound для выбранных ролей. ✅ Достигнуто
(удаление ноды с активными конфигами пока не блокируется — известный пробел, см. [tech-stack.md](tech-stack.md)).
## M4 — Конфиги пользователя (ядро продукта) ## M4 — Конфиги пользователя (ядро продукта)
- Домен `VpnConfig` (создание, отзыв, ротация, статусы; инварианты: активирован + квота роли (грандфазеринг) - Домен `VpnConfig` (создание, отзыв, ротация; инварианты: активирован + квота роли (грандфазеринг)
+ доступ роли к инбаунду; проверка квоты в транзакции; схема `ClientEmail`). + доступ роли к инбаунду; квота — под `pg_advisory_xact_lock`; схема `ClientEmail`).
- CreateVpnConfig (с `label`/`deviceLimit``limitIp`), EditVpnConfig, RotateVpnConfig, RevokeVpnConfig, - CreateVpnConfig (с `label`/`deviceLimit``limitIp`), EditVpnConfig, RotateVpnConfig, RevokeVpnConfig,
GetMyConfigs, GetConfigLink, ListAvailableInbounds; connection string + QR. GetMyConfigs, GetConfigLink, ListAvailableInbounds; connection string по запросу (не сразу при
- Подписка: агрегированная `/sub/{userToken}` (все конфиги) + по конфигу `/sub/{configToken}`; создании), QR строится на фронте.
заголовки `Subscription-Userinfo` / `profile-update-interval`. - Подписка: один эндпоинт `/sub/{token}` — токен либо агрегированный (`AppUser.SubscriptionToken`,
- Самоудаление аккаунта (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных. все конфиги), либо по одному конфигу (`VpnConfig.SubscriptionToken`); заголовки
`Subscription-Userinfo` / `Profile-Update-Interval`.
- Самоудаление аккаунта (`DELETE /api/auth/me`): отзыв всех активных конфигов + удаление `AppUser`.
- Каталог приложений `ClientApp` (домен + `GET /api/apps` по ОС; сид из `seed/client-apps.json`) + **страница инструкций** на фронте. - Каталог приложений `ClientApp` (домен + `GET /api/apps` по ОС; сид из `seed/client-apps.json`) + **страница инструкций** на фронте.
- Фронт: дашборд (метки, лимит устройств), создание/редактирование, страница инструкций, копирование, QR, отзыв, перевыпуск, настройки аккаунта. - Фронт: дашборд (метки, лимит устройств), создание/редактирование, страница инструкций, ссылка/QR
- **Готово, когда**: активированный юзер создаёт рабочий конфиг в доступном инбаунде в пределах квоты; работает агрегированная подписка. по кнопке, отзыв, перевыпуск, настройки аккаунта.
- **Готово, когда**: активированный юзер создаёт рабочий конфиг в доступном инбаунде в пределах квоты;
работает подписка. ✅ Достигнуто. Лимиты трафика/срока конфига — не реализованы, backlog
(см. [domain-model.md](domain-model.md)).
## M5 — Синхронизация трафика и realtime ## M5 — Синхронизация трафика и realtime
- `TrafficSyncService` (обход нод, обновление трафика/статусов, `TrafficSample`); реконсиляция дрейфа с 3x-ui. - `TrafficSyncService` (обход включённых нод, обновление трафика, `TrafficSample`) — только для
отображения, без активной реконсиляции дрейфа (недоступная нода/незнакомый клиент — тихо пропускаются).
- `NodeHealthCheckService`; `TrafficRetentionService` (TTL-чистка истории). - `NodeHealthCheckService`; `TrafficRetentionService` (TTL-чистка истории).
- SignalR `PanelHub` + `IRealtimeNotifier`; события трафика/статусов/нод/активации. - SignalR `PanelHub` + `IRealtimeNotifier` (реализован в `Api/Hubs/`, не в Infrastructure); события
- Фронт: живые прогресс-бары трафика, статусы онлайн, реакция на превышение лимита/срока. `configTrafficUpdated`/`configStatusChanged`/`nodeStatusChanged`/`activationRequested`/`userActivated`.
- **Готово, когда**: трафик и статусы обновляются в UI без перезагрузки. - Фронт: живые обновления трафика/статусов без перезагрузки. Реакции на превышение лимита/срока нет —
таких лимитов не существует (см. M4).
- **Готово, когда**: трафик и статусы обновляются в UI без перезагрузки. ✅ Достигнуто.
## M6 — Админ-статистика, управление пользователями, аудит ## M6 — Админ-статистика, управление пользователями, аудит
- ListUsers, BlockUser (→ отключение конфигов в 3x-ui) / UnblockUser, ChangeUserRole, ResetUserPassword (без привязки TG), GetUserConfigs, force-revoke, GetStats. - ListUsers, BlockUser (→ отключение конфигов в 3x-ui) / UnblockUser (два отдельных эндпоинта),
- `AuditLog`: запись значимых действий (Web/Telegram/System) + эндпоинт `/api/admin/audit`. ChangeUserRole, ResetUserPassword, GetUserConfigs, ForceRevokeConfig, GetStats.
- `AuditLog`: запись значимых действий (активация, блокировка, смена роли, ноды/инбаунды — источник
всегда `Web`, т.к. пишется из тех же хендлеров, что вызывает и бот) + эндпоинт `/api/admin/audit`.
- Каталог приложений: админ-CRUD `ClientApp` (`/api/admin/apps`) — название, ссылка, ОС, порядок, вкл/выкл. - Каталог приложений: админ-CRUD `ClientApp` (`/api/admin/apps`) — название, ссылка, ОС, порядок, вкл/выкл.
- Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки. - Фронт: таблицы пользователей/ролей/нод/приложений (обычные `<table>`, без TanStack Table), журнал
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN. аудита, статистика карточками (без графиков — `recharts` установлен, но не подключён).
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами;
блокировка гасит VPN. ✅ Достигнуто.
## M7 — Telegram-бот ✅ ## M7 — Telegram-бот ✅
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`. - Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`. - Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`. - Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`). - Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
- Команды бота: `/start`, меню, «Мои конфиги» (`GetMyConfigsQuery`), `/login`, `/unlink`, `/requests`, `/help`. - Команды бота: `/start` (+ `link_<token>`/`login_<requestId>` deep-link payload), «Мои конфиги»
(`/configs`, текстовый список, без ссылок/QR), `/unlink`, `/requests`, `/help`.
- **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env). - **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env).
- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам. - **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам.
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто. - **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто.
+57 -29
View File
@@ -13,19 +13,29 @@ health checks, DI. `ThreeXui.Net` таргетит `net10.0` — совпаде
Альтернативы: Vertical Slice (проще для мелких API, но хуже изолирует домен для растущего продукта) — Альтернативы: Vertical Slice (проще для мелких API, но хуже изолирует домен для растущего продукта) —
можно комбинировать: слои + организация Application «по фичам». можно комбинировать: слои + организация Application «по фичам».
### CQRS: собственный тонкий диспетчер ✅ (зафиксировано) ### CQRS: собственный тонкий диспетчер ✅ (зафиксировано и реализовано)
**Решение принято**: свой `ISender` вместо MediatR (тот с v12 стал платным). ~100 строк: **Решение принято**: свой `ISender` вместо MediatR (тот с v12 стал платным). `ISender.Send()`
`ISender.Send()` резолвит `ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через резолвит `ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через `IPipelineBehavior<,>`.
`IPipelineBehavior<,>` (валидация → авторизация → транзакция → логирование). Плюсы: нет лицензий и Реализованы три поведения: `ValidationBehavior` (FluentValidation), `LoggingBehavior`,
внешних зависимостей, полный контроль. Доменные события — свой `IDomainEventHandler<T>` + `UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Плюсы: нет лицензий и внешних
диспетчеризация после `SaveChanges`. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели). зависимостей, полный контроль. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели).
**Отличие от исходного плана**: отдельного диспетчера доменных событий (`IDomainEventHandler<T>`) в
итоге не заводили — оказалось, что для текущего размера проекта прямые вызовы `IRealtimeNotifier`/
`ITelegramNotifier` и запись `AuditLog` прямо в хендлере команды читаются проще, чем публикация
события и поиск обработчика где-то ещё (см. [domain-model.md](domain-model.md#уведомления-и-аудит-без-диспетчера-доменных-событий)).
Также нет отдельного `AuthorizationBehavior` — роль проверяется на уровне эндпоинта
(`RequireAuthorization(...)`), а более тонкие проверки (владение, активация) — в самом хендлере.
### Валидация: FluentValidation ### Валидация: FluentValidation
Декларативные валидаторы на команды/запросы, подключаются через `ValidationBehavior`. Декларативные валидаторы на команды/запросы, подключаются через `ValidationBehavior`. Заводится не
для каждой команды — только там, где есть что проверить помимo типов (например, у команд без
пользовательского ввода валидатора нет).
### Маппинг: Mapster ### Маппинг: вручную, без Mapster
Быстрый, без коммерческой лицензии (в отличие от AutoMapper, тоже ставшего платным), кодогенерация. В исходном плане был Mapster — на практике для такого числа полей ручной статический метод
Для простых проекций — ручной `Select` в DTO без маппера. `XxxDto.FromDomain(entity)` на самом DTO читается не хуже конфига маппера и не добавляет
зависимость. `Mapster` в проект так и не попал.
### ORM: EF Core 10 + Npgsql ### ORM: EF Core 10 + Npgsql
Миграции, LINQ, `IEntityTypeConfiguration`. Провайдер PostgreSQL — Npgsql. Миграции, LINQ, `IEntityTypeConfiguration`. Провайдер PostgreSQL — Npgsql.
@@ -68,16 +78,23 @@ MVP (не нужен публичный webhook, проще в одиночно
Явные ошибки вместо исключений для управляемых сценариев; исключения — только для действительно исключительного. Явные ошибки вместо исключений для управляемых сценариев; исключения — только для действительно исключительного.
### Логирование: Serilog ✅ (зафиксировано) ### Логирование: Serilog ✅ (зафиксировано)
**Решение принято**: структурное логирование — **Serilog** (`Serilog.AspNetCore`). Настройка через **Решение принято**: структурное логирование — **Serilog** (`Serilog.AspNetCore`), настройка через
`appsettings`/env, обогащение контекста (`UserId`/`NodeId`/`ConfigId`/`CorrelationId`), секреты не `appsettings`/env, `UseSerilogRequestLogging()` + `Enrich.FromLogContext()`. Синк MVP — Console.
логируются. Синки MVP: Console (JSON в проде) + rolling file; Seq/OTel-экспорт — опционально позже. Секреты (пароли, JWT, `BotToken`) в логи не попадают. **Не реализовано**: явное обогащение контекста
Наблюдаемость сверх логов (OpenTelemetry-трейсинг, метрики) — вне MVP. полями `UserId`/`NodeId`/`ConfigId`, сквозной `CorrelationId`, rolling file/Seq/OTel-экспорт — было в
исходном плане, осталось в backlog. Сегодня для расследования инцидента доступны только то, что даёт
`Enrich.FromLogContext()` + запрос/ответ из request-логирования.
### API-документация: Swashbuckle (OpenAPI) + Scalar UI ### API-документация: нативный OpenAPI (`Microsoft.AspNetCore.OpenApi`) + Scalar UI
Схема OpenAPI используется фронтом для кодогенерации типов. Scalar — современный UI вместо Swagger UI. `AddOpenApi()`/`MapOpenApi()` — встроенная в ASP.NET Core (.NET 9+) генерация схемы, без Swashbuckle.
`/openapi/v1.json` используется фронтом для `pnpm gen:api` (openapi-typescript). `/scalar` — Scalar UI
вместо Swagger UI. Каждый эндпоинт аннотирован `.Produces<T>()`, чтобы схема полностью описывала
и тела запросов, и тела ответов.
### Тесты: xUnit + FluentAssertions + NSubstitute + Testcontainers ### Тесты: xUnit + NSubstitute + Testcontainers
Юнит-тесты домена/хендлеров (моками портов), интеграционные — с реальным PostgreSQL в Testcontainers. Юнит-тесты домена/хендлеров (без FluentAssertions — обычные `Assert.*` из xUnit хватает для
используемых проверок), интеграционные — с реальным PostgreSQL в Testcontainers
(`Testcontainers.PostgreSql` + `WebApplicationFactory<Program>`).
## Frontend ## Frontend
@@ -104,11 +121,16 @@ MVP (не нужен публичный webhook, проще в одиночно
### Realtime: @microsoft/signalr ### Realtime: @microsoft/signalr
Официальный клиент SignalR; подписки на события хаба обновляют кэш TanStack Query. Официальный клиент SignalR; подписки на события хаба обновляют кэш TanStack Query.
### Типы API: OpenAPI codegen (openapi-typescript / orval) ### Типы API: openapi-typescript ✅ (зафиксировано)
Типы (и, опц., хуки) генерируются из OpenAPI-схемы бэкенда — single source of truth, никакого дрейфа контрактов. `pnpm gen:api` гоняет `openapi-typescript` по `/openapi/v1.json` живого бэкенда →
`shared/api/schema.gen.ts`. На практике фичи импортируют типы из руками написанного
`shared/api/types.ts` (см. [frontend.md](frontend.md)) — он логически совпадает со сгенерированной
схемой (сверено), но даёт нормальные generic (`PagedList<T>`) и понятные имена, которых нет в JSON
Schema. `orval` рассматривался как альтернатива (codegen хуков), не использовался.
### Графики: Recharts ### Графики: Recharts (установлен, графики не построены)
Декларативные графики трафика/статистики. QR-коды конфигов — `qrcode.react`. Библиотека в зависимостях фронта на будущее — в MVP админская статистика показана карточками с
цифрами, без графиков. QR-коды конфигов — `qrcode.react` (реально используется).
### i18n: react-i18next, RU + EN ✅ (зафиксировано) ### i18n: react-i18next, RU + EN ✅ (зафиксировано)
**Решение принято**: локализация с первого дня, языки **RU + EN** (RU по умолчанию). Тексты — через **Решение принято**: локализация с первого дня, языки **RU + EN** (RU по умолчанию). Тексты — через
@@ -167,16 +189,22 @@ MVP (не нужен публичный webhook, проще в одиночно
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) | | Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
| Лимит устройств | Per-config, задаёт юзер (`DeviceLimit``limitIp` в 3x-ui; 0 = без лимита) | | Лимит устройств | Per-config, задаёт юзер (`DeviceLimit``limitIp` в 3x-ui; 0 = без лимита) |
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») | | Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
| Самоудаление аккаунта | Разрешено: отзыв всех конфигов + удаление данных, аудит анонимизируется | | Самоудаление аккаунта | Разрешено: отзыв всех активных конфигов в 3x-ui + удаление `AppUser`. `AuditLog` уже хранит только `Guid` без PII — отдельной анонимизации задним числом нет, сам факт удаления в аудит тоже не пишется |
| Версионирование API | Без версий в MVP (`/api` без `v1`) | | Версионирование API | Без версий в MVP (`/api` без `v1`) |
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` | | Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
| Тема сайта | Светлая + тёмная (+ системная); Tailwind `dark`, выбор в localStorage | | Тема сайта | Светлая + тёмная (+ системная); Tailwind `dark`, выбор в localStorage |
| Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС); стартовый сид из `seed/client-apps.json` | | Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС); стартовый сид из `seed/client-apps.json` |
| Реконсиляция с 3x-ui | На синхронизации сверяем проекцию с панелью, помечаем дрейф, не «воскрешаем» молча | | Реконсиляция с 3x-ui | Не реализована активно — `TrafficSyncService` молча пропускает ноду/клиента при недоступности или несовпадении, без пометки дрейфа (см. [architecture.md](architecture.md)) |
Также заложены: CSRF-защита refresh-cookie + Identity lockout; проверка квоты в транзакции; схема Также реализовано: Identity lockout по неудачным входам; проверка квоты под `pg_advisory_xact_lock`;
`ClientEmail = pnv_{userIdShort}_{rand}`; блокировка удаления ноды при наличии конфигов. схема `ClientEmail = pnv_{userIdShort}_{rand}`. Явного анти-CSRF токена на refresh-cookie нет (см.
[architecture.md](architecture.md#безопасность) — обоснование, почему `SameSite=Strict` + `HttpOnly`
достаточно при мутациях только по Bearer-токену). Удаление ноды с активными конфигами **не
блокируется** — это известный пробел, не защита: `DeleteNodeCommandHandler` каскадно удаляет
инбаунды ноды без проверки существующих `VpnConfig`.
Осталось выбрать позже (не блокирует старт): значение TTL для истории трафика; конкретные синки Не реализовано (осталось на будущее, не блокирует текущую работу): TTL для истории трафика (сейчас
Serilog для прод (файл/Seq/OTel); точные TTL токенов Telegram. Email/SMTP в проекте **не используются** `TrafficRetentionService` работает, но точный порог не вынесен в решение — см. код); прод-синки
(вход по username, восстановление — через Telegram/админа). Serilog (файл/Seq/OTel) и структурное обогащение логов (`UserId`/`CorrelationId`); точные TTL
токенов Telegram. Email/SMTP в проекте **не используются** (вход по username, восстановление — через
Telegram/админа).
+116 -93
View File
@@ -1,151 +1,174 @@
# Telegram Bot # Telegram Bot
Telegram-бот — **второй канал доставки** (presentation-адаптер) поверх той же Application-логики, Telegram-бот — **второй канал доставки** (presentation-адаптер) поверх той же Application-логики,
что и REST API. Он не содержит бизнес-правил: команды бота вызывают те же CQRS-команды/запросы что и REST API. Он не содержит бизнес-правил: обработчики апдейтов вызывают те же CQRS-команды/запросы
(`ICommand/IQuery`), что и веб. Бизнес-инварианты живут в домене, а не в обработчиках бота. (`ICommand`/`IQuery` через собственный `ISender`), что и веб. Бизнес-инварианты живут в домене.
## Возможности ## Возможности (реализовано)
1. **Ссылка на сайт** — кнопка/команда, открывающая веб-панель (при желании — с одноразовым 1. **Мои конфиги**`/configs` присылает текстовый список (метка/локация, протокол, статус) —
deep-link авто-входом для уже привязанного пользователя). **без ссылок и QR** в самом боте; за ссылкой/QR пользователь идёт на сайт. Доступно только
2. **Мои конфиги** — список VPN-конфигов пользователя (протокол, локация, трафик, срок, статус), привязанному аккаунту.
ссылка-подписка и QR по каждому. Доступно только привязанному аккаунту. 2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
3. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: подтверждение входа подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
в боте. Требует предварительной **привязки Telegram** к аккаунту. 3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
4. **Админ: обработка запросов активации** — админ (по Telegram id из env) получает уведомление получает сообщение о каждом запросе активации с именем и комментарием заявителя, жмёт
о запросе активации с комментарием заявителя и жмёт «Активировать / Отклонить» прямо в боте. «Активировать / Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
5. **DM-уведомления пользователю** если Telegram привязан, бот шлёт личные уведомления о ключевых 4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
событиях: «аккаунт активирован», «конфиг отозван админом», «вы заблокированы». принудительный отзыв конфига админом.
6. **Восстановление пароля** — реализовано через passwordless-вход: привязанный пользователь входит 5. **Отвязка**`/unlink`.
через бота (`/login`) и меняет пароль в настройках (`ChangePasswordCommand`). Без привязки
Telegram сброс делает только админ (`ResetUserPasswordCommand`). Отдельная команда бота
`/resetpassword` с одноразовой ссылкой на смену пароля — **backlog**, в MVP не реализована.
> **Скоуп бота в MVP — просмотр (read-only) по конфигам.** Создание/ротация/отзыв конфигов — только **Не реализовано / backlog:**
> на сайте. Полное самообслуживание в боте (создание/отзыв) — в backlog. - Ссылки/QR/подписка в самом боте (только текстовый список конфигов).
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
- Регистрация нового аккаунта из бота (только привязка существующего).
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам.
## Размещение в архитектуре ## Размещение в архитектуре
- Бот работает **в том же процессе**, что и API, как `BackgroundService` - Бот работает **в том же процессе**, что и API, как `BackgroundService` (`TelegramBotHostedService`,
(`TelegramBotHostedService`) — это укладывается в требование «фронт+бек в одном контейнере». `PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере».
- Транспорт с Telegram: **long polling** для MVP (не требует публичного webhook-URL, проще в - Транспорт **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на
одиночном контейнере). Webhook — опциональная альтернатива для прод-нагрузки (тогда — секретный этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`)
токен заголовка для верификации). содержит только `BotToken`, `BotUsername`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей
- Библиотека — **Telegram.Bot** (де-факто стандарт для C#). `Mode`/`WebhookUrl`/`WebhookSecret` в коде нет.
- Код бота лежит в `PnvPanel.Api/Telegram/` (хендлеры апдейтов, построители клавиатур, - Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`,
форматтеры сообщений). Обращения к домену — **только** через собственный `ISender`. как HTTP-запрос — свежие scoped-сервисы на апдейт).
`Telegram.Bot` не проникает в Application/Domain. - Обращения к домену — **только** через `ISender`. `Telegram.Bot` не проникает в Application/Domain.
- Если `Telegram:BotToken` не задан — `TelegramBotHostedService.ExecuteAsync` сразу возвращается,
бот не стартует, панель работает без него (лог `Telegram__BotToken не задан — бот не стартует.`).
``` ```
Telegram ──updates──► TelegramBotHostedService (Api) Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandler (Api/Telegram/)
│ ISender.Send(command/query) // свой диспетчер │ ISender.Send(command/query) // свой диспетчер, свой DI-scope на апдейт
Application (те же хендлеры, что и REST) Application (те же хендлеры, что и REST)
``` ```
## Модель данных (добавления) ## Модель данных (добавления)
- `AppUser.TelegramUserId : long?` — id пользователя Telegram (уникальный, nullable до привязки). - `AppUser.TelegramUserId : long?`, `TelegramUsername : string?`, `TelegramLinkedAt : DateTimeOffset?`.
- `AppUser.TelegramUsername : string?`, `AppUser.TelegramLinkedAt : DateTimeOffset?`.
- `TelegramLinkToken` — короткоживущий одноразовый токен привязки (`token`, `userId`, `expiresAt`, `consumedAt`). - `TelegramLinkToken` — короткоживущий одноразовый токен привязки (`token`, `userId`, `expiresAt`, `consumedAt`).
- `TelegramLoginRequest` — запрос passwordless-входа: `id/nonce`, `status` - `TelegramLoginRequest` — запрос passwordless-входа: `id`, `status`
(`Pending/Approved/Rejected/Expired/Consumed`), `userId?` (после подтверждения), `createdAt`, `expiresAt`. (`Pending/Approved/Rejected/Expired/Consumed`), `userId?` (после подтверждения), `context?`
(IP инициатора, собирается, но **в текст подтверждения в боте не выводится** — известный TODO),
`createdAt`, `expiresAt`.
Подробности полей — в [domain-model.md](domain-model.md). Подробности полей — в [domain-model.md](domain-model.md).
## Флоу 1 — Привязка Telegram к аккаунту ## Флоу 1 — Привязка Telegram к аккаунту
Предусловие: пользователь уже вошёл на сайте (изначально аккаунт создаётся с username+пароль). Предусловие: пользователь уже вошёл на сайте.
1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token``{ deepLink }` 1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token``{ deepLink, expiresAt }`,
вида `https://t.me/<bot>?start=link_<token>` (+ QR). Токен короткоживущий, одноразовый. `deepLink` вида `https://t.me/<bot>?start=link_<token>`. Фронт рисует QR из `deepLink` сам
(`qrcode.react`) — бэкенд картинку не генерирует.
2. Пользователь открывает бота по ссылке → `/start link_<token>`. 2. Пользователь открывает бота по ссылке → `/start link_<token>`.
3. Бот берёт `from.id` (Telegram user id), валидирует токен (`LinkTelegramCommand`), проставляет 3. Бот берёт `from.id`, вызывает `LinkTelegramCommand(token, telegramUserId, telegramUsername)`
`TelegramUserId`/`TelegramUsername`/`TelegramLinkedAt`, гасит токен. валидирует токен, проставляет `TelegramUserId`/`TelegramUsername`/`TelegramLinkedAt`, гасит токен.
4. Бот подтверждает: «Аккаунт привязан». Сайт узнаёт об успехе (поллинг статуса или SignalR). 4. Бот отвечает «✅ Telegram успешно привязан к вашему аккаунту» (или текст ошибки). Сайт узнаёт об
успехе поллингом статуса активации/профиля.
Инварианты: один `TelegramUserId` ↔ один аккаунт; повторная привязка требует отвязки; токен Инварианты: один `TelegramUserId` ↔ один аккаунт; токен одноразовый и истекает.
нельзя переиспользовать и он истекает.
## Флоу 2 — Passwordless-вход через бота ## Флоу 2 — Passwordless-вход через бота
Предусловие: Telegram уже привязан к аккаунту. Предусловие: Telegram уже привязан к аккаунту.
1. На сайте «Войти через Telegram» → `POST /api/auth/telegram/login-request` 1. На сайте «Войти через Telegram» → `POST /api/auth/telegram/login-request`
`{ requestId, deepLink, qr, expiresAt }`. Сайт начинает ждать результат (поллинг `{ requestId, deepLink, expiresAt }`. Сайт начинает поллить
`GET /api/auth/telegram/login-request/{requestId}` или событие SignalR). `GET /api/auth/telegram/login-request/{requestId}`.
2. Пользователь открывает `https://t.me/<bot>?start=login_<nonce>` → бот по `from.id` находит 2. Пользователь открывает `https://t.me/<bot>?start=login_<requestId>` → бот по `from.id` находит
привязанный аккаунт и показывает inline-кнопки **«Подтвердить вход / Отклонить»** привязанный аккаунт (если не найден — просит сначала привязать) и показывает сообщение
(с деталями: время, IP/устройство инициатора — для защиты от фишинга). «Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?» с инлайн-кнопками
3. Подтверждение (`ApproveTelegramLoginCommand`) → запрос переходит в `Approved`, привязывается к `userId`. **«✅ Подтвердить вход» / «❌ Отклонить»**.
4. Сайт (по поллингу/SignalR) получает результат: бэкенд выпускает **стандартные JWT** 3. Подтверждение → `ApproveTelegramLoginCommand`/`RejectTelegramLoginCommand` → запрос переходит в
access в теле ответа, refresh в httpOnly cookie. Запрос помечается `Consumed`. `Approved`/`Rejected`.
4. Сайт по следующему поллингу получает результат: при `Approved``accessToken` в теле,
`refresh` уже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включая
`Secure = request.IsHttps`). Запрос помечается `Consumed`.
Если Telegram **не привязан**passwordless-вход невозможен (бот предлагает сперва привязать Если Telegram **не привязан**бот сразу сообщает «Сначала привяжите Telegram к аккаунту на сайте»,
аккаунт). Регистрация целиком через Telegram — вне MVP (см. backlog). подтвердить вход невозможно. Регистрация целиком через Telegram — вне MVP.
## Флоу 3 — Просмотр конфигов в боте ## Флоу 3 — Просмотр конфигов в боте
1. Привязанный пользователь: `/configs` или кнопка «Мои конфиги». 1. Привязанный пользователь: `/configs`.
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от имени `AppUser`, найденного по `TelegramUserId`. 2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
3. Ответ — список с трафиком/сроком/статусом; по каждому конфигу — inline-кнопки «Ссылка», «QR». 3. Ответ — обычное текстовое сообщение, по строке на конфиг:
QR отдаётся как изображение (генерация на сервере). `• {Label ?? Location} ({Protocol}) — {Status}`. Если конфигов нет — «У вас пока нет конфигов.»
**Ссылок, QR и кнопок здесь нет** — за подключением пользователь идёт на сайт.
## Флоу 4 — Обработка активации админом в боте ## Флоу 4 — Обработка активации админом в боте
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`); 1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`)
доменное событие `ActivationRequested`. `RequestActivationCommandHandler` шлёт SignalR `activationRequested` группе `admins` **и** вызывает
2. Бот шлёт сообщение каждому админу (Telegram id из `AdminSeed__TelegramUserIds`) с username/комментарием `ITelegramNotifier.NotifyAdminsActivationRequestedAsync` (прямой вызов из хендлера, без диспетчера событий).
заявителя и inline-кнопками **«✅ Активировать / ❌ Отклонить»**. 2. Каждому админу (по `Telegram__AdminTelegramUserIds`) уходит сообщение с именем и комментарием
3. Нажатие → `ApproveActivationCommand`/`RejectActivationCommand` (те же, что на сайте) → пользователь заявителя и кнопками **«✅ Активировать / ❌ Отклонить»**.
активируется, ему уходит realtime-пуш `userActivated`, админам обновляется сообщение (решение зафиксировано). 3. Нажатие → `ApproveActivationCommand`/`RejectActivationCommand` (те же, что на сайте) →
4. Действие доступно только Telegram id из списка админов; проверка — на стороне бота перед вызовом команды. пользователь активируется/отклоняется, ему уходит realtime `userActivated` (только при одобрении) +
Telegram-DM, если привязан; нажавшему админу приходит короткое подтверждение («✅ Пользователь
активирован.»/«❌ Запрос отклонён.») — само сообщение с кнопками не редактируется.
4. Проверка прав — на стороне бота (`TrySetAdminCurrentUserAsync`) перед вызовом команды: Telegram id
должен быть в `Telegram__AdminTelegramUserIds`.
`/requests` — тот же список запросов по требованию (до 10 штук, `Pending`), с теми же кнопками; для
кого он доступен — та же проверка админ-id.
## Команды и клавиатуры ## Команды и клавиатуры
| Команда / кнопка | Действие | Требует привязки | | Команда / кнопка | Действие | Требует привязки |
| ---------------------- | -------------------------------------------------------------- | ---------------- | | -------------------------- | -------------------------------------------------------------- | ----------------- |
| `/start` | Приветствие + справка по командам | нет | | `/start` | Приветствие + справка по командам | нет |
| `/start link_<t>` | Привязка аккаунта по токену | нет | | `/start link_<token>` | Привязка аккаунта по токену | нет |
| `/start login_<n>` | Подтверждение passwordless-входа (deep-link с сайта) | да | | `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
| `/configs` | Список конфигов | да | | `/configs` | Текстовый список конфигов | да |
| `/unlink` | Отвязать Telegram от аккаунта | да | | `/unlink` | Отвязать Telegram от аккаунта | да |
| `/help` | Справка | нет | | `/help` | Справка (то же сообщение, что `/start`) | нет |
| «Активировать/Отклонить» | (admin) решение по запросу активации | админ по env | | «Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
| `/requests` | (admin) список ожидающих запросов активации | админ по env | | `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
> Passwordless-вход и `/resetpassword` инициируются с сайта (кнопка «Войти через Telegram»), Любой другой текст → «Не понимаю эту команду. /help — список команд.»
> не отдельной командой бота — см. пункт 6 выше про `/resetpassword` (backlog).
## Безопасность ## Безопасность
- Токены привязки и nonce входа: высокоэнтропийные, **короткоживущие** (≈25 мин), **одноразовые**. - Токены привязки и `requestId` входа: высокоэнтропийные, короткоживущие, одноразовые (см.
- Подтверждение входа показывает контекст (время/устройство) — защита от несанкционированных запросов. `TelegramLinkToken`/`TelegramLoginRequest` в [domain-model.md](domain-model.md) — точный TTL не
- Верификация источника апдейтов: webhook — секретный заголовок; long polling — прямой канал к Bot API по TLS. вынесен в отдельное конфигурируемое значение, см. [tech-stack.md](tech-stack.md)).
- Rate-limiting на создание login/link-запросов и на команды бота. - Подтверждение входа **не показывает** контекст (время/IP/устройство) инициатора — поле `Context`
- Токен бота — секрет (env/secret-store), в логи не попадает; апдейты логируются без чувствительных данных. собирается (`CreateLoginRequestCommand`), но в текст сообщения бота не подставляется. Если это
- Passwordless-вход выпускает те же JWT/refresh, что и обычный — единые правила сессий и ротации. важно для защиты от фишинга — доработка на будущее, не текущее поведение.
- Альтернатива боту для веб-входа — официальный **Telegram Login Widget** (HMAC-подпись данных - Транспорт — только long polling: прямой канал к Bot API по TLS, без верификации webhook-заголовка
ботом, верификация на бэке). Оставлено как опция; основной путь — подтверждение в боте. (webhook не реализован).
- `Telegram:BotToken` — секрет (env/secret-store), в логи не попадает; при пустом токене
`TelegramBotClient` конструируется с синтаксической заглушкой вместо падения при старте — реальный
HTTP-вызов всё равно не происходит, т.к. `TelegramBotHostedService` и `TelegramNotifier` сами
проверяют `BotToken` перед использованием клиента.
- Passwordless-вход выпускает те же JWT/refresh, что и обычный (тот же `AuthResult`, та же cookie-логика).
- Явного rate-limit на команды бота нет (в отличие от HTTP-эндпоинтов `/api/auth/*`).
## Конфигурация ## Конфигурация
Реальные поля `TelegramOptions` (секция `Telegram`):
```jsonc ```jsonc
"Telegram": { "Telegram": {
"BotToken": "…", // секрет (env/secret-store) "BotToken": "…", // секрет; пусто = бот не стартует
"BotUsername": "PnvPanelBot", "BotUsername": "PnvPanelBot", // для deepLink; null/пусто -> deepLink в ответах API тоже null
"Mode": "LongPolling", // или "Webhook" "AdminTelegramUserIds": "123456789,987654321" // через запятую
"WebhookUrl": null, // "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код,
"WebhookSecret": null, // не задавай его — эффекта не будет)
"PublicSiteUrl": "https://panel.example.com"
} }
``` ```
Telegram id администраторов задаются отдельно — `AdminSeed__TelegramUserIds` (см. Переменные окружения — `Telegram__BotToken`, `Telegram__BotUsername`, `Telegram__AdminTelegramUserIds`
[`.env.example`](../.env.example)); именно они авторизуют админ-кнопки в боте и получают (см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют,
уведомления о запросах активации. кому слать уведомления о запросах активации**не** сидируются в БД и не связаны с учёткой
сид-админа (`AdminSeed:*`), это независимый список.
Сообщения бота локализованы (**RU/EN**) по языку пользователя, синхронно с настройкой языка в вебе. Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом —
Строго типизированные `IOptions<TelegramOptions>` с валидацией на старте; при отсутствии не реализована, backlog.
`BotToken` бот не стартует (панель работает без него).
+4 -2
View File
@@ -75,7 +75,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
### U2. Пользователь следит за трафиком ### U2. Пользователь следит за трафиком
- Фоновая синхронизация тянет трафик из 3x-ui; изменения приходят в UI через SignalR (без перезагрузки). - Фоновая синхронизация тянет трафик из 3x-ui; изменения приходят в UI через SignalR (без перезагрузки).
- При достижении лимита/срока конфиг помечается и (опционально) отключается в 3x-ui. - **В MVP это только отображение**: лимиты по трафику/сроку конфига не реализованы — единственная
квота — число активных конфигов на роль. Конфиг живёт, пока его явно не отзовут.
### A1. Админ подключает ноду и публикует инбаунды ### A1. Админ подключает ноду и публикует инбаунды
1. Вводит адрес панели 3x-ui, логин/пароль (шифруются при хранении). 1. Вводит адрес панели 3x-ui, логин/пароль (шифруются при хранении).
@@ -121,7 +122,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
## Нефункциональные требования ## Нефункциональные требования
- **Безопасность**: секреты нод шифруются at-rest; JWT с коротким TTL + refresh; rate-limiting на создание конфигов и auth. - **Безопасность**: секреты нод шифруются at-rest; JWT с коротким TTL + refresh; rate-limiting на
auth/Telegram-эндпоинты и публичную подписку (создание конфигов им пока не покрыто).
- **Наблюдаемость**: структурные логи (Serilog), health-checks нод, метрики. - **Наблюдаемость**: структурные логи (Serilog), health-checks нод, метрики.
- **Отказоустойчивость к нодам**: недоступность одной ноды не роняет панель; операции идемпотентны где возможно. - **Отказоустойчивость к нодам**: недоступность одной ноды не роняет панель; операции идемпотентны где возможно.
- **Производительность**: списки с пагинацией; синхронизация трафика батчами. - **Производительность**: списки с пагинацией; синхронизация трафика батчами.
-32
View File
@@ -1,32 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.