diff --git a/.env.example b/.env.example index 4cbf622..7d05f97 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,6 @@ DataProtection__KeyRingPath=/app/keys # Логин в систему — по username. Email в системе не используется. AdminSeed__Username=admin AdminSeed__Password=change-me-strong-admin-password -# Telegram id(ы) администраторов (через запятую). Дают права админа в боте -# и получают уведомления о запросах на активацию. Узнать id: @userinfobot. -AdminSeed__TelegramUserIds=123456789 # ── Роли по умолчанию ───────────────────────────────────────────────────── # Квота конфигов для системной роли "user" (выдаётся при регистрации). @@ -38,19 +35,14 @@ Roles__DefaultUserMaxConfigs=3 # RateLimiting__AuthPermitLimit=20 # ── Telegram-бот ────────────────────────────────────────────────────────── -# Если BotToken пуст — бот не стартует, панель работает без него. +# Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling +# (webhook не реализован, отдельного режима/URL для него нет). Telegram__BotToken= Telegram__BotUsername=PnvPanelBot -Telegram__Mode=LongPolling -# Для Mode=Webhook: -# Telegram__WebhookUrl=https://panel.example.com/tg/webhook -# Telegram__WebhookSecret=change-me-webhook-secret - -# ── Приложение ──────────────────────────────────────────────────────────── -# Публичный URL сайта (для deep-link'ов бота и ссылок). -App__PublicSiteUrl=https://panel.example.com -# CORS-источники (для dev; в проде фронт и бек — один origin). -App__CorsOrigins=http://localhost:5173 +# Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации) +# и получают уведомления о запросах на активацию. Узнать id: @userinfobot. +# Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI. +Telegram__AdminTelegramUserIds=123456789 # ── ASP.NET Core ────────────────────────────────────────────────────────── ASPNETCORE_ENVIRONMENT=Production diff --git a/CLAUDE.md b/CLAUDE.md index 14e1b58..ed6fb4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,10 @@ хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек + бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose. -> **Статус: проектирование.** Код ещё не написан. Актуальны только документация и этот файл. -> При старте реализации следуй [`docs/roadmap.md`](docs/roadmap.md) (этапы M0…M6). +> **Статус: MVP реализован и работает.** Бэкенд (M0–M8) и фронтенд полностью собраны, покрыты +> тестами (134 бэкенд-теста), единый Docker-образ и docker-compose стек проверены живьём. История +> этапов — [`docs/roadmap.md`](docs/roadmap.md); там же — раздел Backlog с тем, что осознанно +> оставлено за рамками MVP (тарифы, лимиты трафика/срока на конфиг, полное самообслуживание в боте и т.д.). ## Документация (single source of truth) @@ -28,9 +30,12 @@ - **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR, - FluentValidation, Mapster, **Serilog** (логирование). -- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind CSS v4, - Zustand, react-hook-form + zod, @microsoft/signalr, Recharts. Пакетный менеджер — pnpm. + FluentValidation, **Serilog** (логирование). Маппинг DTO — вручную (`FromDomain(...)`), Mapster в + проект не попал. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI, без Swashbuckle. +- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn-стиль поверх Radix + + Tailwind CSS v4, Zustand (только auth-стор), react-hook-form + zod, @microsoft/signalr. Пакетный + менеджер — pnpm, линтер — oxlint. `recharts`/`@tanstack/react-table` установлены, но не + используются в MVP (статистика — карточками, таблицы — руками). - **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling). - **Инфра**: единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose. @@ -95,11 +100,14 @@ Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом (`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать. - **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа - (username/пароль/Telegram id) из переменных окружения; каталог приложений `ClientApp` (если пуст) — - из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — [`.env.example`](.env.example); - при добавлении новой настройки обновляй и его. Секреты (пароль админа, JWT-ключ, BotToken) — только через env/secret-store. -- Telegram id админов (`AdminSeed__TelegramUserIds`) авторизуют админ-действия в боте и получают - уведомления о запросах активации. + (`AdminSeed__Username`/`AdminSeed__Password`) из переменных окружения; каталог приложений `ClientApp` + (если пуст) — из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — + [`.env.example`](.env.example); при добавлении новой настройки обновляй и его. Секреты (пароль + админа, JWT-ключ, `Telegram__BotToken`) — только через env/secret-store. +- Telegram id админов — **отдельно от сидинга**, `Telegram__AdminTelegramUserIds` (через запятую), + читается `TelegramOptions` напрямую при каждой проверке, не пишется в БД. Именно он авторизует + админ-кнопки в боте и адресует уведомления о запросах активации. Seed-админ **не** привязывается к + Telegram автоматически — привязка делается вручную в UI, как у любого пользователя. ## Telegram-бот @@ -128,13 +136,19 @@ Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко: -- Команды `Command`, запросы `Query`, + `Handler`/`Validator`. DTO — суффикс `Dto`. +- Команды `Command`, запросы `Query`, + `Handler`/`Validator` (валидатор — + не для каждой команды, только где есть что проверить). Application DTO — суффикс `Dto` + (`FromDomain(...)` конвертирует из сущности); тела запросов Api-слоя — суффикс `Body`; тела ответов, + которых нет как Application DTO — суффикс `ResponseDto`. - Application организована **по фичам** (feature folders) внутри слоёв. -- Один публичный тип на файл, имя файла = имя типа. Async-методы — суффикс `Async` + `CancellationToken`. -- Секреты не логировать; логи структурные (Serilog) с `UserId`/`NodeId`/`ConfigId`/`CorrelationId`. -- Ошибки API — единый `ProblemDetails`. +- Один публичный тип на файл, имя файла = имя типа (кроме вспомогательных `Body`/`ResponseDto` + records — они живут в том же файле, что и класс эндпоинтов). Async-методы — суффикс `Async` + `CancellationToken`. +- Секреты не логировать; логи — Serilog (`UseSerilogRequestLogging` + `Enrich.FromLogContext()`). + Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId` пока нет — не полагайся на него при + расследовании, пока не добавлено. +- Ошибки API — единый `application/problem+json` (без Swashbuckle — нативный `Microsoft.AspNetCore.OpenApi`). -## Команды (ожидаемые — появятся по мере создания проектов) +## Команды Backend (из `backend/`): ```bash diff --git a/README.md b/README.md index 61f6027..2a8154c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,28 @@ | Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind | | Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose | +## Быстрый старт + +Единый Docker-образ (API + бот + статика SPA) + PostgreSQL: + +```bash +cp .env.example .env # заполнить AdminSeed__Password, Jwt__SigningKey и т.д. +docker compose up -d --build +# → http://localhost:8080 (админ — логин/пароль из .env, AdminSeed__Username/Password) +``` + +Локальная разработка (без Docker для приложения — только `db`): + +```bash +# backend, из backend/ +dotnet build && dotnet test +dotnet run --project src/PnvPanel.Api + +# frontend, из frontend/ +pnpm install +pnpm dev # проксирует /api, /hubs на localhost:8080 +``` + ## Документация Проектная документация лежит в [`docs/`](docs/README.md): @@ -33,14 +55,19 @@ - [Frontend](docs/frontend.md) — стек фронтенда и структура - [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход - [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты -- [Roadmap](docs/roadmap.md) — этапы разработки +- [Roadmap](docs/roadmap.md) — этапы разработки (ретроспектива) + backlog Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example). Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md). ## Статус -🚧 Проектирование. Кодовая база ещё не создана — на этом этапе зафиксированы архитектура и план. +✅ **MVP реализован.** Backend (Clean Architecture, CQRS, 134 теста) и frontend (React SPA, полная +админка) готовы, покрывают весь [roadmap](docs/roadmap.md) M0–M8: регистрация/активация/роли, +управление нодами 3x-ui и inbound'ами, самообслуживание конфигами, realtime по SignalR, Telegram-бот +(привязка + passwordless-вход + уведомления), админ-статистика и аудит. Единый Docker-образ и +docker-compose стек проверены end-to-end. Что осталось за рамками MVP осознанно — раздел Backlog в +[roadmap.md](docs/roadmap.md#backlog-после-mvp). ## Лицензия diff --git a/docs/README.md b/docs/README.md index fc9c458..33ab01f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. ## Принятые решения diff --git a/docs/api-design.md b/docs/api-design.md index 158859a..36ace04 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -1,181 +1,210 @@ # API Design -REST поверх HTTP/JSON, авторизация — `Authorization: Bearer ` (кроме публичных). -Ошибки — `application/problem+json` (`ProblemDetails`). Пагинация — `?page=&pageSize=`, -ответ `PagedList` (`items`, `total`, `page`, `pageSize`). Все даты — ISO-8601 UTC. +REST поверх HTTP/JSON, авторизация — `Authorization: Bearer ` (кроме публичных +эндпоинтов). Ошибки — `application/problem+json`. Пагинация — `?page=&pageSize=`, ответ `PagedList` +(`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()`, так что схема полностью описывает и тела запросов, и тела ответов. +Ниже — полный контракт, сверенный построчно с кодом (`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/?start=link_` / `?start=login_`. **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` | +| 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` | +| 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` | +| 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` | + +**Блокировка/разблокировка — два отдельных эндпоинта без тела**, не один переключатель `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=; download=; total=; expire=` и +`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), либо конфиг остаётся в старом статусе, если это фоновая синхронизация. diff --git a/docs/architecture.md b/docs/architecture.md index 2e48796..43e3a94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`/ +> `IdentityRole`), а домен ссылается на пользователя/роль только по `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**: явная модель успеха/ошибки вместо исключений для управляемых сценариев. +- **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**: явная модель успеха/ошибки (`Result`/`Result`, `Error` с `ErrorType`) вместо + исключений для управляемых сценариев. ### 3. `PnvPanel.Infrastructure` Технические детали и реализации портов. - **Persistence**: `AppDbContext : IdentityDbContext`, реализует `IAppDbContext`; - `IEntityTypeConfiguration` для маппингов; миграции 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` для маппингов; миграции EF Core. Репозиториев нет — хендлеры работают + через `IAppDbContext` напрямую (`DbSet` + LINQ). +- **Identity & Auth**: ASP.NET Core Identity, `JwtTokenService` (access + refresh), `RefreshTokenService` + (хранение/ротация/отзыв refresh-токенов), `RoleService`, `DbInitializer` (сидинг). +- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; кэш клиентов per-node + внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет). +- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService`, `TrafficRetentionService` + (`BackgroundService` + `PeriodicTimer`). +- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest, + ASP.NET Core Data Protection, key-ring на томе `dp_keys`). +- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`. + +> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.** +> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext`, а сам `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()`, чтобы 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`, `IQuery`, `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>` + (ключ — `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` полем `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` → маппинг в HTTP-статус + `ProblemDetails`. -- Непредвиденные исключения → глобальный middleware → 500 + корреляция + структурный лог (без утечки деталей). -- Доменные исключения (нарушение инвариантов) → 409/422 с понятным сообщением. +- Управляемые ошибки → `Result`/`Result` (`ResultExtensions.ToHttpResult`) → маппинг `ErrorType` в + HTTP-статус + `application/problem+json` (400/401/403/404/409/422). +- Непредвиденные исключения → встроенные `AddProblemDetails()` + `UseExceptionHandler()` → 500 без + утечки деталей + `Serilog` request-логирование (`UseSerilogRequestLogging`, обогащение — + `Enrich.FromLogContext()`). Сквозного `CorrelationId`/явного обогащения `UserId`/`NodeId`/`ConfigId` + в логах пока нет — задел на будущее, а не то, на что стоит полагаться при расследовании инцидентов сегодня. +- Доменные исключения (нарушение инвариантов) — `DomainException`, ожидаются только как баг, а не + штатный путь (штатные отказы — через `Result.Failure`, не исключения). ## Развёртывание (единый контейнер приложения) @@ -238,7 +305,7 @@ PostgreSQL: - **Миграции**: применяются **автоматически на старте** приложения (в MVP; при масштабировании на несколько инстансов — вынести в отдельный шаг/джобу). - Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения, - JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`, `PublicSiteUrl`). + JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`). ``` [ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose diff --git a/docs/backend-conventions.md b/docs/backend-conventions.md index efec9cf..24840f8 100644 --- a/docs/backend-conventions.md +++ b/docs/backend-conventions.md @@ -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, IQuery, ICommandHandler<,>, IQueryHandler<,>, IPipelineBehavior<,> - Models/ # Result, Error, PagedList - 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, Error, PagedList, 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, IAppDbContext Configurations/ # IEntityTypeConfiguration 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) + 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 ``` Организация Application — **по фичам** (feature folders), внутри слоёв Clean Architecture. @@ -57,27 +79,43 @@ backend/ - Классы/методы/свойства — `PascalCase`; параметры/локальные — `camelCase`; приватные поля — `_camelCase`. - Команды — `Command` (`CreateVpnConfigCommand`), запросы — `Query`. - Хендлеры — `Handler`; валидаторы — `Validator`. -- DTO — суффикс `Dto` (`VpnConfigDto`); ответы эндпоинтов — `Response`, тела запросов — `Request`. +- **DTO** (Application-слой, возвращаются из `Result`) — суффикс `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` / `IQueryHandler<,>`; `ISender` резолвит их из DI и прогоняет через `IPipelineBehavior<,>` - (валидация, транзакция, логирование). Без внешних CQRS-библиотек. -- **Порты в Application, адаптеры в Infrastructure**: никакого `Npgsql`/`SignalR`/`ThreeXui.Net` в Application/Domain. -- **`IAppDbContext`** экспонирует `DbSet<>` и `SaveChangesAsync`; хендлеры пишут LINQ напрямую - (репозитории — только для сложной агрегатной логики). -- **Result-модель**: команды/запросы возвращают `Result`; эндпоинт маппит в 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`). +- **`IAppDbContext`** экспонирует `DbSet<>` и `SaveChangesAsync`; хендлеры пишут LINQ напрямую — + выделенных репозиториев нет вообще. +- **Result-модель**: команды/запросы возвращают `Result`/`Result`; `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`, сквозные сценарии через реальный 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` для секций конфига; валидация опций на старте. +- `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` для секций конфига (`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 там, где это + не очевидно из имени. diff --git a/docs/domain-model.md b/docs/domain-model.md index d2cd2b3..60f0964 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -1,22 +1,25 @@ # Domain Model Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах. -`AppUser` — часть Identity (в `Infrastructure`); домен ссылается на пользователя по `UserId : Guid`. +`AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser`/ +`IdentityRole`); чистый `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` в 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). diff --git a/docs/frontend.md b/docs/frontend.md index ddd821d..c442171 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -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` на + `` + Tailwind, выбор в `localStorage` (`ThemeProvider`, React Context). +- **Дашборд пользователя** (`/dashboard`): карточки конфигов (протокол, локация, использованный + трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику, + не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для + неактивированного — `ActivationGate` вместо дашборда. +- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка + лимит устройств. + После успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно. +- **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`), + сгруппированный по ОС; клик по приложению открывает ссылку на скачивание. +- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`), + удаление аккаунта с подтверждением (`DeleteAccountSection`). +- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации, + пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные ``, + без TanStack Table. Блокировка пользователя — с подтверждением. +- **Состояния**: `isLoading`/`isError`/пусто различаются явно везде (ошибка сети не выглядит как + «пусто» — паттерн закреплён после находки в `ActivationGate`, распространён на все admin-списки). ## Работа с API -- HTTP-клиент оборачивает `fetch`: подставляет access-token, при 401 — прозрачно обновляет через - refresh-cookie и повторяет запрос; при неуспехе — разлогин. -- Все запросы/мутации — через TanStack Query (ключи по фичам, инвалидация после мутаций). -- Типы ответов/запросов — из codegen по OpenAPI (никакого ручного дублирования DTO). +- `shared/api/client.ts`: `apiRequest()` оборачивает `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`). diff --git a/docs/roadmap.md b/docs/roadmap.md index cc59b6c..20b2f21 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. +- Фронт: таблицы пользователей/ролей/нод/приложений (обычные `
`, без TanStack Table), журнал + аудита, статистика карточками (без графиков — `recharts` установлен, но не подключён). +- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; + блокировка гасит VPN. ✅ Достигнуто. ## M7 — Telegram-бот ✅ - Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions`. - Домен: поля 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_`/`login_` deep-link payload), «Мои конфиги» + (`/configs`, текстовый список, без ссылок/QR), `/unlink`, `/requests`, `/help`. - **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env). - **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам. - **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто. diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 7b8d953..609ab03 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -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` + -диспетчеризация после `SaveChanges`. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели). +### CQRS: собственный тонкий диспетчер ✅ (зафиксировано и реализовано) +**Решение принято**: свой `ISender` вместо MediatR (тот с v12 стал платным). `ISender.Send()` +резолвит `ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через `IPipelineBehavior<,>`. +Реализованы три поведения: `ValidationBehavior` (FluentValidation), `LoggingBehavior`, +`UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Плюсы: нет лицензий и внешних +зависимостей, полный контроль. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели). + +**Отличие от исходного плана**: отдельного диспетчера доменных событий (`IDomainEventHandler`) в +итоге не заводили — оказалось, что для текущего размера проекта прямые вызовы `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()`, чтобы схема полностью описывала +и тела запросов, и тела ответов. -### Тесты: xUnit + FluentAssertions + NSubstitute + Testcontainers -Юнит-тесты домена/хендлеров (моками портов), интеграционные — с реальным PostgreSQL в Testcontainers. +### Тесты: xUnit + NSubstitute + Testcontainers +Юнит-тесты домена/хендлеров (без FluentAssertions — обычные `Assert.*` из xUnit хватает для +используемых проверок), интеграционные — с реальным PostgreSQL в Testcontainers +(`Testcontainers.PostgreSql` + `WebApplicationFactory`). ## 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`) и понятные имена, которых нет в 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/админа). diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index cb3a59f..bb161c0 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -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_`) + смену пароля в настройках на сайте. +- 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/?start=link_` (+ QR). Токен короткоживущий, одноразовый. +1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token` → `{ deepLink, expiresAt }`, + `deepLink` вида `https://t.me/?start=link_`. Фронт рисует QR из `deepLink` сам + (`qrcode.react`) — бэкенд картинку не генерирует. 2. Пользователь открывает бота по ссылке → `/start link_`. -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/?start=login_` → бот по `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/?start=login_` → бот по `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_` | Привязка аккаунта по токену | нет | -| `/start login_` | Подтверждение passwordless-входа (deep-link с сайта) | да | -| `/configs` | Список конфигов | да | -| `/unlink` | Отвязать Telegram от аккаунта | да | -| `/help` | Справка | нет | -| «Активировать/Отклонить» | (admin) решение по запросу активации | админ по env | -| `/requests` | (admin) список ожидающих запросов активации | админ по env | +| Команда / кнопка | Действие | Требует привязки | +| -------------------------- | -------------------------------------------------------------- | ----------------- | +| `/start` | Приветствие + справка по командам | нет | +| `/start link_` | Привязка аккаунта по токену | нет | +| `/start login_` | Подтверждение 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` с валидацией на старте; при отсутствии -`BotToken` бот не стартует (панель работает без него). +Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка +интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом — +не реализована, backlog. diff --git a/docs/vision.md b/docs/vision.md index a73bbeb..66f2bf7 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -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 нод, метрики. - **Отказоустойчивость к нодам**: недоступность одной ноды не роняет панель; операции идемпотентны где возможно. - **Производительность**: списки с пагинацией; синхронизация трафика батчами. diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index d6af7e3..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the Oxlint configuration - -If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: - -```json -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "typescript", "oxc"], - "options": { - "typeAware": true - }, - "rules": { - "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } -} -``` - -See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.