Refactor environment configuration and update documentation for MVP status
- 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:
+8
-3
@@ -1,16 +1,21 @@
|
||||
# PnvPanel — Документация
|
||||
|
||||
Индекс проектной документации. Читать в этом порядке для погружения:
|
||||
**MVP реализован** (backend M0–M8 + полный frontend). Документация ниже описывает систему как она
|
||||
реально построена, со сверенными по коду деталями — не первоначальный план. Расхождения с ранним
|
||||
замыслом отмечены явно там, где это важно (например, домен изначально закладывал доменные события —
|
||||
в реализации от них отказались в пользу прямых вызовов из CQRS-хендлеров, см. [architecture.md](architecture.md)).
|
||||
|
||||
Индекс. Читать в этом порядке для погружения:
|
||||
|
||||
1. **[Product Vision & Scope](vision.md)** — продукт, роли, пользовательские сценарии, границы MVP.
|
||||
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)** — принятые решения по технологиям и их обоснование.
|
||||
5. **[Backend Conventions](backend-conventions.md)** — структура решения, паттерны, соглашения по коду.
|
||||
6. **[Frontend](frontend.md)** — стек, структура, работа с API и realtime.
|
||||
7. **[Telegram Bot](telegram-bot.md)** — бот: ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram.
|
||||
8. **[API Design](api-design.md)** — контракты REST и SignalR.
|
||||
9. **[Roadmap](roadmap.md)** — этапы (milestones) и порядок реализации.
|
||||
9. **[Roadmap](roadmap.md)** — ретроспектива по этапам (milestones) + backlog.
|
||||
|
||||
## Принятые решения
|
||||
|
||||
|
||||
+150
-121
@@ -1,181 +1,210 @@
|
||||
# API Design
|
||||
|
||||
REST поверх HTTP/JSON, авторизация — `Authorization: Bearer <access-token>` (кроме публичных).
|
||||
Ошибки — `application/problem+json` (`ProblemDetails`). Пагинация — `?page=&pageSize=`,
|
||||
ответ `PagedList<T>` (`items`, `total`, `page`, `pageSize`). Все даты — ISO-8601 UTC.
|
||||
REST поверх HTTP/JSON, авторизация — `Authorization: Bearer <access-token>` (кроме публичных
|
||||
эндпоинтов). Ошибки — `application/problem+json`. Пагинация — `?page=&pageSize=`, ответ `PagedList<T>`
|
||||
(`items`, `total`, `page`, `pageSize`) — используется не везде, см. таблицы ниже. Все даты — ISO-8601 UTC.
|
||||
Тела запросов/ответов — camelCase JSON; енумы сериализуются строками (`"Active"`, не `0`).
|
||||
|
||||
Базовый префикс: `/api` (**без версионирования в MVP** — единый фронт+бек; версии введём при
|
||||
необходимости). Ниже — контракт MVP (может уточняться при реализации).
|
||||
Базовый префикс: `/api` (**без версионирования в MVP**). Схема генерируется нативным
|
||||
`Microsoft.AspNetCore.OpenApi` (`/openapi/v1.json`) и Scalar UI (`/scalar`) — каждый эндпоинт
|
||||
аннотирован `.Produces<T>()`, так что схема полностью описывает и тела запросов, и тела ответов.
|
||||
Ниже — полный контракт, сверенный построчно с кодом (`backend/src/PnvPanel.Api/Endpoints/*.cs`).
|
||||
|
||||
## Auth
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ----- | --------------------------- | ------ | ---------------------------------------------------- |
|
||||
| POST | `/api/auth/register` | — | Регистрация `{ username, password }` |
|
||||
| POST | `/api/auth/login` | — | Вход `{ username, password }` → access (body) + refresh (httpOnly cookie) |
|
||||
| POST | `/api/auth/refresh` | — | Обновление access по refresh-cookie (ротация) |
|
||||
| POST | `/api/auth/logout` | user | Отзыв refresh-токена |
|
||||
| POST | `/api/auth/change-password` | user | Смена пароля `{ currentPassword, newPassword }` |
|
||||
| GET | `/api/auth/me` | user | Текущий профиль + роль + `isActivated` + `telegramLinked` |
|
||||
| DELETE| `/api/auth/me` | user | Самоудаление аккаунта (отзыв всех конфигов + удаление данных; аудит анонимизируется) |
|
||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||
| ----- | --------------------------- | ------ | ------------------------------------------ | -------------------------------------- |
|
||||
| POST | `/api/auth/register` | — | `{ userName, password }` | `{ id, userName }` |
|
||||
| POST | `/api/auth/login` | — | `{ userName, password }` | `{ accessToken, expiresAt, user }` + refresh в httpOnly cookie |
|
||||
| POST | `/api/auth/refresh` | — | — (refresh из cookie) | то же, что login; ротация cookie |
|
||||
| POST | `/api/auth/logout` | user | — | `204 No Content` |
|
||||
| POST | `/api/auth/change-password` | user | `{ currentPassword, newPassword }` | `204 No Content` |
|
||||
| GET | `/api/auth/me` | user | — | `{ id, userName, role, isActivated, telegramLinked }` |
|
||||
| DELETE| `/api/auth/me` | user | — | `204 No Content` |
|
||||
|
||||
> **Вход по username.** Email в системе не используется. Забыт пароль:
|
||||
> при привязанном Telegram — восстановление через бота; иначе — сброс админом (см. Admin).
|
||||
`user`/ответ `/me` — **`role` строкой** (одна роль, не массив). Группа `/api/auth/*` под общим
|
||||
rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 запросов/мин).
|
||||
|
||||
> **Вход по username.** Email в системе не используется. Забыт пароль: при привязанном
|
||||
> Telegram — вход без пароля через бота и смена пароля в настройках; иначе — сброс админом (см. Admin).
|
||||
|
||||
## Auth — Telegram (привязка и passwordless-вход)
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ----- | --------------------------------------------- | ---- | -------------------------------------------------------------- |
|
||||
| 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 |
|
||||
Группа `/api/auth/telegram/*`, тот же rate-limit, что и `/api/auth/*`.
|
||||
|
||||
`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
|
||||
// ожидание
|
||||
{ "status": "Pending" }
|
||||
// подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос → Consumed
|
||||
{ "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "roles": ["User"] } }
|
||||
// отклонено / истекло
|
||||
{ "status": "Rejected" } // | "Expired"
|
||||
// ожидание / отклонено / истекло — accessToken/expiresAt/user всегда null, кроме Approved
|
||||
{ "status": "Pending", "accessToken": null, "expiresAt": null, "user": null }
|
||||
// подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос помечается Consumed
|
||||
{ "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "userName": "…", "role": "user", "isActivated": true, "telegramLinked": true } }
|
||||
```
|
||||
`status` — одно из `Pending`/`Approved`/`Rejected`/`Expired`/`Consumed`.
|
||||
|
||||
> Сами апдейты Telegram (`/start`, кнопки) обрабатывает in-process бот (long polling), а не HTTP-эндпоинты.
|
||||
> Контракты команд бота — в [telegram-bot.md](telegram-bot.md).
|
||||
> Апдейты Telegram (`/start`, кнопки, `/configs`) обрабатывает in-process бот (long polling), а не
|
||||
> HTTP-эндпоинты. Команды бота — в [telegram-bot.md](telegram-bot.md).
|
||||
|
||||
## Configs (пользователь)
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ------ | --------------------------------- | ---- | ------------------------------------------------- |
|
||||
| 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 агрегированной подписки пользователя (все активные конфиги) |
|
||||
Группа `/api` (не вложена дальше), `RequireAuthorization()`.
|
||||
|
||||
`POST /api/configs` → `201 Created`:
|
||||
```json
|
||||
{
|
||||
"id": "…", "protocol": "Vless", "location": "DE",
|
||||
"link": "vless://…", "subscriptionUrl": "https://…/sub/…",
|
||||
"trafficLimitBytes": 53687091200, "expiresAt": "2026-08-01T00:00:00Z",
|
||||
"status": "Active"
|
||||
}
|
||||
```
|
||||
| Метод | Путь | Тело запроса | Тело ответа |
|
||||
| ------ | --------------------------------- | ------------------------------------ | --------------------------------------- |
|
||||
| GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` |
|
||||
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }` — **без пагинации**, весь список сразу |
|
||||
| POST | `/api/configs` | `{ inboundId, label?, deviceLimit? }`| `VpnConfigDto` (`200 OK`, не 201) |
|
||||
| PATCH | `/api/configs/{id}` | `{ label?, deviceLimit? }` | `VpnConfigDto` |
|
||||
| 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 — каталог приложений
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ------ | -------------------------- | ----- | ---------------------------------------------------- |
|
||||
| GET | `/api/apps` | user | Включённые приложения, **сгруппированы по ОС** (для страницы инструкций) |
|
||||
| GET | `/api/admin/apps` | admin | Все приложения (вкл. выключенные) |
|
||||
| POST | `/api/admin/apps` | admin | Добавить `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder? }` |
|
||||
| PUT | `/api/admin/apps/{id}` | admin | Изменить приложение (в т.ч. `isEnabled`) |
|
||||
| DELETE | `/api/admin/apps/{id}` | admin | Удалить приложение |
|
||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||
| ------ | -------------------------- | ----- | --------------------------------------------------------------------------------- | ------------- |
|
||||
| GET | `/api/apps` | user | — | `Record<OsPlatform, ClientAppDto[]>` |
|
||||
| GET | `/api/admin/apps` | admin | — | `AdminAppDto[]` (вкл. выключенные) |
|
||||
| POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder }` | `AdminAppDto` |
|
||||
| PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled }` | `AdminAppDto` |
|
||||
| DELETE | `/api/admin/apps/{id}` | admin | — | `204 No Content` |
|
||||
|
||||
`GET /api/apps` → пример:
|
||||
`GET /api/apps` → пример (только `isEnabled == true`, ОС без приложений в ответе отсутствует):
|
||||
```json
|
||||
{
|
||||
"Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "iconUrl": null } ],
|
||||
"iOS": [ { "id": "…", "name": "Hiddify", "downloadUrl": "https://…", "iconUrl": null } ]
|
||||
"Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "description": null, "iconUrl": null } ],
|
||||
"IOS": [ { "id": "…", "name": "Hiddify", "downloadUrl": "https://…", "description": null, "iconUrl": null } ]
|
||||
}
|
||||
```
|
||||
Значение `OsPlatform` в C#/JSON — `IOS` (не `iOS`).
|
||||
|
||||
## Activation (пользователь)
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ----- | --------------------------- | ---- | ------------------------------------------------------ |
|
||||
| GET | `/api/activation/status` | user | Статус активации + текущий `Pending`-запрос (если есть)|
|
||||
| POST | `/api/activation/request` | user | Запросить активацию `{ comment? }` (напр. «я Никита») |
|
||||
|
||||
`GET /api/configs` для неактивированного пользователя вернёт пустой список; `POST /api/configs`
|
||||
до активации → `403` (или `409` с кодом `NotActivated`).
|
||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||
| ----- | --------------------------- | ---- | ----------------- | -------------------------------------------------------- |
|
||||
| GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
|
||||
| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
|
||||
|
||||
## Admin — Activation, Roles
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ------ | ----------------------------------------------- | ----- | --------------------------------------------------- |
|
||||
| GET | `/api/admin/activation-requests` | admin | Список запросов активации (фильтр по статусу) |
|
||||
| POST | `/api/admin/activation-requests/{id}/approve` | admin | Одобрить → пользователь активирован |
|
||||
| POST | `/api/admin/activation-requests/{id}/reject` | admin | Отклонить `{ reason? }` |
|
||||
| GET | `/api/admin/roles` | admin | Список ролей с квотами |
|
||||
| POST | `/api/admin/roles` | admin | Создать роль `{ name, maxConfigs }` |
|
||||
| PUT | `/api/admin/roles/{id}` | admin | Изменить роль (напр. `maxConfigs`) |
|
||||
| DELETE | `/api/admin/roles/{id}` | admin | Удалить роль (нельзя системные `admin`/`user`) |
|
||||
| PATCH | `/api/admin/users/{id}/role` | admin | Сменить роль пользователю `{ roleId }` (ровно одна) |
|
||||
| PATCH | `/api/admin/users/{id}/activation` | admin | Активировать/деактивировать напрямую `{ isActivated }`|
|
||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||
| ------ | ----------------------------------------------- | ----- | ----------------------- | ------------- |
|
||||
| GET | `/api/admin/activation-requests` | admin | query: `statusFilter?, page=1, pageSize=20` | `PagedList<ActivationRequestAdminDto>` |
|
||||
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
||||
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
||||
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
||||
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs }` | `RoleDto` |
|
||||
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs }` | `RoleDto` |
|
||||
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
||||
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` |
|
||||
|
||||
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
|
||||
approve/reject над `ActivationRequest`.
|
||||
|
||||
## Admin — Nodes
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ------ | ----------------------------- | ----- | ----------------------------------------- |
|
||||
| GET | `/api/admin/nodes` | admin | Список нод + статусы |
|
||||
| POST | `/api/admin/nodes` | admin | Подключить ноду `{ name, baseAddress, username, password, location }` |
|
||||
| PUT | `/api/admin/nodes/{id}` | admin | Изменить ноду (в т.ч. `isEnabled`) |
|
||||
| DELETE | `/api/admin/nodes/{id}` | admin | Удалить ноду |
|
||||
| POST | `/api/admin/nodes/{id}/sync` | admin | Пересинхронизировать inbounds с 3x-ui |
|
||||
| POST | `/api/admin/nodes/{id}/probe` | admin | Проверить доступность |
|
||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||
| ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
|
||||
| GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
|
||||
| POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `NodeDto` |
|
||||
| PUT | `/api/admin/nodes/{id}` | admin | `{ name, location?, isEnabled, username?, password? }` | `NodeDto` |
|
||||
| DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` |
|
||||
| POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
|
||||
| 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
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ----- | -------------------------------------- | ----- | ---------------------------------------- |
|
||||
| GET | `/api/admin/inbounds` | admin | Список inbounds (по нодам) + `allowedRoleIds`, `displayName` |
|
||||
| PUT | `/api/admin/inbounds/{id}/publish` | admin | Опубликовать/снять `{ isPublished, displayName?, allowedRoleIds[], maxClients? }` |
|
||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||
| ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
|
||||
| GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` |
|
||||
| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds?, maxClients? }` | `InboundDto` |
|
||||
|
||||
## Admin — Users & Stats
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| ----- | --------------------------------- | ----- | ----------------------------------------- |
|
||||
| GET | `/api/admin/users` | admin | Пользователи (пагинация, поиск) |
|
||||
| PATCH | `/api/admin/users/{id}/block` | admin | Блокировать/разблокировать `{ isBlocked }` (при блоке — отключить конфиги в 3x-ui) |
|
||||
| POST | `/api/admin/users/{id}/reset-password` | admin | Сбросить пароль пользователю без привязки Telegram (выдать временный/задать новый) |
|
||||
| GET | `/api/admin/users/{id}/configs` | admin | Конфиги пользователя |
|
||||
| DELETE| `/api/admin/configs/{id}` | admin | Принудительно отозвать любой конфиг |
|
||||
| GET | `/api/admin/stats` | admin | Сводная статистика (пользователи, конфиги, трафик) |
|
||||
| GET | `/api/admin/audit` | admin | Журнал действий (`AuditLog`, пагинация, фильтры) |
|
||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||
| ------ | ---------------------------------------- | ----- | --------------------------- | ------------- |
|
||||
| GET | `/api/admin/users` | admin | query: `page, pageSize, search?` | `PagedList<UserSummaryDto>` |
|
||||
| PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` |
|
||||
| PATCH | `/api/admin/users/{id}/unblock` | admin | — | `204 No Content` |
|
||||
| POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` |
|
||||
| GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` |
|
||||
| DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) |
|
||||
| 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
|
||||
|
||||
| Метод | Путь | Роль | Описание |
|
||||
| Метод | Путь | Роль | Ответ |
|
||||
| ----- | ----------------- | ---- | ---------------------------------------------------------------- |
|
||||
| GET | `/sub/{token}` | — | Подписка (base64-список ссылок). Токен — либо `AppUser.SubscriptionToken` (**все активные конфиги юзера**), либо `VpnConfig.SubscriptionToken` (**один конфиг**). Без `/api`. |
|
||||
| GET | `/sub/{token}` | — | `text/plain`, base64 от списка connection strings, `\n`-разделены |
|
||||
|
||||
Rate-limited; отключённые/отозванные конфиги в выдачу не попадают; неизвестный/погашенный токен → 404.
|
||||
Ответ отдаёт заголовок **`Subscription-Userinfo`** (`upload`/`download`/`total`/`expire`) — клиенты
|
||||
(v2rayN/Nekoray и т.п.) показывают остаток трафика/срок. Также `profile-update-interval`.
|
||||
Вне `/api` (публичный эндпоинт для VPN-клиентов), под тем же rate-limit'ом, что и `/api/auth/*`.
|
||||
Токен — либо `AppUser.SubscriptionToken` (**все активные конфиги юзера**), либо
|
||||
`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`
|
||||
|
||||
Авторизация — тем же JWT (query `access_token` или заголовок). Группы: `user:{userId}`, `admins`.
|
||||
Авторизация — тем же JWT. Группы: `user:{userId}` (личные события), `admins` (админам).
|
||||
|
||||
### Server → Client
|
||||
|
||||
| Событие | Payload | Кому |
|
||||
| ---------------------- | ------------------------------------------------------------- | ------------ |
|
||||
| `configTrafficUpdated` | `{ configId, usedUpBytes, usedDownBytes, limitBytes }` | владельцу |
|
||||
| `configTrafficUpdated` | `{ configId, usedUpBytes, usedDownBytes }` | владельцу |
|
||||
| `configStatusChanged` | `{ configId, status }` | владельцу |
|
||||
| `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` |
|
||||
| `activationRequested` | `{ requestId, userId, username, comment, createdAt }` | `admins` |
|
||||
| `userActivated` | `{ userId }` | владельцу |
|
||||
| `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` |
|
||||
| `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` |
|
||||
| `userActivated` | `{ userId }` | владельцу |
|
||||
|
||||
### Client → Server
|
||||
MVP — клиент только слушает (группировка по пользователю на сервере при подключении по `UserId` из JWT).
|
||||
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
|
||||
`UserId` из JWT (плюс `admins`, если роль админская).
|
||||
|
||||
## Коды ошибок
|
||||
|
||||
| Код | Когда |
|
||||
| --- | -------------------------------------------------- |
|
||||
| 400 | Ошибка валидации (`errors` в ProblemDetails) |
|
||||
| 401 | Нет/просрочен токен |
|
||||
| 403 | Нет прав (роль/владение/не активирован/роль без доступа к инбаунду) |
|
||||
| 404 | Ресурс не найден |
|
||||
| 409 | Конфликт домена (превышена квота роли, дубликат, уже есть Pending-запрос активации) |
|
||||
| 422 | Нарушение инварианта домена |
|
||||
| 429 | Rate limit |
|
||||
| 502 | Ошибка/недоступность ноды 3x-ui (при необходимости)|
|
||||
| Код | Когда |
|
||||
| --- | -------------------------------------------------------------------- |
|
||||
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
|
||||
| 401 | Нет/просрочен/невалиден access-токен |
|
||||
| 403 | Нет прав по роли, либо `Configs.NotActivated` |
|
||||
| 404 | Ресурс не найден |
|
||||
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации |
|
||||
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
|
||||
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
|
||||
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
|
||||
|
||||
`502`/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится `Result.Failure` и
|
||||
маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.
|
||||
|
||||
+147
-80
@@ -25,10 +25,11 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
||||
│ implements ports │ uses
|
||||
┌───────────────▼───────────────┐ ┌────────────▼──────────────────────────┐
|
||||
│ PnvPanel.Infrastructure │ │ PnvPanel.Domain │
|
||||
│ EF Core (Npgsql) · Identity · │ │ Entities · Value Objects · Domain │
|
||||
│ JWT · XuiPanelGateway · │◄──┤ Events · Enums · Domain Exceptions │
|
||||
│ Background sync · SignalR push│ │ (no external dependencies) │
|
||||
│ EF Core (Npgsql) · Identity · │ │ Entities · Value Object · Enums · │
|
||||
│ JWT · XuiPanelGateway · │◄──┤ Domain Exceptions │
|
||||
│ Background sync · Telegram │ │ (no external dependencies) │
|
||||
└───────────────┬────────────────┘ └───────────────────────────────────────┘
|
||||
(SignalR-хаб/пуш — в PnvPanel.Api, см. ниже)
|
||||
│
|
||||
┌───────────▼──────────┐ ┌──────────────────────────┐
|
||||
│ PostgreSQL │ │ 3x-ui panels (nodes) │
|
||||
@@ -43,51 +44,83 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
||||
`Application`, реализуемые в `Infrastructure`.
|
||||
|
||||
### 1. `PnvPanel.Domain`
|
||||
Ядро без внешних зависимостей (маркерный интерфейс доменных событий `IDomainEvent` — свой, в `Domain/Common`).
|
||||
Ядро без внешних зависимостей. Никакого диспетчера доменных событий нет — это сознательное упрощение
|
||||
относительно исходного плана, см. ниже.
|
||||
|
||||
- **Entities**: `Node`, `Inbound`, `VpnConfig`, `Plan` (см. [domain-model.md](domain-model.md)).
|
||||
- **Value Objects**: `TrafficLimit`, `NodeCredentials`, `ConnectionLink` и т.п.
|
||||
- **Enums**: `VpnProtocol`, `ConfigStatus`, `NodeStatus`.
|
||||
- **Domain Events**: `VpnConfigCreated`, `VpnConfigRevoked`, `TrafficLimitReached`, `NodeWentOffline`.
|
||||
- **Domain Exceptions**: `DomainException` и специализированные (`ConfigQuotaExceededException`).
|
||||
- Инварианты и бизнес-правила инкапсулированы в сущностях (rich domain model), а не в хендлерах.
|
||||
- **Entities**: `Node`, `Inbound`, `VpnConfig`, `ActivationRequest`, `ClientApp`, `AuditLog`,
|
||||
`TelegramLinkToken`, `TelegramLoginRequest`, `TrafficSample` (см. [domain-model.md](domain-model.md)).
|
||||
- **Value Objects**: `NodeCredentials` (логин + зашифрованный пароль ноды). Это единственный VO —
|
||||
`TrafficLimit`/`ConnectionLink` из раннего плана не понадобились (лимиты трафика — backlog,
|
||||
connection string строит `IXuiPanelGateway` на лету).
|
||||
- **Enums**: `VpnProtocol`, `ConfigStatus`, `NodeStatus`, `ActivationStatus`, `AuditSource`,
|
||||
`TelegramLoginStatus`, `OsPlatform`.
|
||||
- **Domain Exceptions**: `DomainException` — брошенный при нарушении инварианта в самой сущности
|
||||
(например, `Revoke()` уже отозванного конфига); хендлеры такие нарушения не ожидают в норме.
|
||||
- Инварианты и бизнес-правила инкапсулированы в сущностях (rich domain model: приватные сеттеры,
|
||||
фабричные методы, поведенческие методы), а не в хендлерах.
|
||||
|
||||
> `AppUser` (Identity) живёт в `Infrastructure` (зависит от `IdentityUser`), а домен ссылается
|
||||
> на пользователя по `UserId` (Guid), чтобы не тащить Identity в ядро.
|
||||
> `AppUser`/`AppRole` (Identity) живут в `Infrastructure` (наследуют `IdentityUser<Guid>`/
|
||||
> `IdentityRole<Guid>`), а домен ссылается на пользователя/роль только по `Guid`, чтобы не тащить
|
||||
> Identity в ядро.
|
||||
|
||||
### 2. `PnvPanel.Application`
|
||||
Сценарии приложения через CQRS.
|
||||
|
||||
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы).
|
||||
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IJwtTokenService`,
|
||||
`ISecretProtector`, `IRealtimeNotifier`, `IDateTime`.
|
||||
- **Validators**: FluentValidation на каждую команду/запрос.
|
||||
- **DTOs** и профили маппинга (Mapster).
|
||||
- **Pipeline behaviors**: `ValidationBehavior`, `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция на команду), `AuthorizationBehavior`.
|
||||
- **Result<T>**: явная модель успеха/ошибки вместо исключений для управляемых сценариев.
|
||||
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы),
|
||||
организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
|
||||
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
|
||||
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`.
|
||||
- **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
|
||||
[backend-conventions.md](backend-conventions.md)).
|
||||
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
|
||||
DTO. Mapster из исходного плана не пригодился — при таком числе полей ручной маппинг читается
|
||||
не хуже конфига маппера и не создаёт лишней зависимости.
|
||||
- **Pipeline behaviors**: `ValidationBehavior`, `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция +
|
||||
`SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` нет — авторизация (роль,
|
||||
активация) — это либо `RequireAuthorization()`/`RequireRole(...)` на эндпоинте, либо явная проверка
|
||||
в начале хендлера (например, «инбаунд доступен роли пользователя»).
|
||||
- **Result<T>**: явная модель успеха/ошибки (`Result`/`Result<T>`, `Error` с `ErrorType`) вместо
|
||||
исключений для управляемых сценариев.
|
||||
|
||||
### 3. `PnvPanel.Infrastructure`
|
||||
Технические детали и реализации портов.
|
||||
|
||||
- **Persistence**: `AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>`, реализует `IAppDbContext`;
|
||||
`IEntityTypeConfiguration<T>` для маппингов; миграции EF Core; репозитории только там, где нужны
|
||||
(в основном хендлеры работают через `IAppDbContext` напрямую).
|
||||
- **Identity & Auth**: ASP.NET Core Identity, `JwtTokenService` (access + refresh), хранение refresh-токенов.
|
||||
- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; фабрика клиентов per-node (см. ниже).
|
||||
- **Realtime**: `SignalRRealtimeNotifier : IRealtimeNotifier` (пуш в хабы).
|
||||
- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService` (`BackgroundService` + `PeriodicTimer`).
|
||||
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest).
|
||||
`IEntityTypeConfiguration<T>` для маппингов; миграции EF Core. Репозиториев нет — хендлеры работают
|
||||
через `IAppDbContext` напрямую (`DbSet<T>` + LINQ).
|
||||
- **Identity & Auth**: ASP.NET Core Identity, `JwtTokenService` (access + refresh), `RefreshTokenService`
|
||||
(хранение/ротация/отзыв refresh-токенов), `RoleService`, `DbInitializer` (сидинг).
|
||||
- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; кэш клиентов per-node
|
||||
внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет).
|
||||
- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService`, `TrafficRetentionService`
|
||||
(`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)
|
||||
Композиционный корень и транспорт.
|
||||
|
||||
- **Minimal API** эндпоинты, сгруппированные по фичам (`MapAuthEndpoints`, `MapConfigEndpoints`, `MapAdminEndpoints`).
|
||||
- **SignalR Hubs**: `PanelHub`.
|
||||
- **Telegram-бот**: `TelegramBotHostedService` + хендлеры апдейтов в `Telegram/` (см. отдельный раздел).
|
||||
- **Minimal API**-эндпоинты, сгруппированные по фичам — 12 файлов в `Endpoints/`
|
||||
(`AuthEndpoints`, `ActivationEndpoints`, `ConfigEndpoints`, `AppEndpoints`, `SubscriptionEndpoints`,
|
||||
`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 (единый контейнер).
|
||||
- **Middleware**: обработка исключений → ProblemDetails, корреляция запросов, rate limiting.
|
||||
- **DI**: `AddApplication()`, `AddInfrastructure()`, `AddApiServices()` — сборка всех слоёв.
|
||||
- **OpenAPI**: Swashbuckle + Scalar UI; генерация схемы для codegen фронта.
|
||||
- **Ошибки**: встроенные `AddProblemDetails()` + `UseExceptionHandler()` (ASP.NET Core, без кастомного
|
||||
middleware) конвертируют необработанные исключения в `application/problem+json`.
|
||||
- **DI**: `AddApplication()` (Application), `AddInfrastructure()` (Infrastructure) + прямая регистрация
|
||||
в `Program.cs` для того, что специфично Api-слою (SignalR, Telegram-клиент, rate limiting).
|
||||
- **OpenAPI**: нативный `Microsoft.AspNetCore.OpenApi` (`/openapi/v1.json`) + `Scalar.AspNetCore`
|
||||
UI (`/scalar`) — без Swashbuckle.
|
||||
|
||||
## CQRS
|
||||
|
||||
@@ -97,36 +130,44 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
||||
через pipeline behaviors. Без внешних CQRS-библиотек (MediatR/и т.п.). Абстракции — `ICommand<T>`,
|
||||
`IQuery<T>`, `ICommandHandler<,>`, `IQueryHandler<,>`, `IPipelineBehavior<,>`.
|
||||
|
||||
Пример потока «создать конфиг»:
|
||||
Пример потока «создать конфиг» (`backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs`):
|
||||
```
|
||||
POST /api/configs
|
||||
→ CreateVpnConfigCommand
|
||||
→ ValidationBehavior (FluentValidation)
|
||||
→ AuthorizationBehavior (роль/владение)
|
||||
→ CreateVpnConfigHandler
|
||||
· проверяет квоту пользователя (домен)
|
||||
· IXuiPanelGateway.AddClientAsync(node, inbound, spec) // 3x-ui
|
||||
· создаёт VpnConfig, сохраняет через IAppDbContext
|
||||
· публикует VpnConfigCreated (domain event)
|
||||
→ UnitOfWorkBehavior (commit)
|
||||
→ 201 Created { id, link, subscriptionUrl }
|
||||
→ ValidationBehavior (FluentValidation — формат inboundId/label/deviceLimit)
|
||||
→ CreateVpnConfigCommandHandler
|
||||
· проверяет активацию + роль инбаунда (доменные проверки)
|
||||
· SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
|
||||
· пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs
|
||||
· IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId
|
||||
· VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext
|
||||
· при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync)
|
||||
→ UnitOfWorkBehavior (commit транзакции)
|
||||
→ 200 OK VpnConfigDto { id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes,
|
||||
expiresAt, status, createdAt }
|
||||
```
|
||||
Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно,
|
||||
`GET /api/configs/{id}/link`, по кнопке на карточке конфига (см. [api-design.md](api-design.md)).
|
||||
|
||||
## Интеграция с 3x-ui (ThreeXui.Net)
|
||||
|
||||
`ThreeXui.Net` конфигурируется на **один** `BaseAddress`, а у нас **несколько нод**. Поэтому:
|
||||
|
||||
- Порт `IXuiPanelGateway` инкапсулирует все операции с панелями и принимает `Node` (или его id):
|
||||
- Порт `IXuiPanelGateway` инкапсулирует все операции с панелями и принимает `Node`:
|
||||
`ListInboundsAsync`, `AddClientAsync`, `UpdateClientAsync`, `RemoveClientAsync`,
|
||||
`GetClientTrafficAsync`, `BuildConnectionStringAsync`, `ProbeAsync`.
|
||||
- `XuiPanelGateway` держит **фабрику/кэш `IXuiClient` per-node** (ключ — `NodeId`), создавая клиента
|
||||
из расшифрованных `NodeCredentials` через `XuiHttpClientFactory`/`HttpClient`. Cookie-session и
|
||||
авто-переавторизация на 401 обеспечиваются самой библиотекой.
|
||||
`GetClientTrafficAsync`, `BuildConnectionStringAsync`, `ProbeAsync`, `ValidateBaseAddress`,
|
||||
`InvalidateClient(nodeId)` (вызывается после смены креденшлов ноды).
|
||||
- `XuiPanelGateway` — единственная реализация, держит `ConcurrentDictionary<Guid, Lazy<IXuiClient>>`
|
||||
(ключ — `NodeId`), создавая клиента из расшифрованных `NodeCredentials` лениво при первом обращении
|
||||
к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`.
|
||||
- Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу.
|
||||
- Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды).
|
||||
- **Реконсиляция дрейфа**: 3x-ui — источник правды по клиентам. При синхронизации сверяем проекцию
|
||||
с панелью: клиент удалён/изменён напрямую в 3x-ui → помечаем конфиг рассинхронизованным
|
||||
(`Disabled`/флаг) и логируем; не «воскрешаем» молча. Наши записи о трафике/статусах обновляем из панели.
|
||||
- **Дрейф с 3x-ui в MVP не реконсилируется активно**: `TrafficSyncService` при недоступной ноде или
|
||||
при отсутствии клиента в ответе панели (`GetClientTrafficAsync`) просто пропускает его в этом цикле
|
||||
синхронизации — не помечает конфиг рассинхронизованным и не шлёт алерт. Если клиента удалили прямо
|
||||
в 3x-ui в обход панели, локальная запись `VpnConfig` продолжит существовать до следующего
|
||||
явного действия пользователя/админа (`Revoke`/`Rotate`), которое обнаружит несоответствие по ответу
|
||||
гейтвея. Активная сверка/алертинг по дрейфу — задел на будущее, не реализовано.
|
||||
|
||||
## Telegram-бот (presentation-адаптер)
|
||||
|
||||
@@ -134,7 +175,9 @@ POST /api/configs
|
||||
бизнес-правил). Полное описание — в [telegram-bot.md](telegram-bot.md). Ключевое для архитектуры:
|
||||
|
||||
- Хостится **в процессе Api** как `BackgroundService` (`TelegramBotHostedService`) — это условие
|
||||
для упаковки «фронт+бек в одном контейнере». Транспорт — **long polling** (MVP), webhook — опция.
|
||||
для упаковки «фронт+бек в одном контейнере». Транспорт — только **long polling**
|
||||
(`ITelegramBotClient.ReceiveAsync`); webhook рассматривался, но не реализован — конфигурации
|
||||
`Telegram:Mode`/`WebhookUrl` в коде нет.
|
||||
- Обращения к домену — только через собственный `ISender`, теми же командами/запросами, что и веб
|
||||
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). `Telegram.Bot`
|
||||
не проникает в Application/Domain.
|
||||
@@ -144,21 +187,25 @@ POST /api/configs
|
||||
## Realtime (SignalR)
|
||||
|
||||
- Хаб `PanelHub` (`/hubs/panel`), авторизация по тому же JWT.
|
||||
- **Группы**: `user:{userId}` (личные события), `admins` (события нод/системы).
|
||||
- **События сервер→клиент** (см. [api-design.md](api-design.md)): `configTrafficUpdated`,
|
||||
`configStatusChanged`, `nodeStatusChanged`.
|
||||
- Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`), вызываемый из хендлеров и
|
||||
фоновых сервисов — Application-слой не зависит от SignalR напрямую.
|
||||
- **Группы** (`GroupNames` в `Api/Hubs/PanelHub.cs`): `user:{userId}` (личные события), `admins`
|
||||
(события нод/системы/активации) — пользователь при подключении добавляется в свою `user:{userId}`
|
||||
и, если он админ, дополнительно в `admins`.
|
||||
- **События сервер→клиент**: `configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`,
|
||||
`activationRequested`, `userActivated` — точные payload'ы см. [api-design.md](api-design.md#signalr--hub-hubspanel).
|
||||
- Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`, реализация в `Api/Hubs/`),
|
||||
вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую.
|
||||
|
||||
## Фоновые задачи
|
||||
|
||||
- **TrafficSyncService** — периодически (`PeriodicTimer`) обходит активные ноды, тянет трафик по
|
||||
клиентам, обновляет `VpnConfig`, пишет `TrafficSample` (для графиков), шлёт realtime-события,
|
||||
помечает превышения (`TrafficLimitReached`).
|
||||
- **NodeHealthCheckService** — health-probe нод, обновляет `NodeStatus`, оповещает `admins`.
|
||||
клиентам через `IXuiPanelGateway.GetClientTrafficAsync`, пишет `VpnConfig.UpdateTraffic(...)` и
|
||||
`TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты
|
||||
и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)).
|
||||
- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`,
|
||||
шлёт `nodeStatusChanged` группе `admins`.
|
||||
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
|
||||
- Для MVP — встроенный `BackgroundService`; при росте нагрузки — вынести в Hangfire/Quartz
|
||||
(см. [tech-stack.md](tech-stack.md)).
|
||||
- Реализованы как обычные `BackgroundService` + `PeriodicTimer`, без внешнего джоб-раннера — для
|
||||
нагрузки MVP этого достаточно (см. [tech-stack.md](tech-stack.md)).
|
||||
|
||||
## Сидирование и старт
|
||||
|
||||
@@ -167,15 +214,19 @@ POST /api/configs
|
||||
|
||||
- **Системные роли**: `admin` (без лимита конфигов) и `user` (`MaxConfigs = Roles__DefaultUserMaxConfigs`, по умолчанию 3).
|
||||
- **Учётка администратора**: создаётся из `AdminSeed__Username` / `AdminSeed__Password`, если ещё нет;
|
||||
сразу активирована и с ролью `admin`.
|
||||
- **Telegram id админов** (`AdminSeed__TelegramUserIds`) — авторизуют админ-действия в боте и
|
||||
адресуют уведомления (например, запросы на активацию).
|
||||
сразу активирована и с ролью `admin`. Seed-админ **не привязывается к Telegram автоматически** —
|
||||
привязка делается вручную в UI, как у любого пользователя.
|
||||
- **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из
|
||||
[`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD.
|
||||
|
||||
Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе
|
||||
**нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении.
|
||||
|
||||
Отдельно от сидинга — **Telegram id админов** (`Telegram__AdminTelegramUserIds`, через запятую)
|
||||
читаются `TelegramOptions` **напрямую при каждой проверке** (не пишутся в БД): именно этот список
|
||||
авторизует нажатие «Активировать/Отклонить» в боте и определяет, кому слать уведомления о новых
|
||||
запросах активации.
|
||||
|
||||
## RBAC — динамические роли и активация
|
||||
|
||||
- `AppRole` расширяет `IdentityRole<Guid>` полем `MaxConfigs`. **У пользователя ровно одна роль**;
|
||||
@@ -189,34 +240,50 @@ POST /api/configs
|
||||
сохраняются, создание новых блокируется до входа в квоту.
|
||||
- **Блокировка пользователя**: `IsBlocked = true` → вход запрещён + все конфиги `Disabled` (отключение
|
||||
клиентов в 3x-ui); разблокировка — обратная операция. Пишется в `AuditLog`.
|
||||
- Уведомления: запросы активации → группа `admins` (SignalR) + Telegram (по `AdminSeed__TelegramUserIds`);
|
||||
решения/блокировки → пользователю (SignalR + Telegram-DM, если привязан).
|
||||
- Уведомления: запросы активации → группа `admins` (SignalR) + Telegram (по
|
||||
`Telegram__AdminTelegramUserIds`); решения/блокировки → пользователю (SignalR + Telegram-DM, если привязан).
|
||||
|
||||
## Безопасность
|
||||
|
||||
- **AuthN**: ASP.NET Core Identity + JWT, **вход по `UserName`** (email в системе не используется).
|
||||
Access-token — короткий TTL (in-memory на клиенте); refresh-token — httpOnly Secure cookie,
|
||||
ротация при использовании, хранение хэша в БД.
|
||||
- **Восстановление пароля**: через привязанный Telegram (passwordless-вход → смена пароля, либо
|
||||
reset-флоу в боте); без привязки — сброс админом (`ResetUserPasswordCommand`). Email/SMTP не используются.
|
||||
Смена пароля вошедшим — `POST /api/auth/change-password`.
|
||||
- **AuthZ**: роли (`admin`/`user`/кастомные) + policy-based (`OwnsConfig`, `RequireAdmin`,
|
||||
`RequireActivated`).
|
||||
- **Восстановление пароля**: через привязанный Telegram (passwordless-вход → смена пароля); без
|
||||
привязки — сброс админом (`ResetUserPasswordCommand`). Email/SMTP не используются. Смена пароля
|
||||
вошедшим — `POST /api/auth/change-password`.
|
||||
- **AuthZ**: именованных policy нет — либо `.RequireAuthorization()` (любой вошедший) или
|
||||
`.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))` на группе эндпоинтов, либо
|
||||
явная проверка внутри хендлера (владение конфигом — сравнение `VpnConfig.UserId` с `ICurrentUser`;
|
||||
активация — `ConfigErrors.NotActivated`).
|
||||
- **Секреты нод**: шифруются `ISecretProtector` (Data Protection) перед сохранением; в API/логи не попадают.
|
||||
- **CSRF**: refresh-cookie — `SameSite=Strict/Lax`, `Secure`, `HttpOnly`; для cookie-based refresh —
|
||||
анти-CSRF токен. Мутации — только по Bearer access-токену, не по cookie.
|
||||
- **Brute-force**: Identity lockout по числу неудачных входов; rate-limit на `/auth/*`.
|
||||
- **Rate limiting**: на `/auth/*`, создание/ротацию конфигов, запросы активации и Telegram (встроенный `RateLimiter` .NET).
|
||||
- **CSRF**: явного анти-CSRF токена нет — все мутации API читают авторизацию только из
|
||||
`Authorization: Bearer` (JS должен явно прочитать access-token из памяти и подставить заголовок,
|
||||
чужой сайт этого сделать не может). Refresh-cookie (`pnv_refresh_token`) — единственное, что браузер
|
||||
шлёт автоматически; она `HttpOnly`, `SameSite=Strict`, `Path=/api/auth`, и `Secure` выставляется по
|
||||
`HttpContext.Request.IsHttps` (учитывает `ForwardedHeaders` за прокси) — этого достаточно, т.к. сама
|
||||
по себе она не даёт мутировать данные, только обменивается на access-token эндпоинтом `/api/auth/refresh`.
|
||||
- **Brute-force**: Identity lockout по числу неудачных входов; rate-limit на `/auth/*` (настраиваемый
|
||||
лимит, `RateLimiting:AuthPermitLimit`, по умолчанию 20 запросов/мин).
|
||||
- **Rate limiting**: встроенный `RateLimiter` .NET, один fixed-window лимит (`RateLimiting:AuthPermitLimit`,
|
||||
по умолчанию 20/мин) применён к `/api/auth/*`, `/api/auth/telegram/*` и `/sub/{token}`; остальные
|
||||
эндпоинты (в т.ч. создание конфигов) им не покрыты.
|
||||
- **Валидация входа**: FluentValidation + жёсткая типизация DTO; ошибки — единый `ProblemDetails`.
|
||||
- **CORS**: в проде фронт и бек — один origin (CORS не нужен); в dev — строгий allowlist (`App__CorsOrigins`).
|
||||
- **CORS**: не настроен вообще (`AddCors`/`UseCors` в коде нет) — фронт и бек всегда один origin: в
|
||||
проде раздаются из одного образа, в dev Vite проксирует `/api`/`/hubs`, так что браузер никогда не
|
||||
делает кросс-origin запрос. Отдельного allowlist-конфига для CORS сейчас не существует.
|
||||
- **Аудит**: значимые действия (активация, блокировка, смена роли, отзыв, ноды/инбаунды) пишутся в
|
||||
`AuditLog` (append-only) с источником `Web`/`Telegram`/`System`.
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
- Управляемые ошибки → `Result`/`Result<T>` → маппинг в HTTP-статус + `ProblemDetails`.
|
||||
- Непредвиденные исключения → глобальный middleware → 500 + корреляция + структурный лог (без утечки деталей).
|
||||
- Доменные исключения (нарушение инвариантов) → 409/422 с понятным сообщением.
|
||||
- Управляемые ошибки → `Result`/`Result<T>` (`ResultExtensions.ToHttpResult`) → маппинг `ErrorType` в
|
||||
HTTP-статус + `application/problem+json` (400/401/403/404/409/422).
|
||||
- Непредвиденные исключения → встроенные `AddProblemDetails()` + `UseExceptionHandler()` → 500 без
|
||||
утечки деталей + `Serilog` request-логирование (`UseSerilogRequestLogging`, обогащение —
|
||||
`Enrich.FromLogContext()`). Сквозного `CorrelationId`/явного обогащения `UserId`/`NodeId`/`ConfigId`
|
||||
в логах пока нет — задел на будущее, а не то, на что стоит полагаться при расследовании инцидентов сегодня.
|
||||
- Доменные исключения (нарушение инвариантов) — `DomainException`, ожидаются только как баг, а не
|
||||
штатный путь (штатные отказы — через `Result.Failure`, не исключения).
|
||||
|
||||
## Развёртывание (единый контейнер приложения)
|
||||
|
||||
@@ -238,7 +305,7 @@ PostgreSQL:
|
||||
- **Миграции**: применяются **автоматически на старте** приложения (в MVP; при масштабировании на
|
||||
несколько инстансов — вынести в отдельный шаг/джобу).
|
||||
- Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения,
|
||||
JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`, `PublicSiteUrl`).
|
||||
JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`).
|
||||
|
||||
```
|
||||
[ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose
|
||||
|
||||
+110
-58
@@ -2,52 +2,74 @@
|
||||
|
||||
## Структура решения
|
||||
|
||||
Solution-файл — **`PnvPanel.slnx`** (новый XML-формат dotnet CLI, не классический `.sln`).
|
||||
|
||||
```
|
||||
backend/
|
||||
PnvPanel.sln
|
||||
PnvPanel.slnx
|
||||
src/
|
||||
PnvPanel.Domain/
|
||||
Common/ # Entity, AggregateRoot, IDomainEvent, ValueObject base
|
||||
Nodes/ # Node, NodeCredentials, NodeStatus, события
|
||||
Inbounds/ # Inbound, VpnProtocol
|
||||
Configs/ # VpnConfig, ConfigStatus, TrafficLimit, события
|
||||
Plans/ # Plan
|
||||
Exceptions/ # DomainException и наследники
|
||||
Common/ # Entity (единственный базовый класс — без AggregateRoot/IDomainEvent)
|
||||
Activation/ # ActivationRequest, ActivationStatus
|
||||
Apps/ # ClientApp, OsPlatform
|
||||
Audit/ # AuditLog, AuditSource
|
||||
Configs/ # VpnConfig, ConfigStatus, TrafficSample
|
||||
Inbounds/ # Inbound, VpnProtocol
|
||||
Nodes/ # Node, NodeCredentials (VO), NodeStatus
|
||||
Telegram/ # TelegramLinkToken, TelegramLoginRequest, TelegramLoginStatus
|
||||
Exceptions/ # DomainException
|
||||
PnvPanel.Application/
|
||||
Common/
|
||||
Behaviors/ # Validation, Logging, UnitOfWork, Authorization
|
||||
Interfaces/ # IAppDbContext, IXuiPanelGateway, ICurrentUser, IRealtimeNotifier, ...
|
||||
Behaviors/ # ValidationBehavior, LoggingBehavior, UnitOfWorkBehavior (нет Authorization-поведения)
|
||||
Interfaces/ # IAppDbContext, IXuiPanelGateway, ICurrentUser, IIdentityService,
|
||||
# ISecretProtector, IRealtimeNotifier, ITelegramNotifier, IRoleService
|
||||
Messaging/ # ISender, ICommand<T>, IQuery<T>, ICommandHandler<,>, IQueryHandler<,>, IPipelineBehavior<,>
|
||||
Models/ # Result<T>, Error, PagedList<T>
|
||||
Mapping/ # Mapster-конфиги
|
||||
Auth/ # Register/Login/Refresh (Commands, Handlers, Validators, DTOs)
|
||||
Configs/ # CreateVpnConfig, EditVpnConfig, RotateVpnConfig, RevokeVpnConfig, GetMyConfigs, GetConfigLink, GetSubscription, ...
|
||||
Nodes/ # RegisterNode, SyncNode, ListNodes, ...
|
||||
Inbounds/ # PublishInbound, ListInbounds, ...
|
||||
Apps/ # (admin) CRUD каталога ClientApp; GetApps (по ОС) для юзера
|
||||
Admin/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, GetStats, Audit, ...
|
||||
Models/ # Result, Result<T>, Error, PagedList<T>, RoleQuota
|
||||
Activation/ # RequestActivationCommand, GetActivationStatusQuery (пользовательские)
|
||||
Admin/
|
||||
Activation/ # ListActivationRequestsQuery, Approve/RejectActivationCommand
|
||||
Apps/ # CRUD ClientApp
|
||||
Audit/ # ListAuditLogsQuery
|
||||
Inbounds/ # ListInbounds, PublishInbound
|
||||
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/
|
||||
Persistence/
|
||||
AppDbContext.cs
|
||||
AppDbContext.cs # : IdentityDbContext<AppUser, AppRole, Guid>, IAppDbContext
|
||||
Configurations/ # IEntityTypeConfiguration<T>
|
||||
Migrations/
|
||||
Identity/ # AppUser, AppRole, JwtTokenService, RefreshToken
|
||||
Xui/ # XuiPanelGateway, XuiClientFactory (per-node)
|
||||
Realtime/ # SignalRRealtimeNotifier
|
||||
BackgroundJobs/ # TrafficSyncService, NodeHealthCheckService
|
||||
Identity/ # AppUser, AppRole, JwtTokenService, RefreshTokenService, RoleService,
|
||||
# DbInitializer (сидинг), IdentityService, CurrentUser
|
||||
Xui/ # XuiPanelGateway (единственный файл — кэш клиентов per-node внутри него)
|
||||
BackgroundJobs/ # TrafficSyncService, NodeHealthCheckService, TrafficRetentionService
|
||||
Security/ # DataProtectionSecretProtector
|
||||
DependencyInjection.cs
|
||||
Telegram/ # TelegramNotifier, TelegramOptions
|
||||
DependencyInjection.cs # AddInfrastructure(...)
|
||||
PnvPanel.Api/
|
||||
Endpoints/ # AuthEndpoints, ConfigEndpoints, NodeEndpoints, AdminEndpoints, SubscriptionEndpoints
|
||||
Hubs/ # PanelHub
|
||||
Middleware/ # ExceptionHandling, RequestCorrelation
|
||||
Extensions/ # AddApiServices, UseApiPipeline
|
||||
Program.cs
|
||||
Endpoints/ # 12 файлов, см. backend-conventions.md ниже и api-design.md
|
||||
Hubs/ # PanelHub, SignalRRealtimeNotifier (реализация IRealtimeNotifier — здесь,
|
||||
# не в Infrastructure, т.к. нужен IHubContext<PanelHub>)
|
||||
Telegram/ # TelegramBotHostedService, PnvBotUpdateHandler, TelegramNotifier
|
||||
Common/ # RateLimiting (константы политик), ResultExtensions (Result -> IResult)
|
||||
Program.cs # DI composition root, pipeline (нет отдельных Middleware/Extensions папок)
|
||||
appsettings*.json
|
||||
tests/
|
||||
PnvPanel.Domain.Tests/
|
||||
PnvPanel.Application.Tests/
|
||||
PnvPanel.Integration.Tests/ # Testcontainers PostgreSQL
|
||||
PnvPanel.Domain.Tests/ # 54 теста
|
||||
PnvPanel.Application.Tests/ # 71 тест
|
||||
PnvPanel.IntegrationTests/ # 9 тестов, Testcontainers.PostgreSql + WebApplicationFactory<Program>
|
||||
```
|
||||
|
||||
Организация Application — **по фичам** (feature folders), внутри слоёв Clean Architecture.
|
||||
@@ -57,27 +79,43 @@ backend/
|
||||
- Классы/методы/свойства — `PascalCase`; параметры/локальные — `camelCase`; приватные поля — `_camelCase`.
|
||||
- Команды — `<Verb><Noun>Command` (`CreateVpnConfigCommand`), запросы — `<Get/List><Noun>Query`.
|
||||
- Хендлеры — `<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`.
|
||||
- Один публичный тип на файл; имя файла = имя типа.
|
||||
- Один публичный тип на файл — с исключением: Api-слой держит вспомогательные `Body`/`ResponseDto`
|
||||
records в том же файле, что и класс эндпоинтов, который их использует (не выносятся отдельно).
|
||||
|
||||
## Паттерны
|
||||
|
||||
- **Rich domain model**: инварианты в сущностях (приватные сеттеры, фабричные методы `Node.Create(...)`,
|
||||
- **Rich domain model**: инварианты в сущностях (приватные сеттеры, фабричные методы `Node.Register(...)`,
|
||||
поведенческие методы `config.Revoke()`), а не анемичные DTO-сущности.
|
||||
- **CQRS через собственный диспетчер**: хендлеры реализуют `ICommandHandler<TCommand,TResult>` /
|
||||
`IQueryHandler<,>`; `ISender` резолвит их из DI и прогоняет через `IPipelineBehavior<,>`
|
||||
(валидация, транзакция, логирование). Без внешних CQRS-библиотек.
|
||||
- **Порты в Application, адаптеры в Infrastructure**: никакого `Npgsql`/`SignalR`/`ThreeXui.Net` в Application/Domain.
|
||||
- **`IAppDbContext`** экспонирует `DbSet<>` и `SaveChangesAsync`; хендлеры пишут LINQ напрямую
|
||||
(репозитории — только для сложной агрегатной логики).
|
||||
- **Result-модель**: команды/запросы возвращают `Result<T>`; эндпоинт маппит в HTTP (`.Match(...)`).
|
||||
- **Транзакция на команду**: `UnitOfWorkBehavior` оборачивает выполнение команды в транзакцию.
|
||||
- **Валидация**: `ValidationBehavior` до хендлера; хендлер не проверяет формат ввода повторно.
|
||||
- **Идемпотентность**: команды к 3x-ui устойчивы к повторам; при частичном сбое — компенсация
|
||||
(создали клиента в панели, но упала БД → удалить клиента, вернуть ошибку).
|
||||
- **Оптимистичная блокировка**: на изменяемых сущностях (нода, конфиг) — `xmin`/rowversion, чтобы
|
||||
параллельные правки не затирали друг друга.
|
||||
(`ValidationBehavior` → `LoggingBehavior` → `UnitOfWorkBehavior`). Без внешних CQRS-библиотек, без
|
||||
доменных событий — хендлер сам вызывает нужные порты (realtime/Telegram/аудит) синхронно.
|
||||
- **Порты в Application, адаптеры в Infrastructure/Api**: никакого `Npgsql`/`SignalR`/`ThreeXui.Net` в
|
||||
Application/Domain — только интерфейсы, реализации могут жить и в `Infrastructure`, и в `Api`
|
||||
(`IRealtimeNotifier` реализован в `Api/Hubs`, т.к. завязан на `IHubContext<PanelHub>`).
|
||||
- **`IAppDbContext`** экспонирует `DbSet<>` и `SaveChangesAsync`; хендлеры пишут LINQ напрямую —
|
||||
выделенных репозиториев нет вообще.
|
||||
- **Result-модель**: команды/запросы возвращают `Result`/`Result<T>`; `ResultExtensions.ToHttpResult()`
|
||||
мапит `Error.Type` в HTTP-статус на границе Api.
|
||||
- **Транзакция на команду**: `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
|
||||
|
||||
@@ -92,25 +130,39 @@ backend/
|
||||
|
||||
## Ошибки и логирование
|
||||
|
||||
- Единый `ProblemDetails` для ошибок API; коды: 400 (валидация), 401/403 (auth), 404, 409 (конфликт домена), 422, 429 (rate limit), 500.
|
||||
- Serilog со структурными полями (`UserId`, `NodeId`, `ConfigId`, `CorrelationId`); секреты не логировать.
|
||||
- Единый `application/problem+json` для ошибок API (`ResultExtensions.ToProblem`); коды: 400 (валидация),
|
||||
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** — инварианты и поведение сущностей, без моков.
|
||||
- **Application.Tests** — хендлеры с подменёнными портами (NSubstitute), проверка веток `Result`.
|
||||
- **Integration.Tests** — реальный PostgreSQL (Testcontainers), миграции, сквозные сценарии эндпоинтов;
|
||||
3x-ui — мок гейтвея или фейковый HTTP-сервер.
|
||||
- Именование тестов: `Method_Scenario_ExpectedResult`.
|
||||
- **PnvPanel.Domain.Tests** (54 теста) — инварианты и поведение сущностей, без моков.
|
||||
- **PnvPanel.Application.Tests** (71 тест) — хендлеры на EF Core InMemory + подменённые порты
|
||||
(NSubstitute), проверка веток `Result`. InMemory, а не Sqlite — модель использует Postgres-специфичные
|
||||
типы (`uuid[]`, `jsonb`), которые Sqlite не поддерживает, а InMemory просто игнорирует.
|
||||
- **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 (перекрывают).
|
||||
- Секреты (JWT-ключ, строка подключения, ключ шифрования) — user-secrets (dev) / env/secret-store (prod).
|
||||
- Строго типизированные `IOptions<T>` для секций конфига; валидация опций на старте.
|
||||
- `appsettings.json` + `appsettings.{Environment}.json` + env vars (перекрывают); в dev — `.env`
|
||||
через `docker-compose`'s `env_file`, локально без Docker — переменные окружения напрямую (проект
|
||||
не подключает `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 **включены**.
|
||||
- `dotnet format` в CI; предупреждения как ошибки для наших проектов.
|
||||
- Комментарии — по необходимости (почему, а не что); публичные контракты портов документируем XML-doc.
|
||||
- `.editorconfig`, nullable reference types **включены**; сборка в CI идёт с `-c Release` и должна
|
||||
быть без предупреждений.
|
||||
- `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
@@ -1,22 +1,25 @@
|
||||
# Domain 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───*─ VpnConfig
|
||||
│ *─┐
|
||||
│ ├─1─ Inbound ─*─1─ Node
|
||||
│ │ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
|
||||
│ └─*─ TrafficSample
|
||||
│ └─1─ Inbound ─*─1─ Node
|
||||
│ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
|
||||
├─0..1─* ActivationRequest (запрос активации у админа, с комментарием)
|
||||
├─1───*─ TelegramLinkToken (короткоживущие токены привязки)
|
||||
└─0..1─* TelegramLoginRequest (passwordless-вход)
|
||||
Plan ─1───*─ VpnConfig (опционально; квота по числу конфигов — на роли, не на Plan)
|
||||
VpnConfig ─*─ TrafficSample (история трафика; пишется TrafficSyncService)
|
||||
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
|
||||
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
|
||||
```
|
||||
@@ -79,41 +82,48 @@ ClientApp (каталог приложений-клиен
|
||||
| `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) |
|
||||
| `Protocol` | `VpnProtocol` | Денормализовано с inbound |
|
||||
| `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui |
|
||||
| `TrafficLimit` | `TrafficLimit` (VO) | Лимит в байтах (0 = безлимит) |
|
||||
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui |
|
||||
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
|
||||
| `UsedDownBytes` | `long` | Синхронизируется из 3x-ui |
|
||||
| `ExpiresAt` | `DateTimeOffset?`| null = бессрочно |
|
||||
| `ExpiresAt` | `DateTimeOffset?`| Зарезервировано на будущее — в MVP ничего его не выставляет, конфиг живёт бессрочно |
|
||||
| `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`|
|
||||
| `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` |
|
||||
| `LastSyncAt` | `DateTimeOffset?`| |
|
||||
| `CreatedAt` | `DateTimeOffset` | |
|
||||
|
||||
Инварианты и переходы:
|
||||
- Создаётся в статусе `Active`; поля клиента в 3x-ui и запись в БД создаются атомарно (компенсация при сбое).
|
||||
- **Проверка квоты выполняется в транзакции с блокировкой** (иначе два параллельных создания пробьют лимит).
|
||||
- `Revoke()` → удаляет клиента в 3x-ui, статус `Revoked` (запись остаётся для истории/аудита).
|
||||
- `Rotate()` → перевыпуск: удаляет старого клиента в 3x-ui и создаёт нового (новый UUID/ссылка);
|
||||
квоту **не тратит**. Для случая утечки ссылки.
|
||||
- `Disable()`/`Enable()` → отключение/включение клиента в 3x-ui без удаления (используется при блокировке юзера).
|
||||
Инварианты и переходы (методы на `VpnConfig`, `backend/src/PnvPanel.Domain/Configs/VpnConfig.cs`):
|
||||
- `Create(...)` → статус `Active`, `ClientExternalId` пуст до ответа от 3x-ui; хендлер вызывает
|
||||
`IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после
|
||||
успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация).
|
||||
- **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания
|
||||
(`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли.
|
||||
- `Revoke()` → статус `Revoked` (запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui.
|
||||
- `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui,
|
||||
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
|
||||
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
|
||||
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
|
||||
- `Rename(label)` / `SetDeviceLimit(n)` → юзер меняет метку и лимит устройств (последнее синкается в `limitIp` 3x-ui).
|
||||
- Синхронизация: если `Used ≥ TrafficLimit` → `LimitReached` (+ событие); если `now ≥ ExpiresAt` → `Expired`.
|
||||
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
|
||||
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
|
||||
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
|
||||
роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже.
|
||||
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
|
||||
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
|
||||
|
||||
### Plan — тариф (опционально, backlog)
|
||||
Шаблон лимитов трафика/срока для конфига. **Квота на число конфигов — это `AppRole.MaxConfigs`,
|
||||
а не Plan.** Plan остаётся опциональным механизмом для лимитов трафика/срока и в MVP не обязателен.
|
||||
> **Не реализовано в MVP**: лимиты трафика и автоматическое истечение срока конфига. `ExpiresAt`
|
||||
> никогда не выставляется, `ConfigStatus.LimitReached` в значении enum есть, но код в него никогда
|
||||
> не переводит конфиг — оставлено на будущее (см. `Plan` ниже и Backlog в [vision.md](vision.md)).
|
||||
|
||||
### Plan — тариф (backlog, не реализовано)
|
||||
Планировался как шаблон лимитов трафика/срока для конфига — **квота на число конфигов уже
|
||||
реализована через `AppRole.MaxConfigs`, это не Plan**. Сущности `Plan` в коде нет; таблица ниже —
|
||||
эскиз на будущее, если/когда лимиты трафика/срока понадобятся.
|
||||
|
||||
| Поле | Тип | Заметки |
|
||||
| ------------------ | ----------- | ------------------------------ |
|
||||
| `Id` | `Guid` | PK |
|
||||
| `Name` | `string` | |
|
||||
| `TrafficLimit` | `TrafficLimit` (VO) | Байты |
|
||||
| `TrafficLimitBytes`| `long` | 0 = безлимит |
|
||||
| `DurationDays` | `int?` | Срок действия конфига |
|
||||
| `MaxConfigs` | `int` | Сколько конфигов даёт тариф |
|
||||
| `IsActive` | `bool` | |
|
||||
|
||||
### TrafficSample — история трафика (для графиков)
|
||||
@@ -139,7 +149,7 @@ ClientApp (каталог приложений-клиен
|
||||
| `Id` | `Guid` | PK |
|
||||
| `Name` | `string` | Название, напр. «v2rayNG», «Hiddify», «NekoBox» |
|
||||
| `DownloadUrl` | `Uri` | Ссылка на скачивание/стор |
|
||||
| `OperatingSystem` | `OsPlatform` | `iOS` / `Android` / `Windows` / `MacOS` / `Linux` |
|
||||
| `OperatingSystem` | `OsPlatform` | `IOS` / `Android` / `Windows` / `MacOS` / `Linux` |
|
||||
| `Description` | `string?` | Короткая подсказка (опц.) |
|
||||
| `IconUrl` | `string?` | Иконка (опц.) |
|
||||
| `SortOrder` | `int` | Порядок внутри группы ОС |
|
||||
@@ -253,9 +263,12 @@ UI **настойчиво напоминает** привязать его (ед
|
||||
|
||||
## Value Objects
|
||||
|
||||
- **NodeCredentials** — `Username` + `ProtectedPassword` (шифротекст); равенство по значению; пароль не сериализуется наружу.
|
||||
- **TrafficLimit** — байты; помощники `IsUnlimited`, `IsExceededBy(used)`, форматирование в ГБ.
|
||||
- **ConnectionLink** — построенная ThreeXui.Net строка подключения + производные (подписка, QR-payload).
|
||||
- **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст,
|
||||
`ISecretProtector`/ASP.NET Data Protection); пароль не сериализуется наружу.
|
||||
|
||||
Connection string для клиента строит `IXuiPanelGateway` (обёртка над `ThreeXui.Net`) на лету при
|
||||
запросе `GET /api/configs/{id}/link` — отдельного value object под это не заводили. QR-код из
|
||||
готовой строки генерируется **на фронте** (`qrcode.react`), сервер картинку не рендерит.
|
||||
|
||||
## Enums
|
||||
|
||||
@@ -266,22 +279,31 @@ enum ConfigStatus { Active, Disabled, Expired, LimitReached, Revoked }
|
||||
enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
|
||||
enum ActivationStatus { Pending, Approved, Rejected }
|
||||
enum AuditSource { Web, Telegram, System }
|
||||
enum OsPlatform { iOS, Android, Windows, MacOS, Linux }
|
||||
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
||||
```
|
||||
|
||||
## Доменные события
|
||||
## Уведомления и аудит (без диспетчера доменных событий)
|
||||
|
||||
| Событие | Когда | Реакция |
|
||||
| ----------------------- | --------------------------------------- | --------------------------------------------------- |
|
||||
| `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` | Пользователь перевыпустил конфиг | Новый линк владельцу; аудит |
|
||||
В `Domain` нет маркера `IDomainEvent` и диспетчера событий — упрощение относительно исходного плана.
|
||||
CQRS-хендлеры сами вызывают порты `IRealtimeNotifier` / `ITelegramNotifier` и пишут `AuditLog`
|
||||
напрямую, после того как изменение состояния сохранено. Так проще проследить, что именно произойдёт
|
||||
при вызове конкретной команды — не нужно искать обработчик события где-то ещё.
|
||||
|
||||
События публикуются из сущностей/хендлеров и обрабатываются `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).
|
||||
|
||||
+106
-78
@@ -1,119 +1,147 @@
|
||||
# Frontend
|
||||
|
||||
SPA на **React 19 + Vite + TypeScript**. Общается с бэком по REST (JWT Bearer) и получает
|
||||
живые обновления по SignalR. Типы API генерируются из OpenAPI-схемы бэкенда.
|
||||
живые обновления по SignalR.
|
||||
|
||||
> **Раздача из единого контейнера.** В проде собранный фронт (`dist/`) кладётся в `wwwroot`
|
||||
> ASP.NET Core и раздаётся тем же приложением (SPA-fallback на `index.html`). Фронт и бек — один
|
||||
> 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 |
|
||||
| Язык | TypeScript (strict) |
|
||||
| Данные с сервера | TanStack Query |
|
||||
| Роутинг | TanStack Router (типобезопасный) |
|
||||
| UI-компоненты | shadcn/ui + Tailwind CSS v4 |
|
||||
| Иконки | lucide-react |
|
||||
| Клиентский стейт | Zustand (auth, тема) |
|
||||
| Формы | react-hook-form + zod |
|
||||
| Realtime | @microsoft/signalr |
|
||||
| Графики | Recharts |
|
||||
| QR-коды | qrcode.react |
|
||||
| Типы API | openapi-typescript / orval (codegen) |
|
||||
| i18n | react-i18next (RU + EN) |
|
||||
| Пакетный менеджер | pnpm |
|
||||
| Задача | Выбор |
|
||||
| ----------------- | ------------------------------------------------------------ |
|
||||
| Сборка/dev | Vite (Rolldown-based) |
|
||||
| Язык | TypeScript (strict) |
|
||||
| Данные с сервера | TanStack Query |
|
||||
| Роутинг | TanStack Router (файловый, `src/routes/`, кодогенерация `routeTree.gen.ts`) |
|
||||
| UI-компоненты | shadcn-стиль поверх Radix (`@radix-ui/react-*`) + Tailwind CSS v4 |
|
||||
| Иконки | lucide-react |
|
||||
| Клиентский стейт | Zustand — только auth-стор (`features/auth/store.ts`); тема — React Context + localStorage, не Zustand |
|
||||
| Формы | react-hook-form + zod |
|
||||
| Realtime | @microsoft/signalr |
|
||||
| QR-коды | qrcode.react (рендерит QR из готовой строки на клиенте) |
|
||||
| Типы API | openapi-typescript (`pnpm gen:api`) — генерирует `schema.gen.ts` для сверки; фичи импортируют руками написанный `shared/api/types.ts` |
|
||||
| i18n | react-i18next (RU + EN) |
|
||||
| Линт | oxlint (не ESLint) |
|
||||
| Пакетный менеджер | pnpm |
|
||||
|
||||
Установлены, но **не используются в MVP**: `recharts` (админская статистика — карточки с цифрами,
|
||||
без графиков), `@tanstack/react-table` (админские таблицы написаны руками, без TanStack Table).
|
||||
Оставлены как задел, если/когда понадобятся графики трафика или сложные таблицы с сортировкой.
|
||||
|
||||
## Структура
|
||||
|
||||
Фактическая структура (`frontend/src/`):
|
||||
|
||||
```
|
||||
frontend/
|
||||
src/
|
||||
app/ # провайдеры (Query, Router, Auth, Theme), корневой layout
|
||||
routes/ # маршруты TanStack Router (login, dashboard, configs, instructions, admin/*)
|
||||
main.tsx # точка входа: QueryClientProvider > ThemeProvider > ToastProvider > RealtimeProvider > RouterProvider
|
||||
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/
|
||||
auth/ # формы, хуки useLogin/useRegister, стор авторизации
|
||||
configs/ # список/создание/редактирование/детали конфигов, QR, подписка
|
||||
instructions/ # страница инструкций + каталог приложений по ОС
|
||||
nodes/ # (admin) управление нодами
|
||||
apps/ # (admin) CRUD каталога приложений
|
||||
admin/ # пользователи, роли, аудит, статистика
|
||||
theme/ # провайдер темы (light/dark/system) + переключатель
|
||||
auth/ # api.ts, store.ts (zustand), guards.ts, LoginForm.tsx, RegisterForm.tsx
|
||||
activation/ # api.ts, ActivationGate.tsx (экран "запросить активацию")
|
||||
configs/ # api.ts, ConfigCard.tsx, CreateConfigDialog.tsx, SubscriptionCard.tsx
|
||||
apps/ # api.ts, AppsCatalog.tsx (для /instructions)
|
||||
telegram/ # api.ts, TelegramLoginButton.tsx
|
||||
settings/ # ChangePasswordForm.tsx, TelegramLinkCard.tsx, DeleteAccountSection.tsx
|
||||
admin/
|
||||
users/, roles/, activation/, nodes/, inbounds/, apps/, audit/, stats/ # api.ts + диалоги CRUD в каждой
|
||||
theme/
|
||||
ThemeProvider.tsx # React Context + localStorage (`pnv-theme`), НЕ zustand
|
||||
shared/
|
||||
api/ # http-клиент (fetch + JWT/refresh), сгенерированные типы, query-хуки
|
||||
realtime/ # инициализация SignalR, подписки → инвалидация Query-кэша
|
||||
ui/ # обёртки над shadcn/ui, общие компоненты
|
||||
lib/ # утилиты, форматирование (байты, даты)
|
||||
config/ # env, константы
|
||||
styles/ # tailwind, темы
|
||||
api/
|
||||
client.ts # apiRequest(), HttpError, access-token в памяти модуля, 401 → silent refresh → повтор
|
||||
types.ts # руками написанные типы ответов/запросов (сверены со schema.gen.ts)
|
||||
schema.gen.ts # генерируется pnpm gen:api, не импортируется напрямую фичами
|
||||
lib/
|
||||
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
|
||||
vite.config.ts
|
||||
package.json
|
||||
```
|
||||
|
||||
Нет `app/`-папки с провайдерами (они прямо в `main.tsx`), нет `shared/config/` (переменные окружения
|
||||
читаются точечно через `import.meta.env`), нет `styles/` (один `index.css` с Tailwind).
|
||||
|
||||
## Дизайн / UX
|
||||
|
||||
- **Тема оформления**: светлая и тёмная (переключатель в шапке; вариант «системная»). Реализация —
|
||||
Tailwind `dark` (класс на `html`) + shadcn/ui; выбор сохраняется (localStorage).
|
||||
- **Современный и чистый вид**: shadcn/ui + Tailwind, адаптивность, аккуратная типографика.
|
||||
- **Дашборд пользователя**: карточки конфигов (протокол, локация, трафик прогресс-баром, срок, статус),
|
||||
быстрые действия (копировать ссылку, показать QR, перевыпустить, отозвать) + карточка «Общая подписка»
|
||||
(агрегированная ссылка/QR со всеми конфигами). Для неактивированного — экран «запросить активацию».
|
||||
- **Создание конфига**: выбор локации/inbound (по `DisplayName`) + метка + лимит устройств →
|
||||
мгновенная выдача ссылки + QR + ссылка на страницу инструкций.
|
||||
- **Страница инструкций** (`/instructions`): общие шаги «как импортировать ссылку/QR» + каталог
|
||||
приложений (`GET /api/apps`), **сгруппированный по ОС**; клик по приложению открывает ссылку на
|
||||
скачивание. Данные ведёт админ (каталог `ClientApp`).
|
||||
- **Редактирование конфига**: изменить метку и лимит устройств.
|
||||
- **Настройки аккаунта**: смена пароля, привязка/отвязка Telegram, **удаление аккаунта** (с подтверждением).
|
||||
- **Админка**: таблицы (TanStack Table) с пагинацией/фильтрами для нод, пользователей, конфигов, ролей,
|
||||
каталога приложений, журнала аудита; очередь запросов активации; графики трафика (Recharts).
|
||||
Блокировка пользователя — с подтверждением (гасит VPN). Управление приложениями (название, ссылка, ОС, вкл/выкл).
|
||||
- **Состояния**: скелетоны при загрузке, аккуратные пустые состояния и toasts на ошибки/успех.
|
||||
- **Тема оформления**: светлая/тёмная/системная (переключатель в шапке). Реализация — класс `dark` на
|
||||
`<html>` + Tailwind, выбор в `localStorage` (`ThemeProvider`, React Context).
|
||||
- **Дашборд пользователя** (`/dashboard`): карточки конфигов (протокол, локация, использованный
|
||||
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
|
||||
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
|
||||
неактивированного — `ActivationGate` вместо дашборда.
|
||||
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка + лимит устройств.
|
||||
После успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно.
|
||||
- **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`),
|
||||
сгруппированный по ОС; клик по приложению открывает ссылку на скачивание.
|
||||
- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
|
||||
удаление аккаунта с подтверждением (`DeleteAccountSection`).
|
||||
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
|
||||
пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные `<table>`,
|
||||
без TanStack Table. Блокировка пользователя — с подтверждением.
|
||||
- **Состояния**: `isLoading`/`isError`/пусто различаются явно везде (ошибка сети не выглядит как
|
||||
«пусто» — паттерн закреплён после находки в `ActivationGate`, распространён на все admin-списки).
|
||||
|
||||
## Работа с API
|
||||
|
||||
- HTTP-клиент оборачивает `fetch`: подставляет access-token, при 401 — прозрачно обновляет через
|
||||
refresh-cookie и повторяет запрос; при неуспехе — разлогин.
|
||||
- Все запросы/мутации — через TanStack Query (ключи по фичам, инвалидация после мутаций).
|
||||
- Типы ответов/запросов — из codegen по OpenAPI (никакого ручного дублирования DTO).
|
||||
- `shared/api/client.ts`: `apiRequest<T>()` оборачивает `fetch`, подставляет access-token в
|
||||
`Authorization`; на `401` — один прозрачный `POST /api/auth/refresh` и повтор запроса; при неуспехе
|
||||
рефреша — `setUnauthorizedHandler` колбэк (разлогин).
|
||||
- Запросы/мутации — через TanStack Query, ключи по фиче, инвалидация после мутаций.
|
||||
- Типы — из `shared/api/types.ts` (см. таблицу стека выше и [tech-stack.md](tech-stack.md)).
|
||||
|
||||
## Авторизация на клиенте
|
||||
|
||||
- **Access-token** — в памяти (не в localStorage), кладётся в `Authorization: Bearer`.
|
||||
- **Refresh-token** — httpOnly Secure cookie (JS не читает), ротация на сервере.
|
||||
- Стор авторизации (Zustand) хранит профиль/роли/`isActivated`; guard-маршруты по роли (`admin` vs обычный)
|
||||
и по активации (неактивированного ведём на экран «запросить активацию»).
|
||||
|
||||
- **Вход — по username** (email не используется). «Забыли пароль?» ведёт: при привязанном
|
||||
Telegram — восстановление через бота; иначе — подсказка обратиться к админу.
|
||||
- **Баннер привязки Telegram**: пока Telegram не привязан, показываем настойчивый, но не блокирующий
|
||||
баннер/напоминание — это единственный self-service способ восстановить доступ. Настройки: смена пароля.
|
||||
- **Access-token** — в памяти (модуль `client.ts`, не React state и не localStorage) — переживает
|
||||
ре-рендеры, но не пережить reload (тогда его молча восстанавливает silent refresh по cookie).
|
||||
- **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`)
|
||||
редиректят через `useNavigate()` в `useEffect`, если условие не выполнено.
|
||||
- **Вход — по username** (email не используется). «Забыли пароль?»: при привязанном Telegram —
|
||||
вход через бота и смена пароля в настройках; иначе — обратиться к админу.
|
||||
|
||||
### Вход и привязка через Telegram
|
||||
- **«Войти через Telegram»**: `POST /api/auth/telegram/login-request` → показать deep-link/QR на
|
||||
бота, затем ждать подтверждения (поллинг `GET …/login-request/{id}` или событие SignalR). При
|
||||
`Approved` — сохранить access, refresh уже в cookie, редирект в панель.
|
||||
- **«Привязать Telegram»** (в настройках, для вошедшего): `POST /api/auth/telegram/link-token` →
|
||||
показать deep-link/QR; статус привязки обновить по факту (поллинг/SignalR). Отвязка — `unlink`.
|
||||
- QR для deep-link — `qrcode.react`.
|
||||
- **«Войти через Telegram»** (`TelegramLoginButton`, на `/login`): `POST .../login-request` →
|
||||
показывает `deepLink` как QR (`qrcode.react`), поллит `GET .../login-request/{id}` до
|
||||
`Approved`/`Rejected`/`Expired`. При `Approved` — сохраняет `accessToken` в память клиента
|
||||
(refresh уже пришёл в cookie от бэка) и редиректит в панель.
|
||||
- **«Привязать Telegram»** (`TelegramLinkCard`, в настройках): `POST .../link-token` → QR из
|
||||
`deepLink`; статус обновляется поллингом. Отвязка — `POST .../unlink`.
|
||||
|
||||
## Realtime
|
||||
|
||||
- Одно SignalR-подключение к `/hubs/panel` с JWT; реконнект с бэкоффом.
|
||||
- Обработчики событий (`configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`) точечно
|
||||
обновляют/инвалидируют кэш TanStack Query — UI обновляется без перезагрузки.
|
||||
- Одно SignalR-подключение к `/hubs/panel` с JWT (`RealtimeProvider`, `shared/realtime/connection.ts`).
|
||||
- Обработчики `configTrafficUpdated`/`configStatusChanged`/`nodeStatusChanged`/`activationRequested`/
|
||||
`userActivated` точечно инвалидируют/обновляют кэш TanStack Query — UI обновляется без перезагрузки.
|
||||
|
||||
## Скрипты (ожидаемые)
|
||||
## Скрипты
|
||||
|
||||
```bash
|
||||
pnpm dev # dev-сервер Vite
|
||||
pnpm build # прод-сборка
|
||||
pnpm preview # предпросмотр сборки
|
||||
pnpm lint # ESLint
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm gen:api # генерация типов из OpenAPI-схемы бэкенда
|
||||
pnpm build # tsc -b && vite build (прод-сборка)
|
||||
pnpm preview # предпросмотр prod-сборки
|
||||
pnpm lint # oxlint
|
||||
pnpm typecheck # tsc -b
|
||||
pnpm gen:api # openapi-typescript по /openapi/v1.json живого бэкенда -> schema.gen.ts
|
||||
```
|
||||
|
||||
`pnpm gen:api` требует запущенный бэкенд на `http://localhost:8080` (или `docker compose up`).
|
||||
|
||||
+67
-44
@@ -1,75 +1,98 @@
|
||||
# Roadmap
|
||||
|
||||
Порядок реализации по этапам (milestones). Каждый этап — работоспособный инкремент.
|
||||
**MVP полностью реализован** — все этапы M0–M8 закрыты. Ниже — ретроспектива по этапам (как было
|
||||
задумано → что реально сделано, с честными пометками о расхождениях) и раздел [Backlog](#backlog-после-mvp)
|
||||
с тем, что осталось за рамками MVP осознанно.
|
||||
|
||||
## M0 — Каркас и инфраструктура
|
||||
- Solution + 4 проекта (Domain/Application/Infrastructure/Api), ссылки по Clean Architecture.
|
||||
- `Directory.Build.props`, `.editorconfig`, nullable + анализаторы, `dotnet format` в CI.
|
||||
- EF Core + Npgsql, первая миграция.
|
||||
- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn/ui + TanStack Query/Router; **тема light/dark/system** (провайдер + переключатель); i18n (RU/EN); dev-прокси `/api`,`/hubs` на бэк.
|
||||
## M0 — Каркас и инфраструктура ✅
|
||||
- Solution (`PnvPanel.slnx`) + 4 проекта (Domain/Application/Infrastructure/Api), ссылки по Clean Architecture.
|
||||
- `Directory.Build.props`, `.editorconfig`, nullable включены. `dotnet format` — локальная команда,
|
||||
в CI **не** запускается (CI гоняет только build/test).
|
||||
- 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 из
|
||||
`wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL); `ForwardedHeaders`
|
||||
(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` (без деплоя).
|
||||
- **Готово, когда**: единый образ поднимается в 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`: системные роли
|
||||
`admin`/`user` и учётка админа + Telegram id админов из env ([`.env.example`](../.env.example)).
|
||||
- **Вход по username** (email не используется); JWT access + refresh (httpOnly cookie, ротация, хранение
|
||||
хэшей), CSRF на refresh, Identity lockout, rate-limit на `/auth/*`; смена пароля.
|
||||
`admin`/`user` и учётка админа из env ([`.env.example`](../.env.example)).
|
||||
- **Вход по username** (email не используется); JWT access + refresh (httpOnly cookie, ротация,
|
||||
`Secure` по факту HTTPS-запроса, хранение хэшей), Identity lockout, rate-limit на `/auth/*`;
|
||||
смена пароля. Явного анти-CSRF токена нет — обоснование в [architecture.md](architecture.md#безопасность).
|
||||
- Регистрация: новый пользователь → роль `user`, `IsActivated = false`.
|
||||
- Фронт: страницы login/register (username), стор авторизации, refresh-flow, guard-маршруты.
|
||||
- **Готово, когда**: регистрация/вход/refresh/logout по username работают, админ засидан, новый юзер неактивен.
|
||||
- **Готово, когда**: регистрация/вход/refresh/logout по username работают, админ засидан, новый юзер неактивен. ✅ Достигнуто.
|
||||
|
||||
## M2 — Роли и активация
|
||||
- Домен: динамические роли (CRUD `admin`, квота `MaxConfigs`), `ActivationRequest`.
|
||||
- Команды/запросы: CreateRole/UpdateRole/DeleteRole, ChangeUserRole (одна роль), RequestActivation (с комментарием),
|
||||
ApproveActivation/RejectActivation.
|
||||
- Эндпоинты активации (user + admin) и ролей; policy `RequireActivated`.
|
||||
## M2 — Роли и активация ✅
|
||||
- Домен: динамические роли (CRUD, квота `MaxConfigs`), `ActivationRequest`.
|
||||
- Команды/запросы: CreateRole/UpdateRole/DeleteRole, ChangeUserRole (одна роль), RequestActivation
|
||||
(с комментарием), ApproveActivation/RejectActivation.
|
||||
- Эндпоинты активации (user + admin) и ролей; проверка активации/роли — inline в хендлерах
|
||||
и `RequireAuthorization(...)` на эндпоинте, без отдельных именованных policy.
|
||||
- Фронт: экран «запросить активацию» (с комментарием), админ-очередь запросов, управление ролями/назначением.
|
||||
- **Готово, когда**: юзер запрашивает активацию с комментарием, админ на сайте активирует; роли с квотами работают.
|
||||
- **Готово, когда**: юзер запрашивает активацию с комментарием, админ на сайте активирует; роли с квотами работают. ✅ Достигнуто.
|
||||
|
||||
## M3 — Ноды и публикация inbounds (по ролям)
|
||||
- Домен `Node`/`Inbound` (+ `AllowedRoles`, `DisplayName`); порт `IXuiPanelGateway` + `XuiPanelGateway`
|
||||
(per-node клиент, ThreeXui.Net); шифрование секретов нод (`ISecretProtector`).
|
||||
- Команды/запросы: RegisterNode, SyncNode, Probe, ListNodes, ListInbounds, PublishInbound (с выбором ролей).
|
||||
## M3 — Ноды и публикация inbounds (по ролям) ✅
|
||||
- Домен `Node`/`Inbound` (+ `AllowedRoles`, `DisplayName`); порт `IXuiPanelGateway` + единственная
|
||||
реализация `XuiPanelGateway` (кэш клиента per-node внутри неё, `ThreeXui.Net`); шифрование секретов
|
||||
нод (`ISecretProtector`/ASP.NET Data Protection).
|
||||
- Команды/запросы: RegisterNode, UpdateNode, DeleteNode, SyncNode, ProbeNode, ListNodes, ListInbounds,
|
||||
PublishInbound (с выбором ролей).
|
||||
- Админка нод/инбаундов на фронте (публикация с `displayName` и `allowedRoleIds`).
|
||||
- **Готово, когда**: админ подключает реальную 3x-ui и публикует inbound «Германия (Trojan)» для выбранных ролей.
|
||||
- **Готово, когда**: админ подключает реальную 3x-ui и публикует inbound для выбранных ролей. ✅ Достигнуто
|
||||
(удаление ноды с активными конфигами пока не блокируется — известный пробел, см. [tech-stack.md](tech-stack.md)).
|
||||
|
||||
## M4 — Конфиги пользователя (ядро продукта)
|
||||
- Домен `VpnConfig` (создание, отзыв, ротация, статусы; инварианты: активирован + квота роли (грандфазеринг)
|
||||
+ доступ роли к инбаунду; проверка квоты в транзакции; схема `ClientEmail`).
|
||||
## M4 — Конфиги пользователя (ядро продукта) ✅
|
||||
- Домен `VpnConfig` (создание, отзыв, ротация; инварианты: активирован + квота роли (грандфазеринг)
|
||||
+ доступ роли к инбаунду; квота — под `pg_advisory_xact_lock`; схема `ClientEmail`).
|
||||
- CreateVpnConfig (с `label`/`deviceLimit`→`limitIp`), EditVpnConfig, RotateVpnConfig, RevokeVpnConfig,
|
||||
GetMyConfigs, GetConfigLink, ListAvailableInbounds; connection string + QR.
|
||||
- Подписка: агрегированная `/sub/{userToken}` (все конфиги) + по конфигу `/sub/{configToken}`;
|
||||
заголовки `Subscription-Userinfo` / `profile-update-interval`.
|
||||
- Самоудаление аккаунта (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных.
|
||||
GetMyConfigs, GetConfigLink, ListAvailableInbounds; connection string по запросу (не сразу при
|
||||
создании), QR строится на фронте.
|
||||
- Подписка: один эндпоинт `/sub/{token}` — токен либо агрегированный (`AppUser.SubscriptionToken`,
|
||||
все конфиги), либо по одному конфигу (`VpnConfig.SubscriptionToken`); заголовки
|
||||
`Subscription-Userinfo` / `Profile-Update-Interval`.
|
||||
- Самоудаление аккаунта (`DELETE /api/auth/me`): отзыв всех активных конфигов + удаление `AppUser`.
|
||||
- Каталог приложений `ClientApp` (домен + `GET /api/apps` по ОС; сид из `seed/client-apps.json`) + **страница инструкций** на фронте.
|
||||
- Фронт: дашборд (метки, лимит устройств), создание/редактирование, страница инструкций, копирование, QR, отзыв, перевыпуск, настройки аккаунта.
|
||||
- **Готово, когда**: активированный юзер создаёт рабочий конфиг в доступном инбаунде в пределах квоты; работает агрегированная подписка.
|
||||
- Фронт: дашборд (метки, лимит устройств), создание/редактирование, страница инструкций, ссылка/QR
|
||||
по кнопке, отзыв, перевыпуск, настройки аккаунта.
|
||||
- **Готово, когда**: активированный юзер создаёт рабочий конфиг в доступном инбаунде в пределах квоты;
|
||||
работает подписка. ✅ Достигнуто. Лимиты трафика/срока конфига — не реализованы, backlog
|
||||
(см. [domain-model.md](domain-model.md)).
|
||||
|
||||
## M5 — Синхронизация трафика и realtime
|
||||
- `TrafficSyncService` (обход нод, обновление трафика/статусов, `TrafficSample`); реконсиляция дрейфа с 3x-ui.
|
||||
## M5 — Синхронизация трафика и realtime ✅
|
||||
- `TrafficSyncService` (обход включённых нод, обновление трафика, `TrafficSample`) — только для
|
||||
отображения, без активной реконсиляции дрейфа (недоступная нода/незнакомый клиент — тихо пропускаются).
|
||||
- `NodeHealthCheckService`; `TrafficRetentionService` (TTL-чистка истории).
|
||||
- SignalR `PanelHub` + `IRealtimeNotifier`; события трафика/статусов/нод/активации.
|
||||
- Фронт: живые прогресс-бары трафика, статусы онлайн, реакция на превышение лимита/срока.
|
||||
- **Готово, когда**: трафик и статусы обновляются в UI без перезагрузки.
|
||||
- SignalR `PanelHub` + `IRealtimeNotifier` (реализован в `Api/Hubs/`, не в Infrastructure); события
|
||||
`configTrafficUpdated`/`configStatusChanged`/`nodeStatusChanged`/`activationRequested`/`userActivated`.
|
||||
- Фронт: живые обновления трафика/статусов без перезагрузки. Реакции на превышение лимита/срока нет —
|
||||
таких лимитов не существует (см. M4).
|
||||
- **Готово, когда**: трафик и статусы обновляются в UI без перезагрузки. ✅ Достигнуто.
|
||||
|
||||
## M6 — Админ-статистика, управление пользователями, аудит
|
||||
- ListUsers, BlockUser (→ отключение конфигов в 3x-ui) / UnblockUser, ChangeUserRole, ResetUserPassword (без привязки TG), GetUserConfigs, force-revoke, GetStats.
|
||||
- `AuditLog`: запись значимых действий (Web/Telegram/System) + эндпоинт `/api/admin/audit`.
|
||||
## M6 — Админ-статистика, управление пользователями, аудит ✅
|
||||
- ListUsers, BlockUser (→ отключение конфигов в 3x-ui) / UnblockUser (два отдельных эндпоинта),
|
||||
ChangeUserRole, ResetUserPassword, GetUserConfigs, ForceRevokeConfig, GetStats.
|
||||
- `AuditLog`: запись значимых действий (активация, блокировка, смена роли, ноды/инбаунды — источник
|
||||
всегда `Web`, т.к. пишется из тех же хендлеров, что вызывает и бот) + эндпоинт `/api/admin/audit`.
|
||||
- Каталог приложений: админ-CRUD `ClientApp` (`/api/admin/apps`) — название, ссылка, ОС, порядок, вкл/выкл.
|
||||
- Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки.
|
||||
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN.
|
||||
- Фронт: таблицы пользователей/ролей/нод/приложений (обычные `<table>`, без TanStack Table), журнал
|
||||
аудита, статистика карточками (без графиков — `recharts` установлен, но не подключён).
|
||||
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами;
|
||||
блокировка гасит VPN. ✅ Достигнуто.
|
||||
|
||||
## M7 — Telegram-бот ✅
|
||||
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
|
||||
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
|
||||
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
||||
- 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).
|
||||
- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам.
|
||||
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто.
|
||||
|
||||
+57
-29
@@ -13,19 +13,29 @@ health checks, DI. `ThreeXui.Net` таргетит `net10.0` — совпаде
|
||||
Альтернативы: Vertical Slice (проще для мелких API, но хуже изолирует домен для растущего продукта) —
|
||||
можно комбинировать: слои + организация Application «по фичам».
|
||||
|
||||
### CQRS: собственный тонкий диспетчер ✅ (зафиксировано)
|
||||
**Решение принято**: свой `ISender` вместо MediatR (тот с v12 стал платным). ~100 строк:
|
||||
`ISender.Send()` резолвит `ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через
|
||||
`IPipelineBehavior<,>` (валидация → авторизация → транзакция → логирование). Плюсы: нет лицензий и
|
||||
внешних зависимостей, полный контроль. Доменные события — свой `IDomainEventHandler<T>` +
|
||||
диспетчеризация после `SaveChanges`. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели).
|
||||
### CQRS: собственный тонкий диспетчер ✅ (зафиксировано и реализовано)
|
||||
**Решение принято**: свой `ISender` вместо MediatR (тот с v12 стал платным). `ISender.Send()`
|
||||
резолвит `ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через `IPipelineBehavior<,>`.
|
||||
Реализованы три поведения: `ValidationBehavior` (FluentValidation), `LoggingBehavior`,
|
||||
`UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Плюсы: нет лицензий и внешних
|
||||
зависимостей, полный контроль. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели).
|
||||
|
||||
**Отличие от исходного плана**: отдельного диспетчера доменных событий (`IDomainEventHandler<T>`) в
|
||||
итоге не заводили — оказалось, что для текущего размера проекта прямые вызовы `IRealtimeNotifier`/
|
||||
`ITelegramNotifier` и запись `AuditLog` прямо в хендлере команды читаются проще, чем публикация
|
||||
события и поиск обработчика где-то ещё (см. [domain-model.md](domain-model.md#уведомления-и-аудит-без-диспетчера-доменных-событий)).
|
||||
Также нет отдельного `AuthorizationBehavior` — роль проверяется на уровне эндпоинта
|
||||
(`RequireAuthorization(...)`), а более тонкие проверки (владение, активация) — в самом хендлере.
|
||||
|
||||
### Валидация: FluentValidation
|
||||
Декларативные валидаторы на команды/запросы, подключаются через `ValidationBehavior`.
|
||||
Декларативные валидаторы на команды/запросы, подключаются через `ValidationBehavior`. Заводится не
|
||||
для каждой команды — только там, где есть что проверить помимo типов (например, у команд без
|
||||
пользовательского ввода валидатора нет).
|
||||
|
||||
### Маппинг: Mapster
|
||||
Быстрый, без коммерческой лицензии (в отличие от AutoMapper, тоже ставшего платным), кодогенерация.
|
||||
Для простых проекций — ручной `Select` в DTO без маппера.
|
||||
### Маппинг: вручную, без Mapster
|
||||
В исходном плане был Mapster — на практике для такого числа полей ручной статический метод
|
||||
`XxxDto.FromDomain(entity)` на самом DTO читается не хуже конфига маппера и не добавляет
|
||||
зависимость. `Mapster` в проект так и не попал.
|
||||
|
||||
### ORM: EF Core 10 + Npgsql
|
||||
Миграции, LINQ, `IEntityTypeConfiguration`. Провайдер PostgreSQL — Npgsql.
|
||||
@@ -68,16 +78,23 @@ MVP (не нужен публичный webhook, проще в одиночно
|
||||
Явные ошибки вместо исключений для управляемых сценариев; исключения — только для действительно исключительного.
|
||||
|
||||
### Логирование: Serilog ✅ (зафиксировано)
|
||||
**Решение принято**: структурное логирование — **Serilog** (`Serilog.AspNetCore`). Настройка через
|
||||
`appsettings`/env, обогащение контекста (`UserId`/`NodeId`/`ConfigId`/`CorrelationId`), секреты не
|
||||
логируются. Синки MVP: Console (JSON в проде) + rolling file; Seq/OTel-экспорт — опционально позже.
|
||||
Наблюдаемость сверх логов (OpenTelemetry-трейсинг, метрики) — вне MVP.
|
||||
**Решение принято**: структурное логирование — **Serilog** (`Serilog.AspNetCore`), настройка через
|
||||
`appsettings`/env, `UseSerilogRequestLogging()` + `Enrich.FromLogContext()`. Синк MVP — Console.
|
||||
Секреты (пароли, JWT, `BotToken`) в логи не попадают. **Не реализовано**: явное обогащение контекста
|
||||
полями `UserId`/`NodeId`/`ConfigId`, сквозной `CorrelationId`, rolling file/Seq/OTel-экспорт — было в
|
||||
исходном плане, осталось в backlog. Сегодня для расследования инцидента доступны только то, что даёт
|
||||
`Enrich.FromLogContext()` + запрос/ответ из request-логирования.
|
||||
|
||||
### API-документация: Swashbuckle (OpenAPI) + Scalar UI
|
||||
Схема OpenAPI используется фронтом для кодогенерации типов. Scalar — современный UI вместо Swagger UI.
|
||||
### API-документация: нативный OpenAPI (`Microsoft.AspNetCore.OpenApi`) + Scalar 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
|
||||
Юнит-тесты домена/хендлеров (моками портов), интеграционные — с реальным PostgreSQL в Testcontainers.
|
||||
### Тесты: xUnit + NSubstitute + Testcontainers
|
||||
Юнит-тесты домена/хендлеров (без FluentAssertions — обычные `Assert.*` из xUnit хватает для
|
||||
используемых проверок), интеграционные — с реальным PostgreSQL в Testcontainers
|
||||
(`Testcontainers.PostgreSql` + `WebApplicationFactory<Program>`).
|
||||
|
||||
## Frontend
|
||||
|
||||
@@ -104,11 +121,16 @@ MVP (не нужен публичный webhook, проще в одиночно
|
||||
### Realtime: @microsoft/signalr
|
||||
Официальный клиент SignalR; подписки на события хаба обновляют кэш TanStack Query.
|
||||
|
||||
### Типы API: OpenAPI codegen (openapi-typescript / orval)
|
||||
Типы (и, опц., хуки) генерируются из OpenAPI-схемы бэкенда — single source of truth, никакого дрейфа контрактов.
|
||||
### Типы API: openapi-typescript ✅ (зафиксировано)
|
||||
`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
|
||||
Декларативные графики трафика/статистики. QR-коды конфигов — `qrcode.react`.
|
||||
### Графики: Recharts (установлен, графики не построены)
|
||||
Библиотека в зависимостях фронта на будущее — в MVP админская статистика показана карточками с
|
||||
цифрами, без графиков. QR-коды конфигов — `qrcode.react` (реально используется).
|
||||
|
||||
### i18n: react-i18next, RU + EN ✅ (зафиксировано)
|
||||
**Решение принято**: локализация с первого дня, языки **RU + EN** (RU по умолчанию). Тексты — через
|
||||
@@ -167,16 +189,22 @@ MVP (не нужен публичный webhook, проще в одиночно
|
||||
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
|
||||
| Лимит устройств | Per-config, задаёт юзер (`DeviceLimit` → `limitIp` в 3x-ui; 0 = без лимита) |
|
||||
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
|
||||
| Самоудаление аккаунта | Разрешено: отзыв всех конфигов + удаление данных, аудит анонимизируется |
|
||||
| Самоудаление аккаунта | Разрешено: отзыв всех активных конфигов в 3x-ui + удаление `AppUser`. `AuditLog` уже хранит только `Guid` без PII — отдельной анонимизации задним числом нет, сам факт удаления в аудит тоже не пишется |
|
||||
| Версионирование API | Без версий в MVP (`/api` без `v1`) |
|
||||
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
|
||||
| Тема сайта | Светлая + тёмная (+ системная); Tailwind `dark`, выбор в localStorage |
|
||||
| Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС); стартовый сид из `seed/client-apps.json` |
|
||||
| Реконсиляция с 3x-ui | На синхронизации сверяем проекцию с панелью, помечаем дрейф, не «воскрешаем» молча |
|
||||
| Реконсиляция с 3x-ui | Не реализована активно — `TrafficSyncService` молча пропускает ноду/клиента при недоступности или несовпадении, без пометки дрейфа (см. [architecture.md](architecture.md)) |
|
||||
|
||||
Также заложены: CSRF-защита refresh-cookie + Identity lockout; проверка квоты в транзакции; схема
|
||||
`ClientEmail = pnv_{userIdShort}_{rand}`; блокировка удаления ноды при наличии конфигов.
|
||||
Также реализовано: Identity lockout по неудачным входам; проверка квоты под `pg_advisory_xact_lock`;
|
||||
схема `ClientEmail = pnv_{userIdShort}_{rand}`. Явного анти-CSRF токена на refresh-cookie нет (см.
|
||||
[architecture.md](architecture.md#безопасность) — обоснование, почему `SameSite=Strict` + `HttpOnly`
|
||||
достаточно при мутациях только по Bearer-токену). Удаление ноды с активными конфигами **не
|
||||
блокируется** — это известный пробел, не защита: `DeleteNodeCommandHandler` каскадно удаляет
|
||||
инбаунды ноды без проверки существующих `VpnConfig`.
|
||||
|
||||
Осталось выбрать позже (не блокирует старт): значение TTL для истории трафика; конкретные синки
|
||||
Serilog для прод (файл/Seq/OTel); точные TTL токенов Telegram. Email/SMTP в проекте **не используются**
|
||||
(вход по username, восстановление — через Telegram/админа).
|
||||
Не реализовано (осталось на будущее, не блокирует текущую работу): TTL для истории трафика (сейчас
|
||||
`TrafficRetentionService` работает, но точный порог не вынесен в решение — см. код); прод-синки
|
||||
Serilog (файл/Seq/OTel) и структурное обогащение логов (`UserId`/`CorrelationId`); точные TTL
|
||||
токенов Telegram. Email/SMTP в проекте **не используются** (вход по username, восстановление — через
|
||||
Telegram/админа).
|
||||
|
||||
+119
-96
@@ -1,151 +1,174 @@
|
||||
# Telegram Bot
|
||||
|
||||
Telegram-бот — **второй канал доставки** (presentation-адаптер) поверх той же Application-логики,
|
||||
что и REST API. Он не содержит бизнес-правил: команды бота вызывают те же CQRS-команды/запросы
|
||||
(`ICommand/IQuery`), что и веб. Бизнес-инварианты живут в домене, а не в обработчиках бота.
|
||||
что и REST API. Он не содержит бизнес-правил: обработчики апдейтов вызывают те же CQRS-команды/запросы
|
||||
(`ICommand`/`IQuery` через собственный `ISender`), что и веб. Бизнес-инварианты живут в домене.
|
||||
|
||||
## Возможности
|
||||
## Возможности (реализовано)
|
||||
|
||||
1. **Ссылка на сайт** — кнопка/команда, открывающая веб-панель (при желании — с одноразовым
|
||||
deep-link авто-входом для уже привязанного пользователя).
|
||||
2. **Мои конфиги** — список VPN-конфигов пользователя (протокол, локация, трафик, срок, статус),
|
||||
ссылка-подписка и QR по каждому. Доступно только привязанному аккаунту.
|
||||
3. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: подтверждение входа
|
||||
в боте. Требует предварительной **привязки Telegram** к аккаунту.
|
||||
4. **Админ: обработка запросов активации** — админ (по Telegram id из env) получает уведомление
|
||||
о запросе активации с комментарием заявителя и жмёт «Активировать / Отклонить» прямо в боте.
|
||||
5. **DM-уведомления пользователю** — если Telegram привязан, бот шлёт личные уведомления о ключевых
|
||||
событиях: «аккаунт активирован», «конфиг отозван админом», «вы заблокированы».
|
||||
6. **Восстановление пароля** — реализовано через passwordless-вход: привязанный пользователь входит
|
||||
через бота (`/login`) и меняет пароль в настройках (`ChangePasswordCommand`). Без привязки
|
||||
Telegram сброс делает только админ (`ResetUserPasswordCommand`). Отдельная команда бота
|
||||
`/resetpassword` с одноразовой ссылкой на смену пароля — **backlog**, в MVP не реализована.
|
||||
1. **Мои конфиги** — `/configs` присылает текстовый список (метка/локация, протокол, статус) —
|
||||
**без ссылок и QR** в самом боте; за ссылкой/QR пользователь идёт на сайт. Доступно только
|
||||
привязанному аккаунту.
|
||||
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
|
||||
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
|
||||
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
|
||||
получает сообщение о каждом запросе активации с именем и комментарием заявителя, жмёт
|
||||
«✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
|
||||
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
|
||||
принудительный отзыв конфига админом.
|
||||
5. **Отвязка** — `/unlink`.
|
||||
|
||||
> **Скоуп бота в MVP — просмотр (read-only) по конфигам.** Создание/ротация/отзыв конфигов — только
|
||||
> на сайте. Полное самообслуживание в боте (создание/отзыв) — в backlog.
|
||||
**Не реализовано / backlog:**
|
||||
- Ссылки/QR/подписка в самом боте (только текстовый список конфигов).
|
||||
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
|
||||
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
|
||||
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
||||
- Регистрация нового аккаунта из бота (только привязка существующего).
|
||||
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам.
|
||||
|
||||
## Размещение в архитектуре
|
||||
|
||||
- Бот работает **в том же процессе**, что и API, как `BackgroundService`
|
||||
(`TelegramBotHostedService`) — это укладывается в требование «фронт+бек в одном контейнере».
|
||||
- Транспорт с Telegram: **long polling** для MVP (не требует публичного webhook-URL, проще в
|
||||
одиночном контейнере). Webhook — опциональная альтернатива для прод-нагрузки (тогда — секретный
|
||||
токен заголовка для верификации).
|
||||
- Библиотека — **Telegram.Bot** (де-факто стандарт для C#).
|
||||
- Код бота лежит в `PnvPanel.Api/Telegram/` (хендлеры апдейтов, построители клавиатур,
|
||||
форматтеры сообщений). Обращения к домену — **только** через собственный `ISender`.
|
||||
`Telegram.Bot` не проникает в Application/Domain.
|
||||
- Бот работает **в том же процессе**, что и API, как `BackgroundService` (`TelegramBotHostedService`,
|
||||
`PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере».
|
||||
- Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на
|
||||
этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`)
|
||||
содержит только `BotToken`, `BotUsername`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей
|
||||
`Mode`/`WebhookUrl`/`WebhookSecret` в коде нет.
|
||||
- Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`,
|
||||
как HTTP-запрос — свежие scoped-сервисы на апдейт).
|
||||
- Обращения к домену — **только** через `ISender`. `Telegram.Bot` не проникает в Application/Domain.
|
||||
- Если `Telegram:BotToken` не задан — `TelegramBotHostedService.ExecuteAsync` сразу возвращается,
|
||||
бот не стартует, панель работает без него (лог `Telegram__BotToken не задан — бот не стартует.`).
|
||||
|
||||
```
|
||||
Telegram ──updates──► TelegramBotHostedService (Api)
|
||||
│ ISender.Send(command/query) // свой диспетчер
|
||||
Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandler (Api/Telegram/)
|
||||
│ ISender.Send(command/query) // свой диспетчер, свой DI-scope на апдейт
|
||||
▼
|
||||
Application (те же хендлеры, что и REST)
|
||||
```
|
||||
|
||||
## Модель данных (добавления)
|
||||
|
||||
- `AppUser.TelegramUserId : long?` — id пользователя Telegram (уникальный, nullable до привязки).
|
||||
- `AppUser.TelegramUsername : string?`, `AppUser.TelegramLinkedAt : DateTimeOffset?`.
|
||||
- `AppUser.TelegramUserId : long?`, `TelegramUsername : string?`, `TelegramLinkedAt : DateTimeOffset?`.
|
||||
- `TelegramLinkToken` — короткоживущий одноразовый токен привязки (`token`, `userId`, `expiresAt`, `consumedAt`).
|
||||
- `TelegramLoginRequest` — запрос passwordless-входа: `id/nonce`, `status`
|
||||
(`Pending/Approved/Rejected/Expired/Consumed`), `userId?` (после подтверждения), `createdAt`, `expiresAt`.
|
||||
- `TelegramLoginRequest` — запрос passwordless-входа: `id`, `status`
|
||||
(`Pending/Approved/Rejected/Expired/Consumed`), `userId?` (после подтверждения), `context?`
|
||||
(IP инициатора, собирается, но **в текст подтверждения в боте не выводится** — известный TODO),
|
||||
`createdAt`, `expiresAt`.
|
||||
|
||||
Подробности полей — в [domain-model.md](domain-model.md).
|
||||
|
||||
## Флоу 1 — Привязка Telegram к аккаунту
|
||||
|
||||
Предусловие: пользователь уже вошёл на сайте (изначально аккаунт создаётся с username+пароль).
|
||||
Предусловие: пользователь уже вошёл на сайте.
|
||||
|
||||
1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token` → `{ deepLink }`
|
||||
вида `https://t.me/<bot>?start=link_<token>` (+ QR). Токен короткоживущий, одноразовый.
|
||||
1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token` → `{ deepLink, expiresAt }`,
|
||||
`deepLink` вида `https://t.me/<bot>?start=link_<token>`. Фронт рисует QR из `deepLink` сам
|
||||
(`qrcode.react`) — бэкенд картинку не генерирует.
|
||||
2. Пользователь открывает бота по ссылке → `/start link_<token>`.
|
||||
3. Бот берёт `from.id` (Telegram user id), валидирует токен (`LinkTelegramCommand`), проставляет
|
||||
`TelegramUserId`/`TelegramUsername`/`TelegramLinkedAt`, гасит токен.
|
||||
4. Бот подтверждает: «Аккаунт привязан». Сайт узнаёт об успехе (поллинг статуса или SignalR).
|
||||
3. Бот берёт `from.id`, вызывает `LinkTelegramCommand(token, telegramUserId, telegramUsername)` —
|
||||
валидирует токен, проставляет `TelegramUserId`/`TelegramUsername`/`TelegramLinkedAt`, гасит токен.
|
||||
4. Бот отвечает «✅ Telegram успешно привязан к вашему аккаунту» (или текст ошибки). Сайт узнаёт об
|
||||
успехе поллингом статуса активации/профиля.
|
||||
|
||||
Инварианты: один `TelegramUserId` ↔ один аккаунт; повторная привязка требует отвязки; токен
|
||||
нельзя переиспользовать и он истекает.
|
||||
Инварианты: один `TelegramUserId` ↔ один аккаунт; токен одноразовый и истекает.
|
||||
|
||||
## Флоу 2 — Passwordless-вход через бота
|
||||
|
||||
Предусловие: Telegram уже привязан к аккаунту.
|
||||
|
||||
1. На сайте «Войти через Telegram» → `POST /api/auth/telegram/login-request` →
|
||||
`{ requestId, deepLink, qr, expiresAt }`. Сайт начинает ждать результат (поллинг
|
||||
`GET /api/auth/telegram/login-request/{requestId}` или событие SignalR).
|
||||
2. Пользователь открывает `https://t.me/<bot>?start=login_<nonce>` → бот по `from.id` находит
|
||||
привязанный аккаунт и показывает inline-кнопки **«Подтвердить вход / Отклонить»**
|
||||
(с деталями: время, IP/устройство инициатора — для защиты от фишинга).
|
||||
3. Подтверждение (`ApproveTelegramLoginCommand`) → запрос переходит в `Approved`, привязывается к `userId`.
|
||||
4. Сайт (по поллингу/SignalR) получает результат: бэкенд выпускает **стандартные JWT** —
|
||||
access в теле ответа, refresh в httpOnly cookie. Запрос помечается `Consumed`.
|
||||
`{ requestId, deepLink, expiresAt }`. Сайт начинает поллить
|
||||
`GET /api/auth/telegram/login-request/{requestId}`.
|
||||
2. Пользователь открывает `https://t.me/<bot>?start=login_<requestId>` → бот по `from.id` находит
|
||||
привязанный аккаунт (если не найден — просит сначала привязать) и показывает сообщение
|
||||
«Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?» с инлайн-кнопками
|
||||
**«✅ Подтвердить вход» / «❌ Отклонить»**.
|
||||
3. Подтверждение → `ApproveTelegramLoginCommand`/`RejectTelegramLoginCommand` → запрос переходит в
|
||||
`Approved`/`Rejected`.
|
||||
4. Сайт по следующему поллингу получает результат: при `Approved` — `accessToken` в теле,
|
||||
`refresh` уже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включая
|
||||
`Secure = request.IsHttps`). Запрос помечается `Consumed`.
|
||||
|
||||
Если Telegram **не привязан** — passwordless-вход невозможен (бот предлагает сперва привязать
|
||||
аккаунт). Регистрация целиком через Telegram — вне MVP (см. backlog).
|
||||
Если Telegram **не привязан** — бот сразу сообщает «Сначала привяжите Telegram к аккаунту на сайте»,
|
||||
подтвердить вход невозможно. Регистрация целиком через Telegram — вне MVP.
|
||||
|
||||
## Флоу 3 — Просмотр конфигов в боте
|
||||
|
||||
1. Привязанный пользователь: `/configs` или кнопка «Мои конфиги».
|
||||
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от имени `AppUser`, найденного по `TelegramUserId`.
|
||||
3. Ответ — список с трафиком/сроком/статусом; по каждому конфигу — inline-кнопки «Ссылка», «QR».
|
||||
QR отдаётся как изображение (генерация на сервере).
|
||||
1. Привязанный пользователь: `/configs`.
|
||||
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
||||
3. Ответ — обычное текстовое сообщение, по строке на конфиг:
|
||||
`• {Label ?? Location} ({Protocol}) — {Status}`. Если конфигов нет — «У вас пока нет конфигов.»
|
||||
**Ссылок, QR и кнопок здесь нет** — за подключением пользователь идёт на сайт.
|
||||
|
||||
## Флоу 4 — Обработка активации админом в боте
|
||||
|
||||
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`);
|
||||
доменное событие `ActivationRequested`.
|
||||
2. Бот шлёт сообщение каждому админу (Telegram id из `AdminSeed__TelegramUserIds`) с username/комментарием
|
||||
заявителя и inline-кнопками **«✅ Активировать / ❌ Отклонить»**.
|
||||
3. Нажатие → `ApproveActivationCommand`/`RejectActivationCommand` (те же, что на сайте) → пользователь
|
||||
активируется, ему уходит realtime-пуш `userActivated`, админам обновляется сообщение (решение зафиксировано).
|
||||
4. Действие доступно только Telegram id из списка админов; проверка — на стороне бота перед вызовом команды.
|
||||
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`) →
|
||||
`RequestActivationCommandHandler` шлёт SignalR `activationRequested` группе `admins` **и** вызывает
|
||||
`ITelegramNotifier.NotifyAdminsActivationRequestedAsync` (прямой вызов из хендлера, без диспетчера событий).
|
||||
2. Каждому админу (по `Telegram__AdminTelegramUserIds`) уходит сообщение с именем и комментарием
|
||||
заявителя и кнопками **«✅ Активировать / ❌ Отклонить»**.
|
||||
3. Нажатие → `ApproveActivationCommand`/`RejectActivationCommand` (те же, что на сайте) →
|
||||
пользователь активируется/отклоняется, ему уходит realtime `userActivated` (только при одобрении) +
|
||||
Telegram-DM, если привязан; нажавшему админу приходит короткое подтверждение («✅ Пользователь
|
||||
активирован.»/«❌ Запрос отклонён.») — само сообщение с кнопками не редактируется.
|
||||
4. Проверка прав — на стороне бота (`TrySetAdminCurrentUserAsync`) перед вызовом команды: Telegram id
|
||||
должен быть в `Telegram__AdminTelegramUserIds`.
|
||||
|
||||
`/requests` — тот же список запросов по требованию (до 10 штук, `Pending`), с теми же кнопками; для
|
||||
кого он доступен — та же проверка админ-id.
|
||||
|
||||
## Команды и клавиатуры
|
||||
|
||||
| Команда / кнопка | Действие | Требует привязки |
|
||||
| ---------------------- | -------------------------------------------------------------- | ---------------- |
|
||||
| `/start` | Приветствие + справка по командам | нет |
|
||||
| `/start link_<t>` | Привязка аккаунта по токену | нет |
|
||||
| `/start login_<n>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
||||
| `/configs` | Список конфигов | да |
|
||||
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
||||
| `/help` | Справка | нет |
|
||||
| «Активировать/Отклонить» | (admin) решение по запросу активации | админ по env |
|
||||
| `/requests` | (admin) список ожидающих запросов активации | админ по env |
|
||||
| Команда / кнопка | Действие | Требует привязки |
|
||||
| -------------------------- | -------------------------------------------------------------- | ----------------- |
|
||||
| `/start` | Приветствие + справка по командам | нет |
|
||||
| `/start link_<token>` | Привязка аккаунта по токену | нет |
|
||||
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
||||
| `/configs` | Текстовый список конфигов | да |
|
||||
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
||||
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
||||
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||||
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
||||
|
||||
> Passwordless-вход и `/resetpassword` инициируются с сайта (кнопка «Войти через Telegram»),
|
||||
> не отдельной командой бота — см. пункт 6 выше про `/resetpassword` (backlog).
|
||||
Любой другой текст → «Не понимаю эту команду. /help — список команд.»
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Токены привязки и nonce входа: высокоэнтропийные, **короткоживущие** (≈2–5 мин), **одноразовые**.
|
||||
- Подтверждение входа показывает контекст (время/устройство) — защита от несанкционированных запросов.
|
||||
- Верификация источника апдейтов: webhook — секретный заголовок; long polling — прямой канал к Bot API по TLS.
|
||||
- Rate-limiting на создание login/link-запросов и на команды бота.
|
||||
- Токен бота — секрет (env/secret-store), в логи не попадает; апдейты логируются без чувствительных данных.
|
||||
- Passwordless-вход выпускает те же JWT/refresh, что и обычный — единые правила сессий и ротации.
|
||||
- Альтернатива боту для веб-входа — официальный **Telegram Login Widget** (HMAC-подпись данных
|
||||
ботом, верификация на бэке). Оставлено как опция; основной путь — подтверждение в боте.
|
||||
- Токены привязки и `requestId` входа: высокоэнтропийные, короткоживущие, одноразовые (см.
|
||||
`TelegramLinkToken`/`TelegramLoginRequest` в [domain-model.md](domain-model.md) — точный TTL не
|
||||
вынесен в отдельное конфигурируемое значение, см. [tech-stack.md](tech-stack.md)).
|
||||
- Подтверждение входа **не показывает** контекст (время/IP/устройство) инициатора — поле `Context`
|
||||
собирается (`CreateLoginRequestCommand`), но в текст сообщения бота не подставляется. Если это
|
||||
важно для защиты от фишинга — доработка на будущее, не текущее поведение.
|
||||
- Транспорт — только 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
|
||||
"Telegram": {
|
||||
"BotToken": "…", // секрет (env/secret-store)
|
||||
"BotUsername": "PnvPanelBot",
|
||||
"Mode": "LongPolling", // или "Webhook"
|
||||
"WebhookUrl": null,
|
||||
"WebhookSecret": null,
|
||||
"PublicSiteUrl": "https://panel.example.com"
|
||||
"BotToken": "…", // секрет; пусто = бот не стартует
|
||||
"BotUsername": "PnvPanelBot", // для deepLink; null/пусто -> deepLink в ответах API тоже null
|
||||
"AdminTelegramUserIds": "123456789,987654321" // через запятую
|
||||
// "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код,
|
||||
// не задавай его — эффекта не будет)
|
||||
}
|
||||
```
|
||||
|
||||
Telegram id администраторов задаются отдельно — `AdminSeed__TelegramUserIds` (см.
|
||||
[`.env.example`](../.env.example)); именно они авторизуют админ-кнопки в боте и получают
|
||||
уведомления о запросах активации.
|
||||
Переменные окружения — `Telegram__BotToken`, `Telegram__BotUsername`, `Telegram__AdminTelegramUserIds`
|
||||
(см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют,
|
||||
кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой
|
||||
сид-админа (`AdminSeed:*`), это независимый список.
|
||||
|
||||
Сообщения бота локализованы (**RU/EN**) по языку пользователя, синхронно с настройкой языка в вебе.
|
||||
|
||||
Строго типизированные `IOptions<TelegramOptions>` с валидацией на старте; при отсутствии
|
||||
`BotToken` бот не стартует (панель работает без него).
|
||||
Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
|
||||
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом —
|
||||
не реализована, backlog.
|
||||
|
||||
+4
-2
@@ -75,7 +75,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
|
||||
|
||||
### U2. Пользователь следит за трафиком
|
||||
- Фоновая синхронизация тянет трафик из 3x-ui; изменения приходят в UI через SignalR (без перезагрузки).
|
||||
- При достижении лимита/срока конфиг помечается и (опционально) отключается в 3x-ui.
|
||||
- **В MVP это только отображение**: лимиты по трафику/сроку конфига не реализованы — единственная
|
||||
квота — число активных конфигов на роль. Конфиг живёт, пока его явно не отзовут.
|
||||
|
||||
### A1. Админ подключает ноду и публикует инбаунды
|
||||
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 нод, метрики.
|
||||
- **Отказоустойчивость к нодам**: недоступность одной ноды не роняет панель; операции идемпотентны где возможно.
|
||||
- **Производительность**: списки с пагинацией; синхронизация трафика батчами.
|
||||
|
||||
Reference in New Issue
Block a user