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

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