Refactor environment configuration and update documentation for MVP status
- Removed deprecated Telegram user ID configuration from `.env.example` and added a new setting for admin Telegram user IDs. - Updated `CLAUDE.md` to reflect the current MVP status, detailing completed features and testing coverage. - Enhanced `README.md` with quick start instructions for Docker setup and clarified project status. - Revised API design documentation to include updated error handling and request/response structures. - Improved frontend documentation to outline the project structure and technologies used.
This commit is contained in:
+6
-14
@@ -25,9 +25,6 @@ DataProtection__KeyRingPath=/app/keys
|
|||||||
# Логин в систему — по username. Email в системе не используется.
|
# Логин в систему — по username. Email в системе не используется.
|
||||||
AdminSeed__Username=admin
|
AdminSeed__Username=admin
|
||||||
AdminSeed__Password=change-me-strong-admin-password
|
AdminSeed__Password=change-me-strong-admin-password
|
||||||
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте
|
|
||||||
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
|
|
||||||
AdminSeed__TelegramUserIds=123456789
|
|
||||||
|
|
||||||
# ── Роли по умолчанию ─────────────────────────────────────────────────────
|
# ── Роли по умолчанию ─────────────────────────────────────────────────────
|
||||||
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
|
# Квота конфигов для системной роли "user" (выдаётся при регистрации).
|
||||||
@@ -38,19 +35,14 @@ Roles__DefaultUserMaxConfigs=3
|
|||||||
# RateLimiting__AuthPermitLimit=20
|
# RateLimiting__AuthPermitLimit=20
|
||||||
|
|
||||||
# ── Telegram-бот ──────────────────────────────────────────────────────────
|
# ── Telegram-бот ──────────────────────────────────────────────────────────
|
||||||
# Если BotToken пуст — бот не стартует, панель работает без него.
|
# Если BotToken пуст — бот не стартует, панель работает без него. Транспорт — только long polling
|
||||||
|
# (webhook не реализован, отдельного режима/URL для него нет).
|
||||||
Telegram__BotToken=
|
Telegram__BotToken=
|
||||||
Telegram__BotUsername=PnvPanelBot
|
Telegram__BotUsername=PnvPanelBot
|
||||||
Telegram__Mode=LongPolling
|
# Telegram id(ы) администраторов (через запятую). Дают права админа в боте (кнопки активации)
|
||||||
# Для Mode=Webhook:
|
# и получают уведомления о запросах на активацию. Узнать id: @userinfobot.
|
||||||
# Telegram__WebhookUrl=https://panel.example.com/tg/webhook
|
# Не связано с сид-админом выше — привязка Telegram к сид-админу делается вручную в UI.
|
||||||
# Telegram__WebhookSecret=change-me-webhook-secret
|
Telegram__AdminTelegramUserIds=123456789
|
||||||
|
|
||||||
# ── Приложение ────────────────────────────────────────────────────────────
|
|
||||||
# Публичный URL сайта (для deep-link'ов бота и ссылок).
|
|
||||||
App__PublicSiteUrl=https://panel.example.com
|
|
||||||
# CORS-источники (для dev; в проде фронт и бек — один origin).
|
|
||||||
App__CorsOrigins=http://localhost:5173
|
|
||||||
|
|
||||||
# ── ASP.NET Core ──────────────────────────────────────────────────────────
|
# ── ASP.NET Core ──────────────────────────────────────────────────────────
|
||||||
ASPNETCORE_ENVIRONMENT=Production
|
ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек +
|
хранит свою проекцию домена в PostgreSQL. Живые обновления — по SignalR. Приложение (фронт + бек +
|
||||||
бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose.
|
бот) поставляется **единым Docker-образом**; PostgreSQL — отдельным контейнером в compose.
|
||||||
|
|
||||||
> **Статус: проектирование.** Код ещё не написан. Актуальны только документация и этот файл.
|
> **Статус: MVP реализован и работает.** Бэкенд (M0–M8) и фронтенд полностью собраны, покрыты
|
||||||
> При старте реализации следуй [`docs/roadmap.md`](docs/roadmap.md) (этапы M0…M6).
|
> тестами (134 бэкенд-теста), единый Docker-образ и docker-compose стек проверены живьём. История
|
||||||
|
> этапов — [`docs/roadmap.md`](docs/roadmap.md); там же — раздел Backlog с тем, что осознанно
|
||||||
|
> оставлено за рамками MVP (тарифы, лимиты трафика/срока на конфиг, полное самообслуживание в боте и т.д.).
|
||||||
|
|
||||||
## Документация (single source of truth)
|
## Документация (single source of truth)
|
||||||
|
|
||||||
@@ -28,9 +30,12 @@
|
|||||||
|
|
||||||
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий
|
- **Backend**: C# / .NET 10, ASP.NET Core Web API, Clean Architecture, CQRS (**собственный тонкий
|
||||||
диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR,
|
диспетчер**, без MediatR), EF Core 10 + Npgsql (PostgreSQL), ASP.NET Core Identity + JWT, SignalR,
|
||||||
FluentValidation, Mapster, **Serilog** (логирование).
|
FluentValidation, **Serilog** (логирование). Маппинг DTO — вручную (`FromDomain(...)`), Mapster в
|
||||||
- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind CSS v4,
|
проект не попал. OpenAPI — нативный `Microsoft.AspNetCore.OpenApi` + Scalar UI, без Swashbuckle.
|
||||||
Zustand, react-hook-form + zod, @microsoft/signalr, Recharts. Пакетный менеджер — pnpm.
|
- **Frontend**: React 19 + Vite + TypeScript, TanStack Query/Router, shadcn-стиль поверх Radix +
|
||||||
|
Tailwind CSS v4, Zustand (только auth-стор), react-hook-form + zod, @microsoft/signalr. Пакетный
|
||||||
|
менеджер — pnpm, линтер — oxlint. `recharts`/`@tanstack/react-table` установлены, но не
|
||||||
|
используются в MVP (статистика — карточками, таблицы — руками).
|
||||||
- **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling).
|
- **Telegram**: Telegram.Bot, бот как `BackgroundService` **в процессе Api** (long polling).
|
||||||
- **Инфра**: единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose.
|
- **Инфра**: единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose.
|
||||||
|
|
||||||
@@ -95,11 +100,14 @@
|
|||||||
Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом
|
Восстановление пароля: через привязанный Telegram (self-service), без привязки — сброс админом
|
||||||
(`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать.
|
(`ResetUserPasswordCommand`). Пока Telegram не привязан — UI настойчиво предлагает его привязать.
|
||||||
- **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа
|
- **Сидинг из env**: идемпотентный `DbInitializer` на старте создаёт системные роли и учётку админа
|
||||||
(username/пароль/Telegram id) из переменных окружения; каталог приложений `ClientApp` (если пуст) —
|
(`AdminSeed__Username`/`AdminSeed__Password`) из переменных окружения; каталог приложений `ClientApp`
|
||||||
из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env — [`.env.example`](.env.example);
|
(если пуст) — из [`seed/client-apps.json`](seed/client-apps.json). Единый источник примера env —
|
||||||
при добавлении новой настройки обновляй и его. Секреты (пароль админа, JWT-ключ, BotToken) — только через env/secret-store.
|
[`.env.example`](.env.example); при добавлении новой настройки обновляй и его. Секреты (пароль
|
||||||
- Telegram id админов (`AdminSeed__TelegramUserIds`) авторизуют админ-действия в боте и получают
|
админа, JWT-ключ, `Telegram__BotToken`) — только через env/secret-store.
|
||||||
уведомления о запросах активации.
|
- Telegram id админов — **отдельно от сидинга**, `Telegram__AdminTelegramUserIds` (через запятую),
|
||||||
|
читается `TelegramOptions` напрямую при каждой проверке, не пишется в БД. Именно он авторизует
|
||||||
|
админ-кнопки в боте и адресует уведомления о запросах активации. Seed-админ **не** привязывается к
|
||||||
|
Telegram автоматически — привязка делается вручную в UI, как у любого пользователя.
|
||||||
|
|
||||||
## Telegram-бот
|
## Telegram-бот
|
||||||
|
|
||||||
@@ -128,13 +136,19 @@
|
|||||||
|
|
||||||
Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко:
|
Полный список — в [backend-conventions.md](docs/backend-conventions.md). Кратко:
|
||||||
|
|
||||||
- Команды `<Verb><Noun>Command`, запросы `<Get/List><Noun>Query`, + `Handler`/`Validator`. DTO — суффикс `Dto`.
|
- Команды `<Verb><Noun>Command`, запросы `<Get/List><Noun>Query`, + `Handler`/`Validator` (валидатор —
|
||||||
|
не для каждой команды, только где есть что проверить). Application DTO — суффикс `Dto`
|
||||||
|
(`FromDomain(...)` конвертирует из сущности); тела запросов Api-слоя — суффикс `Body`; тела ответов,
|
||||||
|
которых нет как Application DTO — суффикс `ResponseDto`.
|
||||||
- Application организована **по фичам** (feature folders) внутри слоёв.
|
- Application организована **по фичам** (feature folders) внутри слоёв.
|
||||||
- Один публичный тип на файл, имя файла = имя типа. Async-методы — суффикс `Async` + `CancellationToken`.
|
- Один публичный тип на файл, имя файла = имя типа (кроме вспомогательных `Body`/`ResponseDto`
|
||||||
- Секреты не логировать; логи структурные (Serilog) с `UserId`/`NodeId`/`ConfigId`/`CorrelationId`.
|
records — они живут в том же файле, что и класс эндпоинтов). Async-методы — суффикс `Async` + `CancellationToken`.
|
||||||
- Ошибки API — единый `ProblemDetails`.
|
- Секреты не логировать; логи — Serilog (`UseSerilogRequestLogging` + `Enrich.FromLogContext()`).
|
||||||
|
Явного обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId` пока нет — не полагайся на него при
|
||||||
|
расследовании, пока не добавлено.
|
||||||
|
- Ошибки API — единый `application/problem+json` (без Swashbuckle — нативный `Microsoft.AspNetCore.OpenApi`).
|
||||||
|
|
||||||
## Команды (ожидаемые — появятся по мере создания проектов)
|
## Команды
|
||||||
|
|
||||||
Backend (из `backend/`):
|
Backend (из `backend/`):
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -21,6 +21,28 @@
|
|||||||
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind |
|
| Frontend | React 19 + Vite + TypeScript, TanStack Query/Router, shadcn/ui + Tailwind |
|
||||||
| Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose |
|
| Упаковка | Единый Docker-образ (API + бот + статика SPA) + PostgreSQL в docker-compose |
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
Единый Docker-образ (API + бот + статика SPA) + PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # заполнить AdminSeed__Password, Jwt__SigningKey и т.д.
|
||||||
|
docker compose up -d --build
|
||||||
|
# → http://localhost:8080 (админ — логин/пароль из .env, AdminSeed__Username/Password)
|
||||||
|
```
|
||||||
|
|
||||||
|
Локальная разработка (без Docker для приложения — только `db`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# backend, из backend/
|
||||||
|
dotnet build && dotnet test
|
||||||
|
dotnet run --project src/PnvPanel.Api
|
||||||
|
|
||||||
|
# frontend, из frontend/
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # проксирует /api, /hubs на localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
Проектная документация лежит в [`docs/`](docs/README.md):
|
Проектная документация лежит в [`docs/`](docs/README.md):
|
||||||
@@ -33,14 +55,19 @@
|
|||||||
- [Frontend](docs/frontend.md) — стек фронтенда и структура
|
- [Frontend](docs/frontend.md) — стек фронтенда и структура
|
||||||
- [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход
|
- [Telegram Bot](docs/telegram-bot.md) — бот, привязка Telegram и passwordless-вход
|
||||||
- [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты
|
- [API Design](docs/api-design.md) — REST-эндпоинты и SignalR-контракты
|
||||||
- [Roadmap](docs/roadmap.md) — этапы разработки
|
- [Roadmap](docs/roadmap.md) — этапы разработки (ретроспектива) + backlog
|
||||||
|
|
||||||
Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example).
|
Пример переменных окружения (сид админа, БД, JWT, Telegram) — [`.env.example`](.env.example).
|
||||||
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md).
|
Инструкции для AI-ассистента (Claude Code) — в [`CLAUDE.md`](CLAUDE.md).
|
||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
🚧 Проектирование. Кодовая база ещё не создана — на этом этапе зафиксированы архитектура и план.
|
✅ **MVP реализован.** Backend (Clean Architecture, CQRS, 134 теста) и frontend (React SPA, полная
|
||||||
|
админка) готовы, покрывают весь [roadmap](docs/roadmap.md) M0–M8: регистрация/активация/роли,
|
||||||
|
управление нодами 3x-ui и inbound'ами, самообслуживание конфигами, realtime по SignalR, Telegram-бот
|
||||||
|
(привязка + passwordless-вход + уведомления), админ-статистика и аудит. Единый Docker-образ и
|
||||||
|
docker-compose стек проверены end-to-end. Что осталось за рамками MVP осознанно — раздел Backlog в
|
||||||
|
[roadmap.md](docs/roadmap.md#backlog-после-mvp).
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -1,16 +1,21 @@
|
|||||||
# PnvPanel — Документация
|
# PnvPanel — Документация
|
||||||
|
|
||||||
Индекс проектной документации. Читать в этом порядке для погружения:
|
**MVP реализован** (backend M0–M8 + полный frontend). Документация ниже описывает систему как она
|
||||||
|
реально построена, со сверенными по коду деталями — не первоначальный план. Расхождения с ранним
|
||||||
|
замыслом отмечены явно там, где это важно (например, домен изначально закладывал доменные события —
|
||||||
|
в реализации от них отказались в пользу прямых вызовов из CQRS-хендлеров, см. [architecture.md](architecture.md)).
|
||||||
|
|
||||||
|
Индекс. Читать в этом порядке для погружения:
|
||||||
|
|
||||||
1. **[Product Vision & Scope](vision.md)** — продукт, роли, пользовательские сценарии, границы MVP.
|
1. **[Product Vision & Scope](vision.md)** — продукт, роли, пользовательские сценарии, границы MVP.
|
||||||
2. **[Architecture](architecture.md)** — Clean Architecture, слои, CQRS, интеграция с 3x-ui, realtime, безопасность, фоновые задачи.
|
2. **[Architecture](architecture.md)** — Clean Architecture, слои, CQRS, интеграция с 3x-ui, realtime, безопасность, фоновые задачи.
|
||||||
3. **[Domain Model](domain-model.md)** — сущности, value objects, связи, инварианты, доменные события.
|
3. **[Domain Model](domain-model.md)** — сущности, value objects, связи, инварианты, уведомления/аудит.
|
||||||
4. **[Tech Stack (ADR)](tech-stack.md)** — принятые решения по технологиям и их обоснование.
|
4. **[Tech Stack (ADR)](tech-stack.md)** — принятые решения по технологиям и их обоснование.
|
||||||
5. **[Backend Conventions](backend-conventions.md)** — структура решения, паттерны, соглашения по коду.
|
5. **[Backend Conventions](backend-conventions.md)** — структура решения, паттерны, соглашения по коду.
|
||||||
6. **[Frontend](frontend.md)** — стек, структура, работа с API и realtime.
|
6. **[Frontend](frontend.md)** — стек, структура, работа с API и realtime.
|
||||||
7. **[Telegram Bot](telegram-bot.md)** — бот: ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram.
|
7. **[Telegram Bot](telegram-bot.md)** — бот: ссылка на сайт, просмотр конфигов, passwordless-вход через привязку Telegram.
|
||||||
8. **[API Design](api-design.md)** — контракты REST и SignalR.
|
8. **[API Design](api-design.md)** — контракты REST и SignalR.
|
||||||
9. **[Roadmap](roadmap.md)** — этапы (milestones) и порядок реализации.
|
9. **[Roadmap](roadmap.md)** — ретроспектива по этапам (milestones) + backlog.
|
||||||
|
|
||||||
## Принятые решения
|
## Принятые решения
|
||||||
|
|
||||||
|
|||||||
+150
-121
@@ -1,181 +1,210 @@
|
|||||||
# API Design
|
# API Design
|
||||||
|
|
||||||
REST поверх HTTP/JSON, авторизация — `Authorization: Bearer <access-token>` (кроме публичных).
|
REST поверх HTTP/JSON, авторизация — `Authorization: Bearer <access-token>` (кроме публичных
|
||||||
Ошибки — `application/problem+json` (`ProblemDetails`). Пагинация — `?page=&pageSize=`,
|
эндпоинтов). Ошибки — `application/problem+json`. Пагинация — `?page=&pageSize=`, ответ `PagedList<T>`
|
||||||
ответ `PagedList<T>` (`items`, `total`, `page`, `pageSize`). Все даты — ISO-8601 UTC.
|
(`items`, `total`, `page`, `pageSize`) — используется не везде, см. таблицы ниже. Все даты — ISO-8601 UTC.
|
||||||
|
Тела запросов/ответов — camelCase JSON; енумы сериализуются строками (`"Active"`, не `0`).
|
||||||
|
|
||||||
Базовый префикс: `/api` (**без версионирования в MVP** — единый фронт+бек; версии введём при
|
Базовый префикс: `/api` (**без версионирования в MVP**). Схема генерируется нативным
|
||||||
необходимости). Ниже — контракт MVP (может уточняться при реализации).
|
`Microsoft.AspNetCore.OpenApi` (`/openapi/v1.json`) и Scalar UI (`/scalar`) — каждый эндпоинт
|
||||||
|
аннотирован `.Produces<T>()`, так что схема полностью описывает и тела запросов, и тела ответов.
|
||||||
|
Ниже — полный контракт, сверенный построчно с кодом (`backend/src/PnvPanel.Api/Endpoints/*.cs`).
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
| ----- | --------------------------- | ------ | ---------------------------------------------------- |
|
| ----- | --------------------------- | ------ | ------------------------------------------ | -------------------------------------- |
|
||||||
| POST | `/api/auth/register` | — | Регистрация `{ username, password }` |
|
| POST | `/api/auth/register` | — | `{ userName, password }` | `{ id, userName }` |
|
||||||
| POST | `/api/auth/login` | — | Вход `{ username, password }` → access (body) + refresh (httpOnly cookie) |
|
| POST | `/api/auth/login` | — | `{ userName, password }` | `{ accessToken, expiresAt, user }` + refresh в httpOnly cookie |
|
||||||
| POST | `/api/auth/refresh` | — | Обновление access по refresh-cookie (ротация) |
|
| POST | `/api/auth/refresh` | — | — (refresh из cookie) | то же, что login; ротация cookie |
|
||||||
| POST | `/api/auth/logout` | user | Отзыв refresh-токена |
|
| POST | `/api/auth/logout` | user | — | `204 No Content` |
|
||||||
| POST | `/api/auth/change-password` | user | Смена пароля `{ currentPassword, newPassword }` |
|
| POST | `/api/auth/change-password` | user | `{ currentPassword, newPassword }` | `204 No Content` |
|
||||||
| GET | `/api/auth/me` | user | Текущий профиль + роль + `isActivated` + `telegramLinked` |
|
| GET | `/api/auth/me` | user | — | `{ id, userName, role, isActivated, telegramLinked }` |
|
||||||
| DELETE| `/api/auth/me` | user | Самоудаление аккаунта (отзыв всех конфигов + удаление данных; аудит анонимизируется) |
|
| DELETE| `/api/auth/me` | user | — | `204 No Content` |
|
||||||
|
|
||||||
> **Вход по username.** Email в системе не используется. Забыт пароль:
|
`user`/ответ `/me` — **`role` строкой** (одна роль, не массив). Группа `/api/auth/*` под общим
|
||||||
> при привязанном Telegram — восстановление через бота; иначе — сброс админом (см. Admin).
|
rate-limit'ом (`RateLimiting:AuthPermitLimit`, по умолчанию 20 запросов/мин).
|
||||||
|
|
||||||
|
> **Вход по username.** Email в системе не используется. Забыт пароль: при привязанном
|
||||||
|
> Telegram — вход без пароля через бота и смена пароля в настройках; иначе — сброс админом (см. Admin).
|
||||||
|
|
||||||
## Auth — Telegram (привязка и passwordless-вход)
|
## Auth — Telegram (привязка и passwordless-вход)
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
Группа `/api/auth/telegram/*`, тот же rate-limit, что и `/api/auth/*`.
|
||||||
| ----- | --------------------------------------------- | ---- | -------------------------------------------------------------- |
|
|
||||||
| POST | `/api/auth/telegram/link-token` | user | Создать токен привязки → `{ deepLink, qr, expiresAt }` |
|
|
||||||
| POST | `/api/auth/telegram/unlink` | user | Отвязать Telegram от аккаунта |
|
|
||||||
| POST | `/api/auth/telegram/login-request` | — | Инициировать вход → `{ requestId, deepLink, qr, expiresAt }` |
|
|
||||||
| GET | `/api/auth/telegram/login-request/{id}` | — | Статус запроса; при `Approved` выдаёт access + refresh-cookie |
|
|
||||||
|
|
||||||
`GET …/login-request/{id}` (поллинг; альтернатива — событие SignalR) → варианты ответа:
|
| Метод | Путь | Роль | Тело ответа |
|
||||||
|
| ----- | --------------------------------------------- | ---- | ------------------------------------------------------ |
|
||||||
|
| POST | `/api/auth/telegram/link-token` | user | `{ deepLink, expiresAt }` |
|
||||||
|
| POST | `/api/auth/telegram/unlink` | user | `204 No Content` |
|
||||||
|
| POST | `/api/auth/telegram/login-request` | — | `{ requestId, deepLink, expiresAt }` |
|
||||||
|
| GET | `/api/auth/telegram/login-request/{id}` | — | см. ниже |
|
||||||
|
|
||||||
|
`deepLink` — `null`, если `Telegram:BotUsername` не настроен (бот не привязан к инстансу), иначе
|
||||||
|
`https://t.me/<bot>?start=link_<token>` / `?start=login_<requestId>`. **QR backend не рендерит** —
|
||||||
|
фронт строит QR из `deepLink` сам (`qrcode.react`).
|
||||||
|
|
||||||
|
`GET …/login-request/{id}` (поллинг) → варианты ответа:
|
||||||
```json
|
```json
|
||||||
// ожидание
|
// ожидание / отклонено / истекло — accessToken/expiresAt/user всегда null, кроме Approved
|
||||||
{ "status": "Pending" }
|
{ "status": "Pending", "accessToken": null, "expiresAt": null, "user": null }
|
||||||
// подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос → Consumed
|
// подтверждено — выпуск токенов (refresh уходит в httpOnly cookie), запрос помечается Consumed
|
||||||
{ "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "roles": ["User"] } }
|
{ "status": "Approved", "accessToken": "…", "expiresAt": "…", "user": { "id": "…", "userName": "…", "role": "user", "isActivated": true, "telegramLinked": true } }
|
||||||
// отклонено / истекло
|
|
||||||
{ "status": "Rejected" } // | "Expired"
|
|
||||||
```
|
```
|
||||||
|
`status` — одно из `Pending`/`Approved`/`Rejected`/`Expired`/`Consumed`.
|
||||||
|
|
||||||
> Сами апдейты Telegram (`/start`, кнопки) обрабатывает in-process бот (long polling), а не HTTP-эндпоинты.
|
> Апдейты Telegram (`/start`, кнопки, `/configs`) обрабатывает in-process бот (long polling), а не
|
||||||
> Контракты команд бота — в [telegram-bot.md](telegram-bot.md).
|
> HTTP-эндпоинты. Команды бота — в [telegram-bot.md](telegram-bot.md).
|
||||||
|
|
||||||
## Configs (пользователь)
|
## Configs (пользователь)
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
Группа `/api` (не вложена дальше), `RequireAuthorization()`.
|
||||||
| ------ | --------------------------------- | ---- | ------------------------------------------------- |
|
|
||||||
| GET | `/api/inbounds/available` | user | Инбаунды, доступные роли пользователя (для выбора при создании) |
|
|
||||||
| GET | `/api/configs` | user | Список своих конфигов (пагинация) |
|
|
||||||
| POST | `/api/configs` | user | Создать конфиг `{ inboundId, label?, deviceLimit? }` (проверки: активирован, квота роли, доступ роли к инбаунду) |
|
|
||||||
| PATCH | `/api/configs/{id}` | user | Изменить `{ label?, deviceLimit? }` (deviceLimit → `limitIp` в 3x-ui) |
|
|
||||||
| GET | `/api/configs/{id}` | user | Детали конфига (метка, трафик, устройства, статус) |
|
|
||||||
| GET | `/api/configs/{id}/link` | user | Connection string + subscriptionUrl + QR-payload |
|
|
||||||
| POST | `/api/configs/{id}/rotate` | user | Перевыпустить конфиг (новый UUID/ссылка; квоту не тратит) |
|
|
||||||
| DELETE | `/api/configs/{id}` | user | Отозвать конфиг (удаляет клиента в 3x-ui) |
|
|
||||||
| GET | `/api/subscription` | user | URL агрегированной подписки пользователя (все активные конфиги) |
|
|
||||||
|
|
||||||
`POST /api/configs` → `201 Created`:
|
| Метод | Путь | Тело запроса | Тело ответа |
|
||||||
```json
|
| ------ | --------------------------------- | ------------------------------------ | --------------------------------------- |
|
||||||
{
|
| GET | `/api/inbounds/available` | — | `AvailableInboundDto[]` |
|
||||||
"id": "…", "protocol": "Vless", "location": "DE",
|
| GET | `/api/configs` | — | `{ configs: VpnConfigDto[], maxConfigs }` — **без пагинации**, весь список сразу |
|
||||||
"link": "vless://…", "subscriptionUrl": "https://…/sub/…",
|
| POST | `/api/configs` | `{ inboundId, label?, deviceLimit? }`| `VpnConfigDto` (`200 OK`, не 201) |
|
||||||
"trafficLimitBytes": 53687091200, "expiresAt": "2026-08-01T00:00:00Z",
|
| PATCH | `/api/configs/{id}` | `{ label?, deviceLimit? }` | `VpnConfigDto` |
|
||||||
"status": "Active"
|
| POST | `/api/configs/{id}/rotate` | — | `VpnConfigDto` (новый `id` тот же, новый `SubscriptionToken`) |
|
||||||
}
|
| DELETE | `/api/configs/{id}` | — | `204 No Content` |
|
||||||
```
|
| GET | `/api/configs/{id}/link` | — | `{ connectionString, subscriptionUrl }` |
|
||||||
|
| GET | `/api/subscription` | — | `{ subscriptionUrl }` |
|
||||||
|
|
||||||
|
**Нет отдельного `GET /api/configs/{id}`** — детали конфига берутся из списка `GET /api/configs`.
|
||||||
|
`VpnConfigDto`: `{ id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes, expiresAt,
|
||||||
|
status, createdAt }`. `expiresAt` в MVP всегда `null` (лимиты по сроку не реализованы — см.
|
||||||
|
[domain-model.md](domain-model.md)). Ссылка подключения **не приходит вместе с созданием** — фронт
|
||||||
|
запрашивает `GET .../link` отдельно, по кнопке на карточке конфига; QR строится на фронте из
|
||||||
|
`connectionString`.
|
||||||
|
|
||||||
|
`POST /api/configs` без активации → `403` (`Configs.NotActivated`); сверх квоты роли → `409`
|
||||||
|
(`Configs.QuotaExceeded`).
|
||||||
|
|
||||||
## Apps — каталог приложений
|
## Apps — каталог приложений
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
| ------ | -------------------------- | ----- | ---------------------------------------------------- |
|
| ------ | -------------------------- | ----- | --------------------------------------------------------------------------------- | ------------- |
|
||||||
| GET | `/api/apps` | user | Включённые приложения, **сгруппированы по ОС** (для страницы инструкций) |
|
| GET | `/api/apps` | user | — | `Record<OsPlatform, ClientAppDto[]>` |
|
||||||
| GET | `/api/admin/apps` | admin | Все приложения (вкл. выключенные) |
|
| GET | `/api/admin/apps` | admin | — | `AdminAppDto[]` (вкл. выключенные) |
|
||||||
| POST | `/api/admin/apps` | admin | Добавить `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder? }` |
|
| POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder }` | `AdminAppDto` |
|
||||||
| PUT | `/api/admin/apps/{id}` | admin | Изменить приложение (в т.ч. `isEnabled`) |
|
| PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled }` | `AdminAppDto` |
|
||||||
| DELETE | `/api/admin/apps/{id}` | admin | Удалить приложение |
|
| DELETE | `/api/admin/apps/{id}` | admin | — | `204 No Content` |
|
||||||
|
|
||||||
`GET /api/apps` → пример:
|
`GET /api/apps` → пример (только `isEnabled == true`, ОС без приложений в ответе отсутствует):
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "iconUrl": null } ],
|
"Android": [ { "id": "…", "name": "v2rayNG", "downloadUrl": "https://…", "description": null, "iconUrl": null } ],
|
||||||
"iOS": [ { "id": "…", "name": "Hiddify", "downloadUrl": "https://…", "iconUrl": null } ]
|
"IOS": [ { "id": "…", "name": "Hiddify", "downloadUrl": "https://…", "description": null, "iconUrl": null } ]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
Значение `OsPlatform` в C#/JSON — `IOS` (не `iOS`).
|
||||||
|
|
||||||
## Activation (пользователь)
|
## Activation (пользователь)
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
| ----- | --------------------------- | ---- | ------------------------------------------------------ |
|
| ----- | --------------------------- | ---- | ----------------- | -------------------------------------------------------- |
|
||||||
| GET | `/api/activation/status` | user | Статус активации + текущий `Pending`-запрос (если есть)|
|
| GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
|
||||||
| POST | `/api/activation/request` | user | Запросить активацию `{ comment? }` (напр. «я Никита») |
|
| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
|
||||||
|
|
||||||
`GET /api/configs` для неактивированного пользователя вернёт пустой список; `POST /api/configs`
|
|
||||||
до активации → `403` (или `409` с кодом `NotActivated`).
|
|
||||||
|
|
||||||
## Admin — Activation, Roles
|
## Admin — Activation, Roles
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
| ------ | ----------------------------------------------- | ----- | --------------------------------------------------- |
|
| ------ | ----------------------------------------------- | ----- | ----------------------- | ------------- |
|
||||||
| GET | `/api/admin/activation-requests` | admin | Список запросов активации (фильтр по статусу) |
|
| GET | `/api/admin/activation-requests` | admin | query: `statusFilter?, page=1, pageSize=20` | `PagedList<ActivationRequestAdminDto>` |
|
||||||
| POST | `/api/admin/activation-requests/{id}/approve` | admin | Одобрить → пользователь активирован |
|
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/activation-requests/{id}/reject` | admin | Отклонить `{ reason? }` |
|
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
||||||
| GET | `/api/admin/roles` | admin | Список ролей с квотами |
|
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
||||||
| POST | `/api/admin/roles` | admin | Создать роль `{ name, maxConfigs }` |
|
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs }` | `RoleDto` |
|
||||||
| PUT | `/api/admin/roles/{id}` | admin | Изменить роль (напр. `maxConfigs`) |
|
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs }` | `RoleDto` |
|
||||||
| DELETE | `/api/admin/roles/{id}` | admin | Удалить роль (нельзя системные `admin`/`user`) |
|
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
||||||
| PATCH | `/api/admin/users/{id}/role` | admin | Сменить роль пользователю `{ roleId }` (ровно одна) |
|
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` |
|
||||||
| PATCH | `/api/admin/users/{id}/activation` | admin | Активировать/деактивировать напрямую `{ isActivated }`|
|
|
||||||
|
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
|
||||||
|
approve/reject над `ActivationRequest`.
|
||||||
|
|
||||||
## Admin — Nodes
|
## Admin — Nodes
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
| ------ | ----------------------------- | ----- | ----------------------------------------- |
|
| ------ | ----------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
|
||||||
| GET | `/api/admin/nodes` | admin | Список нод + статусы |
|
| GET | `/api/admin/nodes` | admin | — | `NodeDto[]` |
|
||||||
| POST | `/api/admin/nodes` | admin | Подключить ноду `{ name, baseAddress, username, password, location }` |
|
| POST | `/api/admin/nodes` | admin | `{ name, baseAddress, username, password, location? }` | `NodeDto` |
|
||||||
| PUT | `/api/admin/nodes/{id}` | admin | Изменить ноду (в т.ч. `isEnabled`) |
|
| PUT | `/api/admin/nodes/{id}` | admin | `{ name, location?, isEnabled, username?, password? }` | `NodeDto` |
|
||||||
| DELETE | `/api/admin/nodes/{id}` | admin | Удалить ноду |
|
| DELETE | `/api/admin/nodes/{id}` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/nodes/{id}/sync` | admin | Пересинхронизировать inbounds с 3x-ui |
|
| POST | `/api/admin/nodes/{id}/sync` | admin | — | `{ inboundsSynced, status }` |
|
||||||
| POST | `/api/admin/nodes/{id}/probe` | admin | Проверить доступность |
|
| POST | `/api/admin/nodes/{id}/probe` | admin | — | `{ isReachable, errorMessage, status }` |
|
||||||
|
|
||||||
|
`DELETE /api/admin/nodes/{id}` удаляет её инбаунды каскадно **без проверки существующих конфигов**
|
||||||
|
на них — известный пробел (см. [tech-stack.md](tech-stack.md)), а не осознанная защита.
|
||||||
|
`username`/`password` в `PUT` — оба опциональны; креденшлы меняются, только если заданы **оба**.
|
||||||
|
|
||||||
## Admin — Inbounds
|
## Admin — Inbounds
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
| ----- | -------------------------------------- | ----- | ---------------------------------------- |
|
| ----- | -------------------------------------- | ----- | ---------------------------------------------------------------------------- | ------------- |
|
||||||
| GET | `/api/admin/inbounds` | admin | Список inbounds (по нодам) + `allowedRoleIds`, `displayName` |
|
| GET | `/api/admin/inbounds` | admin | query: `nodeId?` | `InboundDto[]` |
|
||||||
| PUT | `/api/admin/inbounds/{id}/publish` | admin | Опубликовать/снять `{ isPublished, displayName?, allowedRoleIds[], maxClients? }` |
|
| PUT | `/api/admin/inbounds/{id}/publish` | admin | `{ isPublished, displayName?, allowedRoleIds?, maxClients? }` | `InboundDto` |
|
||||||
|
|
||||||
## Admin — Users & Stats
|
## Admin — Users & Stats
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
| ----- | --------------------------------- | ----- | ----------------------------------------- |
|
| ------ | ---------------------------------------- | ----- | --------------------------- | ------------- |
|
||||||
| GET | `/api/admin/users` | admin | Пользователи (пагинация, поиск) |
|
| GET | `/api/admin/users` | admin | query: `page, pageSize, search?` | `PagedList<UserSummaryDto>` |
|
||||||
| PATCH | `/api/admin/users/{id}/block` | admin | Блокировать/разблокировать `{ isBlocked }` (при блоке — отключить конфиги в 3x-ui) |
|
| PATCH | `/api/admin/users/{id}/block` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/users/{id}/reset-password` | admin | Сбросить пароль пользователю без привязки Telegram (выдать временный/задать новый) |
|
| PATCH | `/api/admin/users/{id}/unblock` | admin | — | `204 No Content` |
|
||||||
| GET | `/api/admin/users/{id}/configs` | admin | Конфиги пользователя |
|
| POST | `/api/admin/users/{id}/reset-password` | admin | `{ newPassword }` | `204 No Content` |
|
||||||
| DELETE| `/api/admin/configs/{id}` | admin | Принудительно отозвать любой конфиг |
|
| GET | `/api/admin/users/{id}/configs` | admin | — | `VpnConfigDto[]` |
|
||||||
| GET | `/api/admin/stats` | admin | Сводная статистика (пользователи, конфиги, трафик) |
|
| DELETE | `/api/admin/configs/{id}` | admin | — | `204 No Content` (принудительный отзыв любого конфига) |
|
||||||
| GET | `/api/admin/audit` | admin | Журнал действий (`AuditLog`, пагинация, фильтры) |
|
| GET | `/api/admin/stats` | admin | — | `StatsDto` |
|
||||||
|
| GET | `/api/admin/audit` | admin | query: `page, pageSize` | `PagedList<AuditLogDto>` |
|
||||||
|
|
||||||
|
**Блокировка/разблокировка — два отдельных эндпоинта без тела**, не один переключатель `isBlocked`.
|
||||||
|
`StatsDto`: `{ totalUsers, activatedUsers, pendingActivationRequests, totalNodes, onlineNodes,
|
||||||
|
totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — считается на лету при запросе,
|
||||||
|
не кэшируется.
|
||||||
|
|
||||||
## Public — Subscription
|
## Public — Subscription
|
||||||
|
|
||||||
| Метод | Путь | Роль | Описание |
|
| Метод | Путь | Роль | Ответ |
|
||||||
| ----- | ----------------- | ---- | ---------------------------------------------------------------- |
|
| ----- | ----------------- | ---- | ---------------------------------------------------------------- |
|
||||||
| GET | `/sub/{token}` | — | Подписка (base64-список ссылок). Токен — либо `AppUser.SubscriptionToken` (**все активные конфиги юзера**), либо `VpnConfig.SubscriptionToken` (**один конфиг**). Без `/api`. |
|
| GET | `/sub/{token}` | — | `text/plain`, base64 от списка connection strings, `\n`-разделены |
|
||||||
|
|
||||||
Rate-limited; отключённые/отозванные конфиги в выдачу не попадают; неизвестный/погашенный токен → 404.
|
Вне `/api` (публичный эндпоинт для VPN-клиентов), под тем же rate-limit'ом, что и `/api/auth/*`.
|
||||||
Ответ отдаёт заголовок **`Subscription-Userinfo`** (`upload`/`download`/`total`/`expire`) — клиенты
|
Токен — либо `AppUser.SubscriptionToken` (**все активные конфиги юзера**), либо
|
||||||
(v2rayN/Nekoray и т.п.) показывают остаток трафика/срок. Также `profile-update-interval`.
|
`VpnConfig.SubscriptionToken` (**один конфиг**); пробуются по очереди, первый успешный — в ответе.
|
||||||
|
Неизвестный/погашенный токен → `404`. Заголовки ответа:
|
||||||
|
`Subscription-Userinfo: upload=<up>; download=<down>; total=<up+down>; expire=<unix|0>` и
|
||||||
|
`Profile-Update-Interval: 12` — их читают клиенты (v2rayN/Nekoray и т.п.), чтобы показать остаток.
|
||||||
|
|
||||||
## SignalR — Hub `/hubs/panel`
|
## SignalR — Hub `/hubs/panel`
|
||||||
|
|
||||||
Авторизация — тем же JWT (query `access_token` или заголовок). Группы: `user:{userId}`, `admins`.
|
Авторизация — тем же JWT. Группы: `user:{userId}` (личные события), `admins` (админам).
|
||||||
|
|
||||||
### Server → Client
|
### Server → Client
|
||||||
|
|
||||||
| Событие | Payload | Кому |
|
| Событие | Payload | Кому |
|
||||||
| ---------------------- | ------------------------------------------------------------- | ------------ |
|
| ---------------------- | ------------------------------------------------------------- | ------------ |
|
||||||
| `configTrafficUpdated` | `{ configId, usedUpBytes, usedDownBytes, limitBytes }` | владельцу |
|
| `configTrafficUpdated` | `{ configId, usedUpBytes, usedDownBytes }` | владельцу |
|
||||||
| `configStatusChanged` | `{ configId, status }` | владельцу |
|
| `configStatusChanged` | `{ configId, status }` | владельцу |
|
||||||
| `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` |
|
| `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` |
|
||||||
| `activationRequested` | `{ requestId, userId, username, comment, createdAt }` | `admins` |
|
| `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` |
|
||||||
| `userActivated` | `{ userId }` | владельцу |
|
| `userActivated` | `{ userId }` | владельцу |
|
||||||
|
|
||||||
### Client → Server
|
### Client → Server
|
||||||
MVP — клиент только слушает (группировка по пользователю на сервере при подключении по `UserId` из JWT).
|
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
|
||||||
|
`UserId` из JWT (плюс `admins`, если роль админская).
|
||||||
|
|
||||||
## Коды ошибок
|
## Коды ошибок
|
||||||
|
|
||||||
| Код | Когда |
|
| Код | Когда |
|
||||||
| --- | -------------------------------------------------- |
|
| --- | -------------------------------------------------------------------- |
|
||||||
| 400 | Ошибка валидации (`errors` в ProblemDetails) |
|
| 400 | Ошибка валидации (FluentValidation, не на все команды — см. [backend-conventions.md](backend-conventions.md)) |
|
||||||
| 401 | Нет/просрочен токен |
|
| 401 | Нет/просрочен/невалиден access-токен |
|
||||||
| 403 | Нет прав (роль/владение/не активирован/роль без доступа к инбаунду) |
|
| 403 | Нет прав по роли, либо `Configs.NotActivated` |
|
||||||
| 404 | Ресурс не найден |
|
| 404 | Ресурс не найден |
|
||||||
| 409 | Конфликт домена (превышена квота роли, дубликат, уже есть Pending-запрос активации) |
|
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации |
|
||||||
| 422 | Нарушение инварианта домена |
|
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
|
||||||
| 429 | Rate limit |
|
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
|
||||||
| 502 | Ошибка/недоступность ноды 3x-ui (при необходимости)|
|
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
|
||||||
|
|
||||||
|
`502`/недоступность 3x-ui наружу не пробрасывается — ошибка гейтвея становится `Result.Failure` и
|
||||||
|
маппится в один из кодов выше (обычно 422), либо конфиг остаётся в старом статусе, если это фоновая синхронизация.
|
||||||
|
|||||||
+147
-80
@@ -25,10 +25,11 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
│ implements ports │ uses
|
│ implements ports │ uses
|
||||||
┌───────────────▼───────────────┐ ┌────────────▼──────────────────────────┐
|
┌───────────────▼───────────────┐ ┌────────────▼──────────────────────────┐
|
||||||
│ PnvPanel.Infrastructure │ │ PnvPanel.Domain │
|
│ PnvPanel.Infrastructure │ │ PnvPanel.Domain │
|
||||||
│ EF Core (Npgsql) · Identity · │ │ Entities · Value Objects · Domain │
|
│ EF Core (Npgsql) · Identity · │ │ Entities · Value Object · Enums · │
|
||||||
│ JWT · XuiPanelGateway · │◄──┤ Events · Enums · Domain Exceptions │
|
│ JWT · XuiPanelGateway · │◄──┤ Domain Exceptions │
|
||||||
│ Background sync · SignalR push│ │ (no external dependencies) │
|
│ Background sync · Telegram │ │ (no external dependencies) │
|
||||||
└───────────────┬────────────────┘ └───────────────────────────────────────┘
|
└───────────────┬────────────────┘ └───────────────────────────────────────┘
|
||||||
|
(SignalR-хаб/пуш — в PnvPanel.Api, см. ниже)
|
||||||
│
|
│
|
||||||
┌───────────▼──────────┐ ┌──────────────────────────┐
|
┌───────────▼──────────┐ ┌──────────────────────────┐
|
||||||
│ PostgreSQL │ │ 3x-ui panels (nodes) │
|
│ PostgreSQL │ │ 3x-ui panels (nodes) │
|
||||||
@@ -43,51 +44,83 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
`Application`, реализуемые в `Infrastructure`.
|
`Application`, реализуемые в `Infrastructure`.
|
||||||
|
|
||||||
### 1. `PnvPanel.Domain`
|
### 1. `PnvPanel.Domain`
|
||||||
Ядро без внешних зависимостей (маркерный интерфейс доменных событий `IDomainEvent` — свой, в `Domain/Common`).
|
Ядро без внешних зависимостей. Никакого диспетчера доменных событий нет — это сознательное упрощение
|
||||||
|
относительно исходного плана, см. ниже.
|
||||||
|
|
||||||
- **Entities**: `Node`, `Inbound`, `VpnConfig`, `Plan` (см. [domain-model.md](domain-model.md)).
|
- **Entities**: `Node`, `Inbound`, `VpnConfig`, `ActivationRequest`, `ClientApp`, `AuditLog`,
|
||||||
- **Value Objects**: `TrafficLimit`, `NodeCredentials`, `ConnectionLink` и т.п.
|
`TelegramLinkToken`, `TelegramLoginRequest`, `TrafficSample` (см. [domain-model.md](domain-model.md)).
|
||||||
- **Enums**: `VpnProtocol`, `ConfigStatus`, `NodeStatus`.
|
- **Value Objects**: `NodeCredentials` (логин + зашифрованный пароль ноды). Это единственный VO —
|
||||||
- **Domain Events**: `VpnConfigCreated`, `VpnConfigRevoked`, `TrafficLimitReached`, `NodeWentOffline`.
|
`TrafficLimit`/`ConnectionLink` из раннего плана не понадобились (лимиты трафика — backlog,
|
||||||
- **Domain Exceptions**: `DomainException` и специализированные (`ConfigQuotaExceededException`).
|
connection string строит `IXuiPanelGateway` на лету).
|
||||||
- Инварианты и бизнес-правила инкапсулированы в сущностях (rich domain model), а не в хендлерах.
|
- **Enums**: `VpnProtocol`, `ConfigStatus`, `NodeStatus`, `ActivationStatus`, `AuditSource`,
|
||||||
|
`TelegramLoginStatus`, `OsPlatform`.
|
||||||
|
- **Domain Exceptions**: `DomainException` — брошенный при нарушении инварианта в самой сущности
|
||||||
|
(например, `Revoke()` уже отозванного конфига); хендлеры такие нарушения не ожидают в норме.
|
||||||
|
- Инварианты и бизнес-правила инкапсулированы в сущностях (rich domain model: приватные сеттеры,
|
||||||
|
фабричные методы, поведенческие методы), а не в хендлерах.
|
||||||
|
|
||||||
> `AppUser` (Identity) живёт в `Infrastructure` (зависит от `IdentityUser`), а домен ссылается
|
> `AppUser`/`AppRole` (Identity) живут в `Infrastructure` (наследуют `IdentityUser<Guid>`/
|
||||||
> на пользователя по `UserId` (Guid), чтобы не тащить Identity в ядро.
|
> `IdentityRole<Guid>`), а домен ссылается на пользователя/роль только по `Guid`, чтобы не тащить
|
||||||
|
> Identity в ядро.
|
||||||
|
|
||||||
### 2. `PnvPanel.Application`
|
### 2. `PnvPanel.Application`
|
||||||
Сценарии приложения через CQRS.
|
Сценарии приложения через CQRS.
|
||||||
|
|
||||||
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы).
|
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы),
|
||||||
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IJwtTokenService`,
|
организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
|
||||||
`ISecretProtector`, `IRealtimeNotifier`, `IDateTime`.
|
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
|
||||||
- **Validators**: FluentValidation на каждую команду/запрос.
|
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`.
|
||||||
- **DTOs** и профили маппинга (Mapster).
|
- **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
|
||||||
- **Pipeline behaviors**: `ValidationBehavior`, `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция на команду), `AuthorizationBehavior`.
|
[backend-conventions.md](backend-conventions.md)).
|
||||||
- **Result<T>**: явная модель успеха/ошибки вместо исключений для управляемых сценариев.
|
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
|
||||||
|
DTO. Mapster из исходного плана не пригодился — при таком числе полей ручной маппинг читается
|
||||||
|
не хуже конфига маппера и не создаёт лишней зависимости.
|
||||||
|
- **Pipeline behaviors**: `ValidationBehavior`, `LoggingBehavior`, `UnitOfWorkBehavior` (транзакция +
|
||||||
|
`SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` нет — авторизация (роль,
|
||||||
|
активация) — это либо `RequireAuthorization()`/`RequireRole(...)` на эндпоинте, либо явная проверка
|
||||||
|
в начале хендлера (например, «инбаунд доступен роли пользователя»).
|
||||||
|
- **Result<T>**: явная модель успеха/ошибки (`Result`/`Result<T>`, `Error` с `ErrorType`) вместо
|
||||||
|
исключений для управляемых сценариев.
|
||||||
|
|
||||||
### 3. `PnvPanel.Infrastructure`
|
### 3. `PnvPanel.Infrastructure`
|
||||||
Технические детали и реализации портов.
|
Технические детали и реализации портов.
|
||||||
|
|
||||||
- **Persistence**: `AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>`, реализует `IAppDbContext`;
|
- **Persistence**: `AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>`, реализует `IAppDbContext`;
|
||||||
`IEntityTypeConfiguration<T>` для маппингов; миграции EF Core; репозитории только там, где нужны
|
`IEntityTypeConfiguration<T>` для маппингов; миграции EF Core. Репозиториев нет — хендлеры работают
|
||||||
(в основном хендлеры работают через `IAppDbContext` напрямую).
|
через `IAppDbContext` напрямую (`DbSet<T>` + LINQ).
|
||||||
- **Identity & Auth**: ASP.NET Core Identity, `JwtTokenService` (access + refresh), хранение refresh-токенов.
|
- **Identity & Auth**: ASP.NET Core Identity, `JwtTokenService` (access + refresh), `RefreshTokenService`
|
||||||
- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; фабрика клиентов per-node (см. ниже).
|
(хранение/ротация/отзыв refresh-токенов), `RoleService`, `DbInitializer` (сидинг).
|
||||||
- **Realtime**: `SignalRRealtimeNotifier : IRealtimeNotifier` (пуш в хабы).
|
- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; кэш клиентов per-node
|
||||||
- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService` (`BackgroundService` + `PeriodicTimer`).
|
внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет).
|
||||||
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest).
|
- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService`, `TrafficRetentionService`
|
||||||
|
(`BackgroundService` + `PeriodicTimer`).
|
||||||
|
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
|
||||||
|
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
|
||||||
|
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
|
||||||
|
|
||||||
|
> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.**
|
||||||
|
> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext<PanelHub>`, а сам `PanelHub`
|
||||||
|
> определён там же — не было смысла тащить эту связку через слой. `Application` всё равно видит
|
||||||
|
> только порт `IRealtimeNotifier`, так что граница зависимостей не нарушена.
|
||||||
|
|
||||||
### 4. `PnvPanel.Api` (Presentation)
|
### 4. `PnvPanel.Api` (Presentation)
|
||||||
Композиционный корень и транспорт.
|
Композиционный корень и транспорт.
|
||||||
|
|
||||||
- **Minimal API** эндпоинты, сгруппированные по фичам (`MapAuthEndpoints`, `MapConfigEndpoints`, `MapAdminEndpoints`).
|
- **Minimal API**-эндпоинты, сгруппированные по фичам — 12 файлов в `Endpoints/`
|
||||||
- **SignalR Hubs**: `PanelHub`.
|
(`AuthEndpoints`, `ActivationEndpoints`, `ConfigEndpoints`, `AppEndpoints`, `SubscriptionEndpoints`,
|
||||||
- **Telegram-бот**: `TelegramBotHostedService` + хендлеры апдейтов в `Telegram/` (см. отдельный раздел).
|
`TelegramEndpoints`, `AdminUserEndpoints`, `AdminAppEndpoints`, `AdminStatsEndpoints`, `NodeEndpoints`,
|
||||||
|
`InboundEndpoints`, `RoleEndpoints`); полный список маршрутов — [api-design.md](api-design.md).
|
||||||
|
Каждый эндпоинт аннотирован `.Produces<T>()`, чтобы OpenAPI-схема полностью описывала тело ответа
|
||||||
|
(нужно для `pnpm gen:api` на фронте).
|
||||||
|
- **SignalR Hubs**: `PanelHub` (`Hubs/`).
|
||||||
|
- **Telegram-бот**: `TelegramBotHostedService` + `PnvBotUpdateHandler` в `Telegram/` (см. отдельный раздел).
|
||||||
- **Статика SPA**: раздача собранного фронта из `wwwroot` + SPA-fallback (единый контейнер).
|
- **Статика SPA**: раздача собранного фронта из `wwwroot` + SPA-fallback (единый контейнер).
|
||||||
- **Middleware**: обработка исключений → ProblemDetails, корреляция запросов, rate limiting.
|
- **Ошибки**: встроенные `AddProblemDetails()` + `UseExceptionHandler()` (ASP.NET Core, без кастомного
|
||||||
- **DI**: `AddApplication()`, `AddInfrastructure()`, `AddApiServices()` — сборка всех слоёв.
|
middleware) конвертируют необработанные исключения в `application/problem+json`.
|
||||||
- **OpenAPI**: Swashbuckle + Scalar UI; генерация схемы для codegen фронта.
|
- **DI**: `AddApplication()` (Application), `AddInfrastructure()` (Infrastructure) + прямая регистрация
|
||||||
|
в `Program.cs` для того, что специфично Api-слою (SignalR, Telegram-клиент, rate limiting).
|
||||||
|
- **OpenAPI**: нативный `Microsoft.AspNetCore.OpenApi` (`/openapi/v1.json`) + `Scalar.AspNetCore`
|
||||||
|
UI (`/scalar`) — без Swashbuckle.
|
||||||
|
|
||||||
## CQRS
|
## CQRS
|
||||||
|
|
||||||
@@ -97,36 +130,44 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
через pipeline behaviors. Без внешних CQRS-библиотек (MediatR/и т.п.). Абстракции — `ICommand<T>`,
|
через pipeline behaviors. Без внешних CQRS-библиотек (MediatR/и т.п.). Абстракции — `ICommand<T>`,
|
||||||
`IQuery<T>`, `ICommandHandler<,>`, `IQueryHandler<,>`, `IPipelineBehavior<,>`.
|
`IQuery<T>`, `ICommandHandler<,>`, `IQueryHandler<,>`, `IPipelineBehavior<,>`.
|
||||||
|
|
||||||
Пример потока «создать конфиг»:
|
Пример потока «создать конфиг» (`backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs`):
|
||||||
```
|
```
|
||||||
POST /api/configs
|
POST /api/configs
|
||||||
→ CreateVpnConfigCommand
|
→ CreateVpnConfigCommand
|
||||||
→ ValidationBehavior (FluentValidation)
|
→ ValidationBehavior (FluentValidation — формат inboundId/label/deviceLimit)
|
||||||
→ AuthorizationBehavior (роль/владение)
|
→ CreateVpnConfigCommandHandler
|
||||||
→ CreateVpnConfigHandler
|
· проверяет активацию + роль инбаунда (доменные проверки)
|
||||||
· проверяет квоту пользователя (домен)
|
· SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
|
||||||
· IXuiPanelGateway.AddClientAsync(node, inbound, spec) // 3x-ui
|
· пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs
|
||||||
· создаёт VpnConfig, сохраняет через IAppDbContext
|
· IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId
|
||||||
· публикует VpnConfigCreated (domain event)
|
· VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext
|
||||||
→ UnitOfWorkBehavior (commit)
|
· при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync)
|
||||||
→ 201 Created { id, link, subscriptionUrl }
|
→ UnitOfWorkBehavior (commit транзакции)
|
||||||
|
→ 200 OK VpnConfigDto { id, label, protocol, location, deviceLimit, usedUpBytes, usedDownBytes,
|
||||||
|
expiresAt, status, createdAt }
|
||||||
```
|
```
|
||||||
|
Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно,
|
||||||
|
`GET /api/configs/{id}/link`, по кнопке на карточке конфига (см. [api-design.md](api-design.md)).
|
||||||
|
|
||||||
## Интеграция с 3x-ui (ThreeXui.Net)
|
## Интеграция с 3x-ui (ThreeXui.Net)
|
||||||
|
|
||||||
`ThreeXui.Net` конфигурируется на **один** `BaseAddress`, а у нас **несколько нод**. Поэтому:
|
`ThreeXui.Net` конфигурируется на **один** `BaseAddress`, а у нас **несколько нод**. Поэтому:
|
||||||
|
|
||||||
- Порт `IXuiPanelGateway` инкапсулирует все операции с панелями и принимает `Node` (или его id):
|
- Порт `IXuiPanelGateway` инкапсулирует все операции с панелями и принимает `Node`:
|
||||||
`ListInboundsAsync`, `AddClientAsync`, `UpdateClientAsync`, `RemoveClientAsync`,
|
`ListInboundsAsync`, `AddClientAsync`, `UpdateClientAsync`, `RemoveClientAsync`,
|
||||||
`GetClientTrafficAsync`, `BuildConnectionStringAsync`, `ProbeAsync`.
|
`GetClientTrafficAsync`, `BuildConnectionStringAsync`, `ProbeAsync`, `ValidateBaseAddress`,
|
||||||
- `XuiPanelGateway` держит **фабрику/кэш `IXuiClient` per-node** (ключ — `NodeId`), создавая клиента
|
`InvalidateClient(nodeId)` (вызывается после смены креденшлов ноды).
|
||||||
из расшифрованных `NodeCredentials` через `XuiHttpClientFactory`/`HttpClient`. Cookie-session и
|
- `XuiPanelGateway` — единственная реализация, держит `ConcurrentDictionary<Guid, Lazy<IXuiClient>>`
|
||||||
авто-переавторизация на 401 обеспечиваются самой библиотекой.
|
(ключ — `NodeId`), создавая клиента из расшифрованных `NodeCredentials` лениво при первом обращении
|
||||||
|
к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`.
|
||||||
- Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу.
|
- Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу.
|
||||||
- Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды).
|
- Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды).
|
||||||
- **Реконсиляция дрейфа**: 3x-ui — источник правды по клиентам. При синхронизации сверяем проекцию
|
- **Дрейф с 3x-ui в MVP не реконсилируется активно**: `TrafficSyncService` при недоступной ноде или
|
||||||
с панелью: клиент удалён/изменён напрямую в 3x-ui → помечаем конфиг рассинхронизованным
|
при отсутствии клиента в ответе панели (`GetClientTrafficAsync`) просто пропускает его в этом цикле
|
||||||
(`Disabled`/флаг) и логируем; не «воскрешаем» молча. Наши записи о трафике/статусах обновляем из панели.
|
синхронизации — не помечает конфиг рассинхронизованным и не шлёт алерт. Если клиента удалили прямо
|
||||||
|
в 3x-ui в обход панели, локальная запись `VpnConfig` продолжит существовать до следующего
|
||||||
|
явного действия пользователя/админа (`Revoke`/`Rotate`), которое обнаружит несоответствие по ответу
|
||||||
|
гейтвея. Активная сверка/алертинг по дрейфу — задел на будущее, не реализовано.
|
||||||
|
|
||||||
## Telegram-бот (presentation-адаптер)
|
## Telegram-бот (presentation-адаптер)
|
||||||
|
|
||||||
@@ -134,7 +175,9 @@ POST /api/configs
|
|||||||
бизнес-правил). Полное описание — в [telegram-bot.md](telegram-bot.md). Ключевое для архитектуры:
|
бизнес-правил). Полное описание — в [telegram-bot.md](telegram-bot.md). Ключевое для архитектуры:
|
||||||
|
|
||||||
- Хостится **в процессе Api** как `BackgroundService` (`TelegramBotHostedService`) — это условие
|
- Хостится **в процессе Api** как `BackgroundService` (`TelegramBotHostedService`) — это условие
|
||||||
для упаковки «фронт+бек в одном контейнере». Транспорт — **long polling** (MVP), webhook — опция.
|
для упаковки «фронт+бек в одном контейнере». Транспорт — только **long polling**
|
||||||
|
(`ITelegramBotClient.ReceiveAsync`); webhook рассматривался, но не реализован — конфигурации
|
||||||
|
`Telegram:Mode`/`WebhookUrl` в коде нет.
|
||||||
- Обращения к домену — только через собственный `ISender`, теми же командами/запросами, что и веб
|
- Обращения к домену — только через собственный `ISender`, теми же командами/запросами, что и веб
|
||||||
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). `Telegram.Bot`
|
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). `Telegram.Bot`
|
||||||
не проникает в Application/Domain.
|
не проникает в Application/Domain.
|
||||||
@@ -144,21 +187,25 @@ POST /api/configs
|
|||||||
## Realtime (SignalR)
|
## Realtime (SignalR)
|
||||||
|
|
||||||
- Хаб `PanelHub` (`/hubs/panel`), авторизация по тому же JWT.
|
- Хаб `PanelHub` (`/hubs/panel`), авторизация по тому же JWT.
|
||||||
- **Группы**: `user:{userId}` (личные события), `admins` (события нод/системы).
|
- **Группы** (`GroupNames` в `Api/Hubs/PanelHub.cs`): `user:{userId}` (личные события), `admins`
|
||||||
- **События сервер→клиент** (см. [api-design.md](api-design.md)): `configTrafficUpdated`,
|
(события нод/системы/активации) — пользователь при подключении добавляется в свою `user:{userId}`
|
||||||
`configStatusChanged`, `nodeStatusChanged`.
|
и, если он админ, дополнительно в `admins`.
|
||||||
- Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`), вызываемый из хендлеров и
|
- **События сервер→клиент**: `configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`,
|
||||||
фоновых сервисов — Application-слой не зависит от SignalR напрямую.
|
`activationRequested`, `userActivated` — точные payload'ы см. [api-design.md](api-design.md#signalr--hub-hubspanel).
|
||||||
|
- Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`, реализация в `Api/Hubs/`),
|
||||||
|
вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую.
|
||||||
|
|
||||||
## Фоновые задачи
|
## Фоновые задачи
|
||||||
|
|
||||||
- **TrafficSyncService** — периодически (`PeriodicTimer`) обходит активные ноды, тянет трафик по
|
- **TrafficSyncService** — периодически (`PeriodicTimer`) обходит активные ноды, тянет трафик по
|
||||||
клиентам, обновляет `VpnConfig`, пишет `TrafficSample` (для графиков), шлёт realtime-события,
|
клиентам через `IXuiPanelGateway.GetClientTrafficAsync`, пишет `VpnConfig.UpdateTraffic(...)` и
|
||||||
помечает превышения (`TrafficLimitReached`).
|
`TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты
|
||||||
- **NodeHealthCheckService** — health-probe нод, обновляет `NodeStatus`, оповещает `admins`.
|
и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)).
|
||||||
|
- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`,
|
||||||
|
шлёт `nodeStatusChanged` группе `admins`.
|
||||||
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
|
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
|
||||||
- Для MVP — встроенный `BackgroundService`; при росте нагрузки — вынести в Hangfire/Quartz
|
- Реализованы как обычные `BackgroundService` + `PeriodicTimer`, без внешнего джоб-раннера — для
|
||||||
(см. [tech-stack.md](tech-stack.md)).
|
нагрузки MVP этого достаточно (см. [tech-stack.md](tech-stack.md)).
|
||||||
|
|
||||||
## Сидирование и старт
|
## Сидирование и старт
|
||||||
|
|
||||||
@@ -167,15 +214,19 @@ POST /api/configs
|
|||||||
|
|
||||||
- **Системные роли**: `admin` (без лимита конфигов) и `user` (`MaxConfigs = Roles__DefaultUserMaxConfigs`, по умолчанию 3).
|
- **Системные роли**: `admin` (без лимита конфигов) и `user` (`MaxConfigs = Roles__DefaultUserMaxConfigs`, по умолчанию 3).
|
||||||
- **Учётка администратора**: создаётся из `AdminSeed__Username` / `AdminSeed__Password`, если ещё нет;
|
- **Учётка администратора**: создаётся из `AdminSeed__Username` / `AdminSeed__Password`, если ещё нет;
|
||||||
сразу активирована и с ролью `admin`.
|
сразу активирована и с ролью `admin`. Seed-админ **не привязывается к Telegram автоматически** —
|
||||||
- **Telegram id админов** (`AdminSeed__TelegramUserIds`) — авторизуют админ-действия в боте и
|
привязка делается вручную в UI, как у любого пользователя.
|
||||||
адресуют уведомления (например, запросы на активацию).
|
|
||||||
- **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из
|
- **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из
|
||||||
[`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD.
|
[`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD.
|
||||||
|
|
||||||
Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе
|
Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе
|
||||||
**нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении.
|
**нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении.
|
||||||
|
|
||||||
|
Отдельно от сидинга — **Telegram id админов** (`Telegram__AdminTelegramUserIds`, через запятую)
|
||||||
|
читаются `TelegramOptions` **напрямую при каждой проверке** (не пишутся в БД): именно этот список
|
||||||
|
авторизует нажатие «Активировать/Отклонить» в боте и определяет, кому слать уведомления о новых
|
||||||
|
запросах активации.
|
||||||
|
|
||||||
## RBAC — динамические роли и активация
|
## RBAC — динамические роли и активация
|
||||||
|
|
||||||
- `AppRole` расширяет `IdentityRole<Guid>` полем `MaxConfigs`. **У пользователя ровно одна роль**;
|
- `AppRole` расширяет `IdentityRole<Guid>` полем `MaxConfigs`. **У пользователя ровно одна роль**;
|
||||||
@@ -189,34 +240,50 @@ POST /api/configs
|
|||||||
сохраняются, создание новых блокируется до входа в квоту.
|
сохраняются, создание новых блокируется до входа в квоту.
|
||||||
- **Блокировка пользователя**: `IsBlocked = true` → вход запрещён + все конфиги `Disabled` (отключение
|
- **Блокировка пользователя**: `IsBlocked = true` → вход запрещён + все конфиги `Disabled` (отключение
|
||||||
клиентов в 3x-ui); разблокировка — обратная операция. Пишется в `AuditLog`.
|
клиентов в 3x-ui); разблокировка — обратная операция. Пишется в `AuditLog`.
|
||||||
- Уведомления: запросы активации → группа `admins` (SignalR) + Telegram (по `AdminSeed__TelegramUserIds`);
|
- Уведомления: запросы активации → группа `admins` (SignalR) + Telegram (по
|
||||||
решения/блокировки → пользователю (SignalR + Telegram-DM, если привязан).
|
`Telegram__AdminTelegramUserIds`); решения/блокировки → пользователю (SignalR + Telegram-DM, если привязан).
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- **AuthN**: ASP.NET Core Identity + JWT, **вход по `UserName`** (email в системе не используется).
|
- **AuthN**: ASP.NET Core Identity + JWT, **вход по `UserName`** (email в системе не используется).
|
||||||
Access-token — короткий TTL (in-memory на клиенте); refresh-token — httpOnly Secure cookie,
|
Access-token — короткий TTL (in-memory на клиенте); refresh-token — httpOnly Secure cookie,
|
||||||
ротация при использовании, хранение хэша в БД.
|
ротация при использовании, хранение хэша в БД.
|
||||||
- **Восстановление пароля**: через привязанный Telegram (passwordless-вход → смена пароля, либо
|
- **Восстановление пароля**: через привязанный Telegram (passwordless-вход → смена пароля); без
|
||||||
reset-флоу в боте); без привязки — сброс админом (`ResetUserPasswordCommand`). Email/SMTP не используются.
|
привязки — сброс админом (`ResetUserPasswordCommand`). Email/SMTP не используются. Смена пароля
|
||||||
Смена пароля вошедшим — `POST /api/auth/change-password`.
|
вошедшим — `POST /api/auth/change-password`.
|
||||||
- **AuthZ**: роли (`admin`/`user`/кастомные) + policy-based (`OwnsConfig`, `RequireAdmin`,
|
- **AuthZ**: именованных policy нет — либо `.RequireAuthorization()` (любой вошедший) или
|
||||||
`RequireActivated`).
|
`.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))` на группе эндпоинтов, либо
|
||||||
|
явная проверка внутри хендлера (владение конфигом — сравнение `VpnConfig.UserId` с `ICurrentUser`;
|
||||||
|
активация — `ConfigErrors.NotActivated`).
|
||||||
- **Секреты нод**: шифруются `ISecretProtector` (Data Protection) перед сохранением; в API/логи не попадают.
|
- **Секреты нод**: шифруются `ISecretProtector` (Data Protection) перед сохранением; в API/логи не попадают.
|
||||||
- **CSRF**: refresh-cookie — `SameSite=Strict/Lax`, `Secure`, `HttpOnly`; для cookie-based refresh —
|
- **CSRF**: явного анти-CSRF токена нет — все мутации API читают авторизацию только из
|
||||||
анти-CSRF токен. Мутации — только по Bearer access-токену, не по cookie.
|
`Authorization: Bearer` (JS должен явно прочитать access-token из памяти и подставить заголовок,
|
||||||
- **Brute-force**: Identity lockout по числу неудачных входов; rate-limit на `/auth/*`.
|
чужой сайт этого сделать не может). Refresh-cookie (`pnv_refresh_token`) — единственное, что браузер
|
||||||
- **Rate limiting**: на `/auth/*`, создание/ротацию конфигов, запросы активации и Telegram (встроенный `RateLimiter` .NET).
|
шлёт автоматически; она `HttpOnly`, `SameSite=Strict`, `Path=/api/auth`, и `Secure` выставляется по
|
||||||
|
`HttpContext.Request.IsHttps` (учитывает `ForwardedHeaders` за прокси) — этого достаточно, т.к. сама
|
||||||
|
по себе она не даёт мутировать данные, только обменивается на access-token эндпоинтом `/api/auth/refresh`.
|
||||||
|
- **Brute-force**: Identity lockout по числу неудачных входов; rate-limit на `/auth/*` (настраиваемый
|
||||||
|
лимит, `RateLimiting:AuthPermitLimit`, по умолчанию 20 запросов/мин).
|
||||||
|
- **Rate limiting**: встроенный `RateLimiter` .NET, один fixed-window лимит (`RateLimiting:AuthPermitLimit`,
|
||||||
|
по умолчанию 20/мин) применён к `/api/auth/*`, `/api/auth/telegram/*` и `/sub/{token}`; остальные
|
||||||
|
эндпоинты (в т.ч. создание конфигов) им не покрыты.
|
||||||
- **Валидация входа**: FluentValidation + жёсткая типизация DTO; ошибки — единый `ProblemDetails`.
|
- **Валидация входа**: FluentValidation + жёсткая типизация DTO; ошибки — единый `ProblemDetails`.
|
||||||
- **CORS**: в проде фронт и бек — один origin (CORS не нужен); в dev — строгий allowlist (`App__CorsOrigins`).
|
- **CORS**: не настроен вообще (`AddCors`/`UseCors` в коде нет) — фронт и бек всегда один origin: в
|
||||||
|
проде раздаются из одного образа, в dev Vite проксирует `/api`/`/hubs`, так что браузер никогда не
|
||||||
|
делает кросс-origin запрос. Отдельного allowlist-конфига для CORS сейчас не существует.
|
||||||
- **Аудит**: значимые действия (активация, блокировка, смена роли, отзыв, ноды/инбаунды) пишутся в
|
- **Аудит**: значимые действия (активация, блокировка, смена роли, отзыв, ноды/инбаунды) пишутся в
|
||||||
`AuditLog` (append-only) с источником `Web`/`Telegram`/`System`.
|
`AuditLog` (append-only) с источником `Web`/`Telegram`/`System`.
|
||||||
|
|
||||||
## Обработка ошибок
|
## Обработка ошибок
|
||||||
|
|
||||||
- Управляемые ошибки → `Result`/`Result<T>` → маппинг в HTTP-статус + `ProblemDetails`.
|
- Управляемые ошибки → `Result`/`Result<T>` (`ResultExtensions.ToHttpResult`) → маппинг `ErrorType` в
|
||||||
- Непредвиденные исключения → глобальный middleware → 500 + корреляция + структурный лог (без утечки деталей).
|
HTTP-статус + `application/problem+json` (400/401/403/404/409/422).
|
||||||
- Доменные исключения (нарушение инвариантов) → 409/422 с понятным сообщением.
|
- Непредвиденные исключения → встроенные `AddProblemDetails()` + `UseExceptionHandler()` → 500 без
|
||||||
|
утечки деталей + `Serilog` request-логирование (`UseSerilogRequestLogging`, обогащение —
|
||||||
|
`Enrich.FromLogContext()`). Сквозного `CorrelationId`/явного обогащения `UserId`/`NodeId`/`ConfigId`
|
||||||
|
в логах пока нет — задел на будущее, а не то, на что стоит полагаться при расследовании инцидентов сегодня.
|
||||||
|
- Доменные исключения (нарушение инвариантов) — `DomainException`, ожидаются только как баг, а не
|
||||||
|
штатный путь (штатные отказы — через `Result.Failure`, не исключения).
|
||||||
|
|
||||||
## Развёртывание (единый контейнер приложения)
|
## Развёртывание (единый контейнер приложения)
|
||||||
|
|
||||||
@@ -238,7 +305,7 @@ PostgreSQL:
|
|||||||
- **Миграции**: применяются **автоматически на старте** приложения (в MVP; при масштабировании на
|
- **Миграции**: применяются **автоматически на старте** приложения (в MVP; при масштабировании на
|
||||||
несколько инстансов — вынести в отдельный шаг/джобу).
|
несколько инстансов — вынести в отдельный шаг/джобу).
|
||||||
- Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения,
|
- Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения,
|
||||||
JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`, `PublicSiteUrl`).
|
JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`).
|
||||||
|
|
||||||
```
|
```
|
||||||
[ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose
|
[ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose
|
||||||
|
|||||||
+110
-58
@@ -2,52 +2,74 @@
|
|||||||
|
|
||||||
## Структура решения
|
## Структура решения
|
||||||
|
|
||||||
|
Solution-файл — **`PnvPanel.slnx`** (новый XML-формат dotnet CLI, не классический `.sln`).
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
PnvPanel.sln
|
PnvPanel.slnx
|
||||||
src/
|
src/
|
||||||
PnvPanel.Domain/
|
PnvPanel.Domain/
|
||||||
Common/ # Entity, AggregateRoot, IDomainEvent, ValueObject base
|
Common/ # Entity (единственный базовый класс — без AggregateRoot/IDomainEvent)
|
||||||
Nodes/ # Node, NodeCredentials, NodeStatus, события
|
Activation/ # ActivationRequest, ActivationStatus
|
||||||
Inbounds/ # Inbound, VpnProtocol
|
Apps/ # ClientApp, OsPlatform
|
||||||
Configs/ # VpnConfig, ConfigStatus, TrafficLimit, события
|
Audit/ # AuditLog, AuditSource
|
||||||
Plans/ # Plan
|
Configs/ # VpnConfig, ConfigStatus, TrafficSample
|
||||||
Exceptions/ # DomainException и наследники
|
Inbounds/ # Inbound, VpnProtocol
|
||||||
|
Nodes/ # Node, NodeCredentials (VO), NodeStatus
|
||||||
|
Telegram/ # TelegramLinkToken, TelegramLoginRequest, TelegramLoginStatus
|
||||||
|
Exceptions/ # DomainException
|
||||||
PnvPanel.Application/
|
PnvPanel.Application/
|
||||||
Common/
|
Common/
|
||||||
Behaviors/ # Validation, Logging, UnitOfWork, Authorization
|
Behaviors/ # ValidationBehavior, LoggingBehavior, UnitOfWorkBehavior (нет Authorization-поведения)
|
||||||
Interfaces/ # IAppDbContext, IXuiPanelGateway, ICurrentUser, IRealtimeNotifier, ...
|
Interfaces/ # IAppDbContext, IXuiPanelGateway, ICurrentUser, IIdentityService,
|
||||||
|
# ISecretProtector, IRealtimeNotifier, ITelegramNotifier, IRoleService
|
||||||
Messaging/ # ISender, ICommand<T>, IQuery<T>, ICommandHandler<,>, IQueryHandler<,>, IPipelineBehavior<,>
|
Messaging/ # ISender, ICommand<T>, IQuery<T>, ICommandHandler<,>, IQueryHandler<,>, IPipelineBehavior<,>
|
||||||
Models/ # Result<T>, Error, PagedList<T>
|
Models/ # Result, Result<T>, Error, PagedList<T>, RoleQuota
|
||||||
Mapping/ # Mapster-конфиги
|
Activation/ # RequestActivationCommand, GetActivationStatusQuery (пользовательские)
|
||||||
Auth/ # Register/Login/Refresh (Commands, Handlers, Validators, DTOs)
|
Admin/
|
||||||
Configs/ # CreateVpnConfig, EditVpnConfig, RotateVpnConfig, RevokeVpnConfig, GetMyConfigs, GetConfigLink, GetSubscription, ...
|
Activation/ # ListActivationRequestsQuery, Approve/RejectActivationCommand
|
||||||
Nodes/ # RegisterNode, SyncNode, ListNodes, ...
|
Apps/ # CRUD ClientApp
|
||||||
Inbounds/ # PublishInbound, ListInbounds, ...
|
Audit/ # ListAuditLogsQuery
|
||||||
Apps/ # (admin) CRUD каталога ClientApp; GetApps (по ОС) для юзера
|
Inbounds/ # ListInbounds, PublishInbound
|
||||||
Admin/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, GetStats, Audit, ...
|
Nodes/ # RegisterNode, UpdateNode, DeleteNode, SyncNode, ProbeNode, ListNodes
|
||||||
|
Roles/ # ListRoles, CreateRole, UpdateRole, DeleteRole
|
||||||
|
Stats/ # GetStatsQuery
|
||||||
|
Users/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, ResetUserPassword,
|
||||||
|
# ForceRevokeConfig, GetUserConfigs
|
||||||
|
Apps/ # ListAppsQuery (по ОС, для юзера)
|
||||||
|
Auth/
|
||||||
|
ChangePassword/, DeleteMyAccount/, Login/, Logout/, Me/, Refresh/, Register/
|
||||||
|
Configs/
|
||||||
|
Create/, Edit/, Rotate/, Revoke/, GetMyConfigs/, GetConfigLink/, GetMySubscription/,
|
||||||
|
ListAvailableInbounds/
|
||||||
|
Subscriptions/ # SubscriptionDto, GetUserSubscriptionQuery, GetConfigSubscriptionQuery
|
||||||
|
Telegram/ # LinkTelegramCommand, CreateLinkTokenCommand, CreateLoginRequestCommand,
|
||||||
|
# Approve/RejectTelegramLoginCommand, GetLoginRequestStatusQuery, ...
|
||||||
|
# Telegram/Bot/ — контракт для бота (не сам Telegram.Bot)
|
||||||
PnvPanel.Infrastructure/
|
PnvPanel.Infrastructure/
|
||||||
Persistence/
|
Persistence/
|
||||||
AppDbContext.cs
|
AppDbContext.cs # : IdentityDbContext<AppUser, AppRole, Guid>, IAppDbContext
|
||||||
Configurations/ # IEntityTypeConfiguration<T>
|
Configurations/ # IEntityTypeConfiguration<T>
|
||||||
Migrations/
|
Migrations/
|
||||||
Identity/ # AppUser, AppRole, JwtTokenService, RefreshToken
|
Identity/ # AppUser, AppRole, JwtTokenService, RefreshTokenService, RoleService,
|
||||||
Xui/ # XuiPanelGateway, XuiClientFactory (per-node)
|
# DbInitializer (сидинг), IdentityService, CurrentUser
|
||||||
Realtime/ # SignalRRealtimeNotifier
|
Xui/ # XuiPanelGateway (единственный файл — кэш клиентов per-node внутри него)
|
||||||
BackgroundJobs/ # TrafficSyncService, NodeHealthCheckService
|
BackgroundJobs/ # TrafficSyncService, NodeHealthCheckService, TrafficRetentionService
|
||||||
Security/ # DataProtectionSecretProtector
|
Security/ # DataProtectionSecretProtector
|
||||||
DependencyInjection.cs
|
Telegram/ # TelegramNotifier, TelegramOptions
|
||||||
|
DependencyInjection.cs # AddInfrastructure(...)
|
||||||
PnvPanel.Api/
|
PnvPanel.Api/
|
||||||
Endpoints/ # AuthEndpoints, ConfigEndpoints, NodeEndpoints, AdminEndpoints, SubscriptionEndpoints
|
Endpoints/ # 12 файлов, см. backend-conventions.md ниже и api-design.md
|
||||||
Hubs/ # PanelHub
|
Hubs/ # PanelHub, SignalRRealtimeNotifier (реализация IRealtimeNotifier — здесь,
|
||||||
Middleware/ # ExceptionHandling, RequestCorrelation
|
# не в Infrastructure, т.к. нужен IHubContext<PanelHub>)
|
||||||
Extensions/ # AddApiServices, UseApiPipeline
|
Telegram/ # TelegramBotHostedService, PnvBotUpdateHandler, TelegramNotifier
|
||||||
Program.cs
|
Common/ # RateLimiting (константы политик), ResultExtensions (Result -> IResult)
|
||||||
|
Program.cs # DI composition root, pipeline (нет отдельных Middleware/Extensions папок)
|
||||||
appsettings*.json
|
appsettings*.json
|
||||||
tests/
|
tests/
|
||||||
PnvPanel.Domain.Tests/
|
PnvPanel.Domain.Tests/ # 54 теста
|
||||||
PnvPanel.Application.Tests/
|
PnvPanel.Application.Tests/ # 71 тест
|
||||||
PnvPanel.Integration.Tests/ # Testcontainers PostgreSQL
|
PnvPanel.IntegrationTests/ # 9 тестов, Testcontainers.PostgreSql + WebApplicationFactory<Program>
|
||||||
```
|
```
|
||||||
|
|
||||||
Организация Application — **по фичам** (feature folders), внутри слоёв Clean Architecture.
|
Организация Application — **по фичам** (feature folders), внутри слоёв Clean Architecture.
|
||||||
@@ -57,27 +79,43 @@ backend/
|
|||||||
- Классы/методы/свойства — `PascalCase`; параметры/локальные — `camelCase`; приватные поля — `_camelCase`.
|
- Классы/методы/свойства — `PascalCase`; параметры/локальные — `camelCase`; приватные поля — `_camelCase`.
|
||||||
- Команды — `<Verb><Noun>Command` (`CreateVpnConfigCommand`), запросы — `<Get/List><Noun>Query`.
|
- Команды — `<Verb><Noun>Command` (`CreateVpnConfigCommand`), запросы — `<Get/List><Noun>Query`.
|
||||||
- Хендлеры — `<Command/Query>Handler`; валидаторы — `<Command/Query>Validator`.
|
- Хендлеры — `<Command/Query>Handler`; валидаторы — `<Command/Query>Validator`.
|
||||||
- DTO — суффикс `Dto` (`VpnConfigDto`); ответы эндпоинтов — `Response`, тела запросов — `Request`.
|
- **DTO** (Application-слой, возвращаются из `Result<T>`) — суффикс `Dto` (`VpnConfigDto`, `NodeDto`);
|
||||||
|
конвертация из сущности — статический `FromDomain(entity, ...)` на самом DTO.
|
||||||
|
- **Тела запросов** (Api-слой, только для JSON-полей, которых нет в готовой команде) — суффикс `Body`
|
||||||
|
(`CreateConfigBody`, `UpdateNodeBody`) либо сам record команды биндится напрямую как тело
|
||||||
|
(`RegisterCommand`, `LoginCommand`).
|
||||||
|
- **Тела ответов, которых нет как Application DTO** (например, потому что Api-слой добавляет
|
||||||
|
вычисляемое поле — абсолютный URL из токена) — суффикс `ResponseDto` (`AuthResponseDto`,
|
||||||
|
`ConfigLinkResponseDto`, `TelegramLoginStatusResponseDto`), определяются прямо в файле эндпоинта.
|
||||||
- Async-методы — суффикс `Async`, всегда принимают `CancellationToken`.
|
- Async-методы — суффикс `Async`, всегда принимают `CancellationToken`.
|
||||||
- Один публичный тип на файл; имя файла = имя типа.
|
- Один публичный тип на файл — с исключением: Api-слой держит вспомогательные `Body`/`ResponseDto`
|
||||||
|
records в том же файле, что и класс эндпоинтов, который их использует (не выносятся отдельно).
|
||||||
|
|
||||||
## Паттерны
|
## Паттерны
|
||||||
|
|
||||||
- **Rich domain model**: инварианты в сущностях (приватные сеттеры, фабричные методы `Node.Create(...)`,
|
- **Rich domain model**: инварианты в сущностях (приватные сеттеры, фабричные методы `Node.Register(...)`,
|
||||||
поведенческие методы `config.Revoke()`), а не анемичные DTO-сущности.
|
поведенческие методы `config.Revoke()`), а не анемичные DTO-сущности.
|
||||||
- **CQRS через собственный диспетчер**: хендлеры реализуют `ICommandHandler<TCommand,TResult>` /
|
- **CQRS через собственный диспетчер**: хендлеры реализуют `ICommandHandler<TCommand,TResult>` /
|
||||||
`IQueryHandler<,>`; `ISender` резолвит их из DI и прогоняет через `IPipelineBehavior<,>`
|
`IQueryHandler<,>`; `ISender` резолвит их из DI и прогоняет через `IPipelineBehavior<,>`
|
||||||
(валидация, транзакция, логирование). Без внешних CQRS-библиотек.
|
(`ValidationBehavior` → `LoggingBehavior` → `UnitOfWorkBehavior`). Без внешних CQRS-библиотек, без
|
||||||
- **Порты в Application, адаптеры в Infrastructure**: никакого `Npgsql`/`SignalR`/`ThreeXui.Net` в Application/Domain.
|
доменных событий — хендлер сам вызывает нужные порты (realtime/Telegram/аудит) синхронно.
|
||||||
- **`IAppDbContext`** экспонирует `DbSet<>` и `SaveChangesAsync`; хендлеры пишут LINQ напрямую
|
- **Порты в Application, адаптеры в Infrastructure/Api**: никакого `Npgsql`/`SignalR`/`ThreeXui.Net` в
|
||||||
(репозитории — только для сложной агрегатной логики).
|
Application/Domain — только интерфейсы, реализации могут жить и в `Infrastructure`, и в `Api`
|
||||||
- **Result-модель**: команды/запросы возвращают `Result<T>`; эндпоинт маппит в HTTP (`.Match(...)`).
|
(`IRealtimeNotifier` реализован в `Api/Hubs`, т.к. завязан на `IHubContext<PanelHub>`).
|
||||||
- **Транзакция на команду**: `UnitOfWorkBehavior` оборачивает выполнение команды в транзакцию.
|
- **`IAppDbContext`** экспонирует `DbSet<>` и `SaveChangesAsync`; хендлеры пишут LINQ напрямую —
|
||||||
- **Валидация**: `ValidationBehavior` до хендлера; хендлер не проверяет формат ввода повторно.
|
выделенных репозиториев нет вообще.
|
||||||
- **Идемпотентность**: команды к 3x-ui устойчивы к повторам; при частичном сбое — компенсация
|
- **Result-модель**: команды/запросы возвращают `Result`/`Result<T>`; `ResultExtensions.ToHttpResult()`
|
||||||
(создали клиента в панели, но упала БД → удалить клиента, вернуть ошибку).
|
мапит `Error.Type` в HTTP-статус на границе Api.
|
||||||
- **Оптимистичная блокировка**: на изменяемых сущностях (нода, конфиг) — `xmin`/rowversion, чтобы
|
- **Транзакция на команду**: `UnitOfWorkBehavior` вызывает `SaveChangesAsync` после хендлера команды
|
||||||
параллельные правки не затирали друг друга.
|
(не запросов) — отдельной BEGIN/COMMIT-транзакции вокруг этого в MVP нет, полагаемся на то, что
|
||||||
|
один `SaveChanges` уже атомарен для одной единицы работы.
|
||||||
|
- **Компенсация при частичном сбое**: если клиент успешно создан в 3x-ui, а `SaveChanges` в БД упал —
|
||||||
|
хендлер вызывает `RemoveClientAsync`, чтобы не оставить сироту в панели.
|
||||||
|
- **Защита от гонок на квоте — `pg_advisory_xact_lock`**, не оптимистичная блокировка: перед проверкой
|
||||||
|
квоты роли `CreateVpnConfigCommandHandler` берёт `pg_advisory_xact_lock(hashtext(userId))` —
|
||||||
|
сериализует параллельные попытки создать конфиг одним и тем же пользователем в рамках транзакции.
|
||||||
|
Отдельного rowversion/`xmin` на `Node`/`VpnConfig` нет (единственный `IsConcurrencyToken` в схеме —
|
||||||
|
штатный `ConcurrencyStamp` таблиц Identity).
|
||||||
|
|
||||||
## Работа с 3x-ui
|
## Работа с 3x-ui
|
||||||
|
|
||||||
@@ -92,25 +130,39 @@ backend/
|
|||||||
|
|
||||||
## Ошибки и логирование
|
## Ошибки и логирование
|
||||||
|
|
||||||
- Единый `ProblemDetails` для ошибок API; коды: 400 (валидация), 401/403 (auth), 404, 409 (конфликт домена), 422, 429 (rate limit), 500.
|
- Единый `application/problem+json` для ошибок API (`ResultExtensions.ToProblem`); коды: 400 (валидация),
|
||||||
- Serilog со структурными полями (`UserId`, `NodeId`, `ConfigId`, `CorrelationId`); секреты не логировать.
|
401/403 (auth/не активирован), 404, 409 (конфликт домена — квота, дубликат), 422 (прочее), 429 (rate limit), 500.
|
||||||
|
- Serilog + `UseSerilogRequestLogging()`; секреты (пароли, JWT, `BotToken`) не логировать. Структурного
|
||||||
|
обогащения `UserId`/`NodeId`/`ConfigId`/`CorrelationId` пока нет — см. [tech-stack.md](tech-stack.md).
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
- **Domain.Tests** — инварианты и поведение сущностей, без моков.
|
- **PnvPanel.Domain.Tests** (54 теста) — инварианты и поведение сущностей, без моков.
|
||||||
- **Application.Tests** — хендлеры с подменёнными портами (NSubstitute), проверка веток `Result`.
|
- **PnvPanel.Application.Tests** (71 тест) — хендлеры на EF Core InMemory + подменённые порты
|
||||||
- **Integration.Tests** — реальный PostgreSQL (Testcontainers), миграции, сквозные сценарии эндпоинтов;
|
(NSubstitute), проверка веток `Result`. InMemory, а не Sqlite — модель использует Postgres-специфичные
|
||||||
3x-ui — мок гейтвея или фейковый HTTP-сервер.
|
типы (`uuid[]`, `jsonb`), которые Sqlite не поддерживает, а InMemory просто игнорирует.
|
||||||
- Именование тестов: `Method_Scenario_ExpectedResult`.
|
- **PnvPanel.IntegrationTests** (9 тестов) — реальный PostgreSQL (`Testcontainers.PostgreSql`) +
|
||||||
|
`WebApplicationFactory<Program>`, сквозные сценарии через реальный HTTP-контракт, включая проверку
|
||||||
|
`pg_advisory_xact_lock` под параллельной нагрузкой на квоту; 3x-ui подменён `FakeXuiPanelGateway`.
|
||||||
|
- Именование тестов: `Method_Scenario_ExpectedResult`. Обычные `Assert.*` из xUnit — без FluentAssertions.
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
- `appsettings.json` + `appsettings.{Environment}.json` + env vars (перекрывают).
|
- `appsettings.json` + `appsettings.{Environment}.json` + env vars (перекрывают); в dev — `.env`
|
||||||
- Секреты (JWT-ключ, строка подключения, ключ шифрования) — user-secrets (dev) / env/secret-store (prod).
|
через `docker-compose`'s `env_file`, локально без Docker — переменные окружения напрямую (проект
|
||||||
- Строго типизированные `IOptions<T>` для секций конфига; валидация опций на старте.
|
не подключает `dotnet user-secrets` — `UserSecretsId` в `.csproj` нет).
|
||||||
|
- Секреты (JWT-ключ, строка подключения, `Telegram:BotToken`, пароль сид-админа) — только через env/secret-store.
|
||||||
|
- Строго типизированные `IOptions<T>` для секций конфига (`JwtOptions`, `AdminSeedOptions`,
|
||||||
|
`TelegramOptions`, `RolesOptions`, ...); явной валидации на старте (`ValidateOnStart`/data annotations)
|
||||||
|
нет — отсутствующий обязательный секрет обнаружится при первом обращении (например, `AddInfrastructure`
|
||||||
|
бросит `InvalidOperationException`, если не задан `ConnectionStrings:Default`), не раньше.
|
||||||
|
|
||||||
## Стиль и качество кода
|
## Стиль и качество кода
|
||||||
|
|
||||||
- `.editorconfig` + анализаторы (`Microsoft.CodeAnalysis.NetAnalyzers`), nullable reference types **включены**.
|
- `.editorconfig`, nullable reference types **включены**; сборка в CI идёт с `-c Release` и должна
|
||||||
- `dotnet format` в CI; предупреждения как ошибки для наших проектов.
|
быть без предупреждений.
|
||||||
- Комментарии — по необходимости (почему, а не что); публичные контракты портов документируем XML-doc.
|
- `dotnet format` — локальная команда разработчика (см. корневой `CLAUDE.md`), **в CI не запускается**;
|
||||||
|
CI гоняет только `dotnet build`/`dotnet test` (backend) и `pnpm lint`/`typecheck`/`build` (frontend).
|
||||||
|
- Комментарии — по необходимости (почему, а не что, — см. примеры в коде: причина `pg_advisory_xact_lock`,
|
||||||
|
причина `Secure = request.IsHttps`); публичные контракты портов документируем XML-doc там, где это
|
||||||
|
не очевидно из имени.
|
||||||
|
|||||||
+64
-42
@@ -1,22 +1,25 @@
|
|||||||
# Domain Model
|
# Domain Model
|
||||||
|
|
||||||
Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах.
|
Домен — «rich model»: инварианты и переходы состояний живут в сущностях, а не в хендлерах.
|
||||||
`AppUser` — часть Identity (в `Infrastructure`); домен ссылается на пользователя по `UserId : Guid`.
|
`AppUser`/`AppRole` — часть Identity (живут в `Infrastructure`, т.к. расширяют `IdentityUser<Guid>`/
|
||||||
|
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
|
||||||
|
|
||||||
|
Ниже — то, что реально реализовано и работает. Тарифы `Plan` и лимиты трафика на конфиг
|
||||||
|
(`TrafficLimit`) были в первоначальном плане, но остались в backlog — квота в MVP только одна:
|
||||||
|
число активных конфигов на роль (`AppRole.MaxConfigs`).
|
||||||
|
|
||||||
## Диаграмма связей
|
## Диаграмма связей
|
||||||
|
|
||||||
```
|
```
|
||||||
AppUser (Identity) [+ IsActivated, TelegramUserId]
|
AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken]
|
||||||
├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs)
|
├─*───1─ AppRole (ровно одна роль; роль несёт квоту MaxConfigs)
|
||||||
├─1───*─ VpnConfig
|
├─1───*─ VpnConfig
|
||||||
│ *─┐
|
│ └─1─ Inbound ─*─1─ Node
|
||||||
│ ├─1─ Inbound ─*─1─ Node
|
│ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
|
||||||
│ │ └─*───*─ AppRole (какие роли могут создавать конфиги в инбаунде)
|
|
||||||
│ └─*─ TrafficSample
|
|
||||||
├─0..1─* ActivationRequest (запрос активации у админа, с комментарием)
|
├─0..1─* ActivationRequest (запрос активации у админа, с комментарием)
|
||||||
├─1───*─ TelegramLinkToken (короткоживущие токены привязки)
|
├─1───*─ TelegramLinkToken (короткоживущие токены привязки)
|
||||||
└─0..1─* TelegramLoginRequest (passwordless-вход)
|
└─0..1─* TelegramLoginRequest (passwordless-вход)
|
||||||
Plan ─1───*─ VpnConfig (опционально; квота по числу конфигов — на роли, не на Plan)
|
VpnConfig ─*─ TrafficSample (история трафика; пишется TrafficSyncService)
|
||||||
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
|
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
|
||||||
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
|
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
|
||||||
```
|
```
|
||||||
@@ -79,41 +82,48 @@ ClientApp (каталог приложений-клиен
|
|||||||
| `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) |
|
| `ClientExternalId` | `string` | Идентификатор клиента, который вернула панель (UUID для VLESS/VMess, пароль для Trojan/Shadowsocks — ThreeXui.Net отдаёт его как string) |
|
||||||
| `Protocol` | `VpnProtocol` | Денормализовано с inbound |
|
| `Protocol` | `VpnProtocol` | Денормализовано с inbound |
|
||||||
| `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui |
|
| `DeviceLimit` | `int` | Лимит одновременных устройств/IP (0 = без лимита); задаёт юзер → `limitIp` в 3x-ui |
|
||||||
| `TrafficLimit` | `TrafficLimit` (VO) | Лимит в байтах (0 = безлимит) |
|
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui (только для отображения — лимит трафика не применяется) |
|
||||||
| `UsedUpBytes` | `long` | Синхронизируется из 3x-ui |
|
|
||||||
| `UsedDownBytes` | `long` | Синхронизируется из 3x-ui |
|
| `UsedDownBytes` | `long` | Синхронизируется из 3x-ui |
|
||||||
| `ExpiresAt` | `DateTimeOffset?`| null = бессрочно |
|
| `ExpiresAt` | `DateTimeOffset?`| Зарезервировано на будущее — в MVP ничего его не выставляет, конфиг живёт бессрочно |
|
||||||
| `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`|
|
| `Status` | `ConfigStatus` | `Active` / `Disabled` / `Expired` / `LimitReached` / `Revoked`|
|
||||||
| `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` |
|
| `SubscriptionToken`| `string` | Секрет для публичного `/sub/{token}` |
|
||||||
| `LastSyncAt` | `DateTimeOffset?`| |
|
| `LastSyncAt` | `DateTimeOffset?`| |
|
||||||
| `CreatedAt` | `DateTimeOffset` | |
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
Инварианты и переходы:
|
Инварианты и переходы (методы на `VpnConfig`, `backend/src/PnvPanel.Domain/Configs/VpnConfig.cs`):
|
||||||
- Создаётся в статусе `Active`; поля клиента в 3x-ui и запись в БД создаются атомарно (компенсация при сбое).
|
- `Create(...)` → статус `Active`, `ClientExternalId` пуст до ответа от 3x-ui; хендлер вызывает
|
||||||
- **Проверка квоты выполняется в транзакции с блокировкой** (иначе два параллельных создания пробьют лимит).
|
`IXuiPanelGateway.AddClientAsync`, затем `AssignRemoteClient(id)` и сохраняет — при сбое БД после
|
||||||
- `Revoke()` → удаляет клиента в 3x-ui, статус `Revoked` (запись остаётся для истории/аудита).
|
успешного создания в панели хендлер удаляет клиента в 3x-ui (компенсация).
|
||||||
- `Rotate()` → перевыпуск: удаляет старого клиента в 3x-ui и создаёт нового (новый UUID/ссылка);
|
- **Проверка квоты выполняется под `pg_advisory_xact_lock(hashtext(userId))`** в транзакции создания
|
||||||
квоту **не тратит**. Для случая утечки ссылки.
|
(`CreateVpnConfigCommandHandler`) — иначе два параллельных запроса могли бы пробить лимит роли.
|
||||||
- `Disable()`/`Enable()` → отключение/включение клиента в 3x-ui без удаления (используется при блокировке юзера).
|
- `Revoke()` → статус `Revoked` (запись остаётся для истории/аудита); хендлер отдельно удаляет клиента в 3x-ui.
|
||||||
|
- `Rotate(newClientEmail, newClientExternalId)` → перевыпуск: хендлер создаёт нового клиента в 3x-ui,
|
||||||
|
удаляет старого, генерирует новый `SubscriptionToken`; квоту **не тратит**. Для случая утечки ссылки.
|
||||||
|
- `Disable()`/`Enable()` → меняют только статус записи (`Active ↔ Disabled`); отключение/включение
|
||||||
|
самого клиента в 3x-ui делает хендлер отдельным вызовом гейтвея (используется при блокировке юзера).
|
||||||
- `Rename(label)` / `SetDeviceLimit(n)` → юзер меняет метку и лимит устройств (последнее синкается в `limitIp` 3x-ui).
|
- `Rename(label)` / `SetDeviceLimit(n)` → юзер меняет метку и лимит устройств (последнее синкается в `limitIp` 3x-ui).
|
||||||
- Синхронизация: если `Used ≥ TrafficLimit` → `LimitReached` (+ событие); если `now ≥ ExpiresAt` → `Expired`.
|
- `UpdateTraffic(up, down)` → пишет `TrafficSyncService` при периодической синхронизации, только для отображения.
|
||||||
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
|
- **Создание разрешено только активированному пользователю** (`AppUser.IsActivated == true`).
|
||||||
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
|
- Число активных конфигов пользователя не может превышать **квоту его роли** (`AppRole.MaxConfigs`;
|
||||||
роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже.
|
роль `admin` — без лимита). У пользователя ровно одна роль. См. `AppRole` ниже.
|
||||||
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
|
- Инбаунд должен быть доступен роли пользователя (`Inbound.AllowedRoles`).
|
||||||
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
|
- Разрешено несколько конфигов в одном инбаунде (ограничение — только общая квота роли).
|
||||||
|
|
||||||
### Plan — тариф (опционально, backlog)
|
> **Не реализовано в MVP**: лимиты трафика и автоматическое истечение срока конфига. `ExpiresAt`
|
||||||
Шаблон лимитов трафика/срока для конфига. **Квота на число конфигов — это `AppRole.MaxConfigs`,
|
> никогда не выставляется, `ConfigStatus.LimitReached` в значении enum есть, но код в него никогда
|
||||||
а не Plan.** Plan остаётся опциональным механизмом для лимитов трафика/срока и в MVP не обязателен.
|
> не переводит конфиг — оставлено на будущее (см. `Plan` ниже и Backlog в [vision.md](vision.md)).
|
||||||
|
|
||||||
|
### Plan — тариф (backlog, не реализовано)
|
||||||
|
Планировался как шаблон лимитов трафика/срока для конфига — **квота на число конфигов уже
|
||||||
|
реализована через `AppRole.MaxConfigs`, это не Plan**. Сущности `Plan` в коде нет; таблица ниже —
|
||||||
|
эскиз на будущее, если/когда лимиты трафика/срока понадобятся.
|
||||||
|
|
||||||
| Поле | Тип | Заметки |
|
| Поле | Тип | Заметки |
|
||||||
| ------------------ | ----------- | ------------------------------ |
|
| ------------------ | ----------- | ------------------------------ |
|
||||||
| `Id` | `Guid` | PK |
|
| `Id` | `Guid` | PK |
|
||||||
| `Name` | `string` | |
|
| `Name` | `string` | |
|
||||||
| `TrafficLimit` | `TrafficLimit` (VO) | Байты |
|
| `TrafficLimitBytes`| `long` | 0 = безлимит |
|
||||||
| `DurationDays` | `int?` | Срок действия конфига |
|
| `DurationDays` | `int?` | Срок действия конфига |
|
||||||
| `MaxConfigs` | `int` | Сколько конфигов даёт тариф |
|
|
||||||
| `IsActive` | `bool` | |
|
| `IsActive` | `bool` | |
|
||||||
|
|
||||||
### TrafficSample — история трафика (для графиков)
|
### TrafficSample — история трафика (для графиков)
|
||||||
@@ -139,7 +149,7 @@ ClientApp (каталог приложений-клиен
|
|||||||
| `Id` | `Guid` | PK |
|
| `Id` | `Guid` | PK |
|
||||||
| `Name` | `string` | Название, напр. «v2rayNG», «Hiddify», «NekoBox» |
|
| `Name` | `string` | Название, напр. «v2rayNG», «Hiddify», «NekoBox» |
|
||||||
| `DownloadUrl` | `Uri` | Ссылка на скачивание/стор |
|
| `DownloadUrl` | `Uri` | Ссылка на скачивание/стор |
|
||||||
| `OperatingSystem` | `OsPlatform` | `iOS` / `Android` / `Windows` / `MacOS` / `Linux` |
|
| `OperatingSystem` | `OsPlatform` | `IOS` / `Android` / `Windows` / `MacOS` / `Linux` |
|
||||||
| `Description` | `string?` | Короткая подсказка (опц.) |
|
| `Description` | `string?` | Короткая подсказка (опц.) |
|
||||||
| `IconUrl` | `string?` | Иконка (опц.) |
|
| `IconUrl` | `string?` | Иконка (опц.) |
|
||||||
| `SortOrder` | `int` | Порядок внутри группы ОС |
|
| `SortOrder` | `int` | Порядок внутри группы ОС |
|
||||||
@@ -253,9 +263,12 @@ UI **настойчиво напоминает** привязать его (ед
|
|||||||
|
|
||||||
## Value Objects
|
## Value Objects
|
||||||
|
|
||||||
- **NodeCredentials** — `Username` + `ProtectedPassword` (шифротекст); равенство по значению; пароль не сериализуется наружу.
|
- **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст,
|
||||||
- **TrafficLimit** — байты; помощники `IsUnlimited`, `IsExceededBy(used)`, форматирование в ГБ.
|
`ISecretProtector`/ASP.NET Data Protection); пароль не сериализуется наружу.
|
||||||
- **ConnectionLink** — построенная ThreeXui.Net строка подключения + производные (подписка, QR-payload).
|
|
||||||
|
Connection string для клиента строит `IXuiPanelGateway` (обёртка над `ThreeXui.Net`) на лету при
|
||||||
|
запросе `GET /api/configs/{id}/link` — отдельного value object под это не заводили. QR-код из
|
||||||
|
готовой строки генерируется **на фронте** (`qrcode.react`), сервер картинку не рендерит.
|
||||||
|
|
||||||
## Enums
|
## Enums
|
||||||
|
|
||||||
@@ -266,22 +279,31 @@ enum ConfigStatus { Active, Disabled, Expired, LimitReached, Revoked }
|
|||||||
enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
|
enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
|
||||||
enum ActivationStatus { Pending, Approved, Rejected }
|
enum ActivationStatus { Pending, Approved, Rejected }
|
||||||
enum AuditSource { Web, Telegram, System }
|
enum AuditSource { Web, Telegram, System }
|
||||||
enum OsPlatform { iOS, Android, Windows, MacOS, Linux }
|
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Доменные события
|
## Уведомления и аудит (без диспетчера доменных событий)
|
||||||
|
|
||||||
| Событие | Когда | Реакция |
|
В `Domain` нет маркера `IDomainEvent` и диспетчера событий — упрощение относительно исходного плана.
|
||||||
| ----------------------- | --------------------------------------- | --------------------------------------------------- |
|
CQRS-хендлеры сами вызывают порты `IRealtimeNotifier` / `ITelegramNotifier` и пишут `AuditLog`
|
||||||
| `VpnConfigCreated` | Успешно создан конфиг | Realtime-пуш владельцу; аудит |
|
напрямую, после того как изменение состояния сохранено. Так проще проследить, что именно произойдёт
|
||||||
| `VpnConfigRevoked` | Конфиг отозван | Realtime-пуш; аудит |
|
при вызове конкретной команды — не нужно искать обработчик события где-то ещё.
|
||||||
| `TrafficLimitReached` | `Used ≥ Limit` при синхронизации | (опц.) отключить клиента в 3x-ui; пуш; статус |
|
|
||||||
| `NodeWentOffline` | Health-probe вернул недоступность | Пуш группе `admins`; пометка статуса |
|
|
||||||
| `ActivationRequested` | Пользователь запросил активацию | Пуш `admins` + уведомление админам в Telegram |
|
|
||||||
| `UserActivated` | Админ одобрил активацию | Пуш владельцу + Telegram-DM (если привязан); аудит |
|
|
||||||
| `UserBlocked` / `UserUnblocked` | Админ (раз)блокировал пользователя | Отключить/включить конфиги в 3x-ui; пуш + Telegram-DM; аудит |
|
|
||||||
| `VpnConfigRotated` | Пользователь перевыпустил конфиг | Новый линк владельцу; аудит |
|
|
||||||
|
|
||||||
События публикуются из сущностей/хендлеров и обрабатываются `IDomainEventHandler<T>` в Application
|
| Хендлер / фоновый сервис | Что происходит |
|
||||||
(диспетчеризация — собственным диспетчером после `SaveChanges`); внешние эффекты (SignalR, 3x-ui) —
|
| ------------------------------------ | -------------------------------------------------------------------------- |
|
||||||
через порты, реализуемые в Infrastructure.
|
| `CreateVpnConfigCommandHandler` | Создаёт клиента в 3x-ui + `VpnConfig` |
|
||||||
|
| `RevokeVpnConfigCommandHandler` / `RotateVpnConfigCommandHandler` | Меняют клиента в 3x-ui и запись |
|
||||||
|
| `RequestActivationCommandHandler` | Realtime `activationRequested` группе `admins` + Telegram-уведомление админам (`AdminTelegramUserIds`) |
|
||||||
|
| `ApproveActivationCommandHandler` | `AuditLog` (`ActivationApproved`); realtime `userActivated` владельцу + Telegram-DM, если привязан |
|
||||||
|
| `RejectActivationCommandHandler` | `AuditLog` (`ActivationRejected`) |
|
||||||
|
| `BlockUserCommandHandler` / `UnblockUserCommandHandler` | Отключают/включают все активные конфиги в 3x-ui; `AuditLog`; Telegram-DM владельцу |
|
||||||
|
| `ChangeUserRoleCommandHandler` | `AuditLog` (`UserRoleChanged`) |
|
||||||
|
| `ForceRevokeConfigCommandHandler` | Отзывает конфиг в 3x-ui; `AuditLog` (`ConfigForceRevoked`); Telegram-DM владельцу |
|
||||||
|
| `ResetUserPasswordCommandHandler` | `AuditLog` (`UserPasswordReset`) |
|
||||||
|
| `RegisterNodeCommandHandler` / `UpdateNodeCommandHandler` / `DeleteNodeCommandHandler` | `AuditLog` (`NodeRegistered`/`NodeUpdated`/`NodeDeleted`) |
|
||||||
|
| `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) |
|
||||||
|
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
|
||||||
|
| `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу |
|
||||||
|
|
||||||
|
SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и
|
||||||
|
[api-design.md](api-design.md#signalr--hub-hubspanel).
|
||||||
|
|||||||
+106
-78
@@ -1,119 +1,147 @@
|
|||||||
# Frontend
|
# Frontend
|
||||||
|
|
||||||
SPA на **React 19 + Vite + TypeScript**. Общается с бэком по REST (JWT Bearer) и получает
|
SPA на **React 19 + Vite + TypeScript**. Общается с бэком по REST (JWT Bearer) и получает
|
||||||
живые обновления по SignalR. Типы API генерируются из OpenAPI-схемы бэкенда.
|
живые обновления по SignalR.
|
||||||
|
|
||||||
> **Раздача из единого контейнера.** В проде собранный фронт (`dist/`) кладётся в `wwwroot`
|
> **Раздача из единого контейнера.** В проде собранный фронт (`dist/`) кладётся в `wwwroot`
|
||||||
> ASP.NET Core и раздаётся тем же приложением (SPA-fallback на `index.html`). Фронт и бек — один
|
> ASP.NET Core и раздаётся тем же приложением (SPA-fallback на `index.html`). Фронт и бек — один
|
||||||
> origin, база API — относительный `/api`, SignalR — `/hubs/panel`. В dev Vite-сервер проксирует
|
> origin, база API — относительный `/api`, SignalR — `/hubs/panel`. В dev Vite-сервер проксирует
|
||||||
> `/api` и `/hubs` на бэкенд. Детали упаковки — [architecture.md](architecture.md#развёртывание-единый-контейнер-приложения).
|
> `/api` и `/hubs` на бэкенд (`vite.config.ts`, цель — `http://localhost:8080` по умолчанию,
|
||||||
|
> переопределяется `VITE_API_TARGET`). Детали упаковки — [architecture.md](architecture.md#развёртывание-единый-контейнер-приложения).
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
| Задача | Выбор |
|
| Задача | Выбор |
|
||||||
| ----------------- | --------------------------------------- |
|
| ----------------- | ------------------------------------------------------------ |
|
||||||
| Сборка/dev | Vite |
|
| Сборка/dev | Vite (Rolldown-based) |
|
||||||
| Язык | TypeScript (strict) |
|
| Язык | TypeScript (strict) |
|
||||||
| Данные с сервера | TanStack Query |
|
| Данные с сервера | TanStack Query |
|
||||||
| Роутинг | TanStack Router (типобезопасный) |
|
| Роутинг | TanStack Router (файловый, `src/routes/`, кодогенерация `routeTree.gen.ts`) |
|
||||||
| UI-компоненты | shadcn/ui + Tailwind CSS v4 |
|
| UI-компоненты | shadcn-стиль поверх Radix (`@radix-ui/react-*`) + Tailwind CSS v4 |
|
||||||
| Иконки | lucide-react |
|
| Иконки | lucide-react |
|
||||||
| Клиентский стейт | Zustand (auth, тема) |
|
| Клиентский стейт | Zustand — только auth-стор (`features/auth/store.ts`); тема — React Context + localStorage, не Zustand |
|
||||||
| Формы | react-hook-form + zod |
|
| Формы | react-hook-form + zod |
|
||||||
| Realtime | @microsoft/signalr |
|
| Realtime | @microsoft/signalr |
|
||||||
| Графики | Recharts |
|
| QR-коды | qrcode.react (рендерит QR из готовой строки на клиенте) |
|
||||||
| QR-коды | qrcode.react |
|
| Типы API | openapi-typescript (`pnpm gen:api`) — генерирует `schema.gen.ts` для сверки; фичи импортируют руками написанный `shared/api/types.ts` |
|
||||||
| Типы API | openapi-typescript / orval (codegen) |
|
| i18n | react-i18next (RU + EN) |
|
||||||
| i18n | react-i18next (RU + EN) |
|
| Линт | oxlint (не ESLint) |
|
||||||
| Пакетный менеджер | pnpm |
|
| Пакетный менеджер | pnpm |
|
||||||
|
|
||||||
|
Установлены, но **не используются в MVP**: `recharts` (админская статистика — карточки с цифрами,
|
||||||
|
без графиков), `@tanstack/react-table` (админские таблицы написаны руками, без TanStack Table).
|
||||||
|
Оставлены как задел, если/когда понадобятся графики трафика или сложные таблицы с сортировкой.
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
|
Фактическая структура (`frontend/src/`):
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
src/
|
src/
|
||||||
app/ # провайдеры (Query, Router, Auth, Theme), корневой layout
|
main.tsx # точка входа: QueryClientProvider > ThemeProvider > ToastProvider > RealtimeProvider > RouterProvider
|
||||||
routes/ # маршруты TanStack Router (login, dashboard, configs, instructions, admin/*)
|
router.tsx # createRouter из routeTree.gen.ts
|
||||||
|
routeTree.gen.ts # сгенерировано @tanstack/router-plugin, не редактируется руками
|
||||||
|
index.css # Tailwind v4 (@import), без отдельной styles/-папки
|
||||||
|
routes/ # файловый роутинг TanStack Router
|
||||||
|
__root.tsx # шапка (лого, нав, переключатель языка/темы), Outlet
|
||||||
|
index.tsx, login.tsx, register.tsx, dashboard.tsx, instructions.tsx, settings.tsx
|
||||||
|
admin.tsx # layout админки (вкладки) + Outlet
|
||||||
|
admin/
|
||||||
|
index.tsx, activation.tsx, users.tsx, roles.tsx, nodes.tsx, apps.tsx, audit.tsx
|
||||||
features/
|
features/
|
||||||
auth/ # формы, хуки useLogin/useRegister, стор авторизации
|
auth/ # api.ts, store.ts (zustand), guards.ts, LoginForm.tsx, RegisterForm.tsx
|
||||||
configs/ # список/создание/редактирование/детали конфигов, QR, подписка
|
activation/ # api.ts, ActivationGate.tsx (экран "запросить активацию")
|
||||||
instructions/ # страница инструкций + каталог приложений по ОС
|
configs/ # api.ts, ConfigCard.tsx, CreateConfigDialog.tsx, SubscriptionCard.tsx
|
||||||
nodes/ # (admin) управление нодами
|
apps/ # api.ts, AppsCatalog.tsx (для /instructions)
|
||||||
apps/ # (admin) CRUD каталога приложений
|
telegram/ # api.ts, TelegramLoginButton.tsx
|
||||||
admin/ # пользователи, роли, аудит, статистика
|
settings/ # ChangePasswordForm.tsx, TelegramLinkCard.tsx, DeleteAccountSection.tsx
|
||||||
theme/ # провайдер темы (light/dark/system) + переключатель
|
admin/
|
||||||
|
users/, roles/, activation/, nodes/, inbounds/, apps/, audit/, stats/ # api.ts + диалоги CRUD в каждой
|
||||||
|
theme/
|
||||||
|
ThemeProvider.tsx # React Context + localStorage (`pnv-theme`), НЕ zustand
|
||||||
shared/
|
shared/
|
||||||
api/ # http-клиент (fetch + JWT/refresh), сгенерированные типы, query-хуки
|
api/
|
||||||
realtime/ # инициализация SignalR, подписки → инвалидация Query-кэша
|
client.ts # apiRequest(), HttpError, access-token в памяти модуля, 401 → silent refresh → повтор
|
||||||
ui/ # обёртки над shadcn/ui, общие компоненты
|
types.ts # руками написанные типы ответов/запросов (сверены со schema.gen.ts)
|
||||||
lib/ # утилиты, форматирование (байты, даты)
|
schema.gen.ts # генерируется pnpm gen:api, не импортируется напрямую фичами
|
||||||
config/ # env, константы
|
lib/
|
||||||
styles/ # tailwind, темы
|
i18n.ts, cn.ts, format.ts
|
||||||
|
realtime/
|
||||||
|
connection.ts, RealtimeProvider.tsx
|
||||||
|
ui/
|
||||||
|
button.tsx, input.tsx, label.tsx, card.tsx, dialog.tsx, select.tsx, badge.tsx,
|
||||||
|
progress.tsx, toast-store.tsx, toaster.tsx
|
||||||
index.html
|
index.html
|
||||||
vite.config.ts
|
vite.config.ts
|
||||||
package.json
|
package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Нет `app/`-папки с провайдерами (они прямо в `main.tsx`), нет `shared/config/` (переменные окружения
|
||||||
|
читаются точечно через `import.meta.env`), нет `styles/` (один `index.css` с Tailwind).
|
||||||
|
|
||||||
## Дизайн / UX
|
## Дизайн / UX
|
||||||
|
|
||||||
- **Тема оформления**: светлая и тёмная (переключатель в шапке; вариант «системная»). Реализация —
|
- **Тема оформления**: светлая/тёмная/системная (переключатель в шапке). Реализация — класс `dark` на
|
||||||
Tailwind `dark` (класс на `html`) + shadcn/ui; выбор сохраняется (localStorage).
|
`<html>` + Tailwind, выбор в `localStorage` (`ThemeProvider`, React Context).
|
||||||
- **Современный и чистый вид**: shadcn/ui + Tailwind, адаптивность, аккуратная типографика.
|
- **Дашборд пользователя** (`/dashboard`): карточки конфигов (протокол, локация, использованный
|
||||||
- **Дашборд пользователя**: карточки конфигов (протокол, локация, трафик прогресс-баром, срок, статус),
|
трафик, статус), кнопки на карточке — показать ссылку/QR (запрашивает `GET .../link` по клику,
|
||||||
быстрые действия (копировать ссылку, показать QR, перевыпустить, отозвать) + карточка «Общая подписка»
|
не сразу при создании), перевыпустить, отозвать; отдельная карточка «Общая подписка». Для
|
||||||
(агрегированная ссылка/QR со всеми конфигами). Для неактивированного — экран «запросить активацию».
|
неактивированного — `ActivationGate` вместо дашборда.
|
||||||
- **Создание конфига**: выбор локации/inbound (по `DisplayName`) + метка + лимит устройств →
|
- **Создание конфига**: диалог — выбор инбаунда (по `displayName`) + метка + лимит устройств.
|
||||||
мгновенная выдача ссылки + QR + ссылка на страницу инструкций.
|
После успеха карточка конфига появляется в списке; ссылку/QR пользователь открывает отдельно.
|
||||||
- **Страница инструкций** (`/instructions`): общие шаги «как импортировать ссылку/QR» + каталог
|
- **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`),
|
||||||
приложений (`GET /api/apps`), **сгруппированный по ОС**; клик по приложению открывает ссылку на
|
сгруппированный по ОС; клик по приложению открывает ссылку на скачивание.
|
||||||
скачивание. Данные ведёт админ (каталог `ClientApp`).
|
- **Настройки** (`/settings`): смена пароля, привязка/отвязка Telegram (`TelegramLinkCard`),
|
||||||
- **Редактирование конфига**: изменить метку и лимит устройств.
|
удаление аккаунта с подтверждением (`DeleteAccountSection`).
|
||||||
- **Настройки аккаунта**: смена пароля, привязка/отвязка Telegram, **удаление аккаунта** (с подтверждением).
|
- **Админка** (`/admin/*`): вкладки — обзор (карточки статистики, без графиков), запросы активации,
|
||||||
- **Админка**: таблицы (TanStack Table) с пагинацией/фильтрами для нод, пользователей, конфигов, ролей,
|
пользователи, роли, ноды (+ публикация инбаундов), приложения, аудит. Таблицы — обычные `<table>`,
|
||||||
каталога приложений, журнала аудита; очередь запросов активации; графики трафика (Recharts).
|
без TanStack Table. Блокировка пользователя — с подтверждением.
|
||||||
Блокировка пользователя — с подтверждением (гасит VPN). Управление приложениями (название, ссылка, ОС, вкл/выкл).
|
- **Состояния**: `isLoading`/`isError`/пусто различаются явно везде (ошибка сети не выглядит как
|
||||||
- **Состояния**: скелетоны при загрузке, аккуратные пустые состояния и toasts на ошибки/успех.
|
«пусто» — паттерн закреплён после находки в `ActivationGate`, распространён на все admin-списки).
|
||||||
|
|
||||||
## Работа с API
|
## Работа с API
|
||||||
|
|
||||||
- HTTP-клиент оборачивает `fetch`: подставляет access-token, при 401 — прозрачно обновляет через
|
- `shared/api/client.ts`: `apiRequest<T>()` оборачивает `fetch`, подставляет access-token в
|
||||||
refresh-cookie и повторяет запрос; при неуспехе — разлогин.
|
`Authorization`; на `401` — один прозрачный `POST /api/auth/refresh` и повтор запроса; при неуспехе
|
||||||
- Все запросы/мутации — через TanStack Query (ключи по фичам, инвалидация после мутаций).
|
рефреша — `setUnauthorizedHandler` колбэк (разлогин).
|
||||||
- Типы ответов/запросов — из codegen по OpenAPI (никакого ручного дублирования DTO).
|
- Запросы/мутации — через TanStack Query, ключи по фиче, инвалидация после мутаций.
|
||||||
|
- Типы — из `shared/api/types.ts` (см. таблицу стека выше и [tech-stack.md](tech-stack.md)).
|
||||||
|
|
||||||
## Авторизация на клиенте
|
## Авторизация на клиенте
|
||||||
|
|
||||||
- **Access-token** — в памяти (не в localStorage), кладётся в `Authorization: Bearer`.
|
- **Access-token** — в памяти (модуль `client.ts`, не React state и не localStorage) — переживает
|
||||||
- **Refresh-token** — httpOnly Secure cookie (JS не читает), ротация на сервере.
|
ре-рендеры, но не пережить reload (тогда его молча восстанавливает silent refresh по cookie).
|
||||||
- Стор авторизации (Zustand) хранит профиль/роли/`isActivated`; guard-маршруты по роли (`admin` vs обычный)
|
- **Refresh-token** — httpOnly Secure cookie (`pnv_refresh_token`, `Path=/api/auth`), JS его не видит.
|
||||||
и по активации (неактивированного ведём на экран «запросить активацию»).
|
- Стор авторизации (`features/auth/store.ts`, Zustand) хранит текущего пользователя/`isActivated`.
|
||||||
|
Guard-хуки `useRequireAuth()`/`useRequireGuest()`/`useRequireAdmin()` (`features/auth/guards.ts`)
|
||||||
- **Вход — по username** (email не используется). «Забыли пароль?» ведёт: при привязанном
|
редиректят через `useNavigate()` в `useEffect`, если условие не выполнено.
|
||||||
Telegram — восстановление через бота; иначе — подсказка обратиться к админу.
|
- **Вход — по username** (email не используется). «Забыли пароль?»: при привязанном Telegram —
|
||||||
- **Баннер привязки Telegram**: пока Telegram не привязан, показываем настойчивый, но не блокирующий
|
вход через бота и смена пароля в настройках; иначе — обратиться к админу.
|
||||||
баннер/напоминание — это единственный self-service способ восстановить доступ. Настройки: смена пароля.
|
|
||||||
|
|
||||||
### Вход и привязка через Telegram
|
### Вход и привязка через Telegram
|
||||||
- **«Войти через Telegram»**: `POST /api/auth/telegram/login-request` → показать deep-link/QR на
|
- **«Войти через Telegram»** (`TelegramLoginButton`, на `/login`): `POST .../login-request` →
|
||||||
бота, затем ждать подтверждения (поллинг `GET …/login-request/{id}` или событие SignalR). При
|
показывает `deepLink` как QR (`qrcode.react`), поллит `GET .../login-request/{id}` до
|
||||||
`Approved` — сохранить access, refresh уже в cookie, редирект в панель.
|
`Approved`/`Rejected`/`Expired`. При `Approved` — сохраняет `accessToken` в память клиента
|
||||||
- **«Привязать Telegram»** (в настройках, для вошедшего): `POST /api/auth/telegram/link-token` →
|
(refresh уже пришёл в cookie от бэка) и редиректит в панель.
|
||||||
показать deep-link/QR; статус привязки обновить по факту (поллинг/SignalR). Отвязка — `unlink`.
|
- **«Привязать Telegram»** (`TelegramLinkCard`, в настройках): `POST .../link-token` → QR из
|
||||||
- QR для deep-link — `qrcode.react`.
|
`deepLink`; статус обновляется поллингом. Отвязка — `POST .../unlink`.
|
||||||
|
|
||||||
## Realtime
|
## Realtime
|
||||||
|
|
||||||
- Одно SignalR-подключение к `/hubs/panel` с JWT; реконнект с бэкоффом.
|
- Одно SignalR-подключение к `/hubs/panel` с JWT (`RealtimeProvider`, `shared/realtime/connection.ts`).
|
||||||
- Обработчики событий (`configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`) точечно
|
- Обработчики `configTrafficUpdated`/`configStatusChanged`/`nodeStatusChanged`/`activationRequested`/
|
||||||
обновляют/инвалидируют кэш TanStack Query — UI обновляется без перезагрузки.
|
`userActivated` точечно инвалидируют/обновляют кэш TanStack Query — UI обновляется без перезагрузки.
|
||||||
|
|
||||||
## Скрипты (ожидаемые)
|
## Скрипты
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # dev-сервер Vite
|
pnpm dev # dev-сервер Vite
|
||||||
pnpm build # прод-сборка
|
pnpm build # tsc -b && vite build (прод-сборка)
|
||||||
pnpm preview # предпросмотр сборки
|
pnpm preview # предпросмотр prod-сборки
|
||||||
pnpm lint # ESLint
|
pnpm lint # oxlint
|
||||||
pnpm typecheck # tsc --noEmit
|
pnpm typecheck # tsc -b
|
||||||
pnpm gen:api # генерация типов из OpenAPI-схемы бэкенда
|
pnpm gen:api # openapi-typescript по /openapi/v1.json живого бэкенда -> schema.gen.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`pnpm gen:api` требует запущенный бэкенд на `http://localhost:8080` (или `docker compose up`).
|
||||||
|
|||||||
+67
-44
@@ -1,75 +1,98 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Порядок реализации по этапам (milestones). Каждый этап — работоспособный инкремент.
|
**MVP полностью реализован** — все этапы M0–M8 закрыты. Ниже — ретроспектива по этапам (как было
|
||||||
|
задумано → что реально сделано, с честными пометками о расхождениях) и раздел [Backlog](#backlog-после-mvp)
|
||||||
|
с тем, что осталось за рамками MVP осознанно.
|
||||||
|
|
||||||
## M0 — Каркас и инфраструктура
|
## M0 — Каркас и инфраструктура ✅
|
||||||
- Solution + 4 проекта (Domain/Application/Infrastructure/Api), ссылки по Clean Architecture.
|
- Solution (`PnvPanel.slnx`) + 4 проекта (Domain/Application/Infrastructure/Api), ссылки по Clean Architecture.
|
||||||
- `Directory.Build.props`, `.editorconfig`, nullable + анализаторы, `dotnet format` в CI.
|
- `Directory.Build.props`, `.editorconfig`, nullable включены. `dotnet format` — локальная команда,
|
||||||
- EF Core + Npgsql, первая миграция.
|
в CI **не** запускается (CI гоняет только build/test).
|
||||||
- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn/ui + TanStack Query/Router; **тема light/dark/system** (провайдер + переключатель); i18n (RU/EN); dev-прокси `/api`,`/hubs` на бэк.
|
- EF Core + Npgsql, миграции.
|
||||||
|
- Scaffolding фронта: Vite + React + TS + Tailwind + shadcn-стиль поверх Radix + TanStack Query/Router;
|
||||||
|
**тема light/dark/system** (провайдер + переключатель); i18n (RU/EN); dev-прокси `/api`,`/hubs` на бэк.
|
||||||
- **Единый контейнер**: multi-stage Dockerfile (node → dotnet publish → aspnet), Api раздаёт SPA из
|
- **Единый контейнер**: multi-stage Dockerfile (node → dotnet publish → aspnet), Api раздаёт SPA из
|
||||||
`wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL); `ForwardedHeaders`
|
`wwwroot` (fallback на `index.html`); docker-compose `app` + `db` (PostgreSQL); `ForwardedHeaders`
|
||||||
(TLS — внешним прокси); авто-применение миграций на старте.
|
(TLS — внешним прокси); авто-применение миграций на старте.
|
||||||
- Health-check `/health`, Serilog, OpenAPI + Scalar.
|
- Health-check `/health`, Serilog, нативный OpenAPI (`Microsoft.AspNetCore.OpenApi`) + Scalar (без Swashbuckle).
|
||||||
- **CI (GitHub Actions)**: `dotnet build/test` + `pnpm build/lint/typecheck` (без деплоя).
|
- **CI (GitHub Actions)**: `dotnet build/test` + `pnpm build/lint/typecheck` (без деплоя).
|
||||||
- **Готово, когда**: единый образ поднимается в docker-compose рядом с postgres, отдаёт заглушку SPA и `/health`, есть базовая миграция, CI зелёный.
|
- **Готово, когда**: единый образ поднимается в docker-compose рядом с postgres, отдаёт SPA и `/health`,
|
||||||
|
есть миграции, CI зелёный. ✅ Достигнуто — включая полную проверку `docker compose up` end-to-end.
|
||||||
|
|
||||||
## M1 — Аутентификация и сидинг
|
## M1 — Аутентификация и сидинг ✅
|
||||||
- ASP.NET Core Identity (`AppUser`/`AppRole` c `MaxConfigs`); `DbInitializer`: системные роли
|
- ASP.NET Core Identity (`AppUser`/`AppRole` c `MaxConfigs`); `DbInitializer`: системные роли
|
||||||
`admin`/`user` и учётка админа + Telegram id админов из env ([`.env.example`](../.env.example)).
|
`admin`/`user` и учётка админа из env ([`.env.example`](../.env.example)).
|
||||||
- **Вход по username** (email не используется); JWT access + refresh (httpOnly cookie, ротация, хранение
|
- **Вход по username** (email не используется); JWT access + refresh (httpOnly cookie, ротация,
|
||||||
хэшей), CSRF на refresh, Identity lockout, rate-limit на `/auth/*`; смена пароля.
|
`Secure` по факту HTTPS-запроса, хранение хэшей), Identity lockout, rate-limit на `/auth/*`;
|
||||||
|
смена пароля. Явного анти-CSRF токена нет — обоснование в [architecture.md](architecture.md#безопасность).
|
||||||
- Регистрация: новый пользователь → роль `user`, `IsActivated = false`.
|
- Регистрация: новый пользователь → роль `user`, `IsActivated = false`.
|
||||||
- Фронт: страницы login/register (username), стор авторизации, refresh-flow, guard-маршруты.
|
- Фронт: страницы login/register (username), стор авторизации, refresh-flow, guard-маршруты.
|
||||||
- **Готово, когда**: регистрация/вход/refresh/logout по username работают, админ засидан, новый юзер неактивен.
|
- **Готово, когда**: регистрация/вход/refresh/logout по username работают, админ засидан, новый юзер неактивен. ✅ Достигнуто.
|
||||||
|
|
||||||
## M2 — Роли и активация
|
## M2 — Роли и активация ✅
|
||||||
- Домен: динамические роли (CRUD `admin`, квота `MaxConfigs`), `ActivationRequest`.
|
- Домен: динамические роли (CRUD, квота `MaxConfigs`), `ActivationRequest`.
|
||||||
- Команды/запросы: CreateRole/UpdateRole/DeleteRole, ChangeUserRole (одна роль), RequestActivation (с комментарием),
|
- Команды/запросы: CreateRole/UpdateRole/DeleteRole, ChangeUserRole (одна роль), RequestActivation
|
||||||
ApproveActivation/RejectActivation.
|
(с комментарием), ApproveActivation/RejectActivation.
|
||||||
- Эндпоинты активации (user + admin) и ролей; policy `RequireActivated`.
|
- Эндпоинты активации (user + admin) и ролей; проверка активации/роли — inline в хендлерах
|
||||||
|
и `RequireAuthorization(...)` на эндпоинте, без отдельных именованных policy.
|
||||||
- Фронт: экран «запросить активацию» (с комментарием), админ-очередь запросов, управление ролями/назначением.
|
- Фронт: экран «запросить активацию» (с комментарием), админ-очередь запросов, управление ролями/назначением.
|
||||||
- **Готово, когда**: юзер запрашивает активацию с комментарием, админ на сайте активирует; роли с квотами работают.
|
- **Готово, когда**: юзер запрашивает активацию с комментарием, админ на сайте активирует; роли с квотами работают. ✅ Достигнуто.
|
||||||
|
|
||||||
## M3 — Ноды и публикация inbounds (по ролям)
|
## M3 — Ноды и публикация inbounds (по ролям) ✅
|
||||||
- Домен `Node`/`Inbound` (+ `AllowedRoles`, `DisplayName`); порт `IXuiPanelGateway` + `XuiPanelGateway`
|
- Домен `Node`/`Inbound` (+ `AllowedRoles`, `DisplayName`); порт `IXuiPanelGateway` + единственная
|
||||||
(per-node клиент, ThreeXui.Net); шифрование секретов нод (`ISecretProtector`).
|
реализация `XuiPanelGateway` (кэш клиента per-node внутри неё, `ThreeXui.Net`); шифрование секретов
|
||||||
- Команды/запросы: RegisterNode, SyncNode, Probe, ListNodes, ListInbounds, PublishInbound (с выбором ролей).
|
нод (`ISecretProtector`/ASP.NET Data Protection).
|
||||||
|
- Команды/запросы: RegisterNode, UpdateNode, DeleteNode, SyncNode, ProbeNode, ListNodes, ListInbounds,
|
||||||
|
PublishInbound (с выбором ролей).
|
||||||
- Админка нод/инбаундов на фронте (публикация с `displayName` и `allowedRoleIds`).
|
- Админка нод/инбаундов на фронте (публикация с `displayName` и `allowedRoleIds`).
|
||||||
- **Готово, когда**: админ подключает реальную 3x-ui и публикует inbound «Германия (Trojan)» для выбранных ролей.
|
- **Готово, когда**: админ подключает реальную 3x-ui и публикует inbound для выбранных ролей. ✅ Достигнуто
|
||||||
|
(удаление ноды с активными конфигами пока не блокируется — известный пробел, см. [tech-stack.md](tech-stack.md)).
|
||||||
|
|
||||||
## M4 — Конфиги пользователя (ядро продукта)
|
## M4 — Конфиги пользователя (ядро продукта) ✅
|
||||||
- Домен `VpnConfig` (создание, отзыв, ротация, статусы; инварианты: активирован + квота роли (грандфазеринг)
|
- Домен `VpnConfig` (создание, отзыв, ротация; инварианты: активирован + квота роли (грандфазеринг)
|
||||||
+ доступ роли к инбаунду; проверка квоты в транзакции; схема `ClientEmail`).
|
+ доступ роли к инбаунду; квота — под `pg_advisory_xact_lock`; схема `ClientEmail`).
|
||||||
- CreateVpnConfig (с `label`/`deviceLimit`→`limitIp`), EditVpnConfig, RotateVpnConfig, RevokeVpnConfig,
|
- CreateVpnConfig (с `label`/`deviceLimit`→`limitIp`), EditVpnConfig, RotateVpnConfig, RevokeVpnConfig,
|
||||||
GetMyConfigs, GetConfigLink, ListAvailableInbounds; connection string + QR.
|
GetMyConfigs, GetConfigLink, ListAvailableInbounds; connection string по запросу (не сразу при
|
||||||
- Подписка: агрегированная `/sub/{userToken}` (все конфиги) + по конфигу `/sub/{configToken}`;
|
создании), QR строится на фронте.
|
||||||
заголовки `Subscription-Userinfo` / `profile-update-interval`.
|
- Подписка: один эндпоинт `/sub/{token}` — токен либо агрегированный (`AppUser.SubscriptionToken`,
|
||||||
- Самоудаление аккаунта (`DELETE /api/auth/me`): отзыв всех конфигов + удаление данных.
|
все конфиги), либо по одному конфигу (`VpnConfig.SubscriptionToken`); заголовки
|
||||||
|
`Subscription-Userinfo` / `Profile-Update-Interval`.
|
||||||
|
- Самоудаление аккаунта (`DELETE /api/auth/me`): отзыв всех активных конфигов + удаление `AppUser`.
|
||||||
- Каталог приложений `ClientApp` (домен + `GET /api/apps` по ОС; сид из `seed/client-apps.json`) + **страница инструкций** на фронте.
|
- Каталог приложений `ClientApp` (домен + `GET /api/apps` по ОС; сид из `seed/client-apps.json`) + **страница инструкций** на фронте.
|
||||||
- Фронт: дашборд (метки, лимит устройств), создание/редактирование, страница инструкций, копирование, QR, отзыв, перевыпуск, настройки аккаунта.
|
- Фронт: дашборд (метки, лимит устройств), создание/редактирование, страница инструкций, ссылка/QR
|
||||||
- **Готово, когда**: активированный юзер создаёт рабочий конфиг в доступном инбаунде в пределах квоты; работает агрегированная подписка.
|
по кнопке, отзыв, перевыпуск, настройки аккаунта.
|
||||||
|
- **Готово, когда**: активированный юзер создаёт рабочий конфиг в доступном инбаунде в пределах квоты;
|
||||||
|
работает подписка. ✅ Достигнуто. Лимиты трафика/срока конфига — не реализованы, backlog
|
||||||
|
(см. [domain-model.md](domain-model.md)).
|
||||||
|
|
||||||
## M5 — Синхронизация трафика и realtime
|
## M5 — Синхронизация трафика и realtime ✅
|
||||||
- `TrafficSyncService` (обход нод, обновление трафика/статусов, `TrafficSample`); реконсиляция дрейфа с 3x-ui.
|
- `TrafficSyncService` (обход включённых нод, обновление трафика, `TrafficSample`) — только для
|
||||||
|
отображения, без активной реконсиляции дрейфа (недоступная нода/незнакомый клиент — тихо пропускаются).
|
||||||
- `NodeHealthCheckService`; `TrafficRetentionService` (TTL-чистка истории).
|
- `NodeHealthCheckService`; `TrafficRetentionService` (TTL-чистка истории).
|
||||||
- SignalR `PanelHub` + `IRealtimeNotifier`; события трафика/статусов/нод/активации.
|
- SignalR `PanelHub` + `IRealtimeNotifier` (реализован в `Api/Hubs/`, не в Infrastructure); события
|
||||||
- Фронт: живые прогресс-бары трафика, статусы онлайн, реакция на превышение лимита/срока.
|
`configTrafficUpdated`/`configStatusChanged`/`nodeStatusChanged`/`activationRequested`/`userActivated`.
|
||||||
- **Готово, когда**: трафик и статусы обновляются в UI без перезагрузки.
|
- Фронт: живые обновления трафика/статусов без перезагрузки. Реакции на превышение лимита/срока нет —
|
||||||
|
таких лимитов не существует (см. M4).
|
||||||
|
- **Готово, когда**: трафик и статусы обновляются в UI без перезагрузки. ✅ Достигнуто.
|
||||||
|
|
||||||
## M6 — Админ-статистика, управление пользователями, аудит
|
## M6 — Админ-статистика, управление пользователями, аудит ✅
|
||||||
- ListUsers, BlockUser (→ отключение конфигов в 3x-ui) / UnblockUser, ChangeUserRole, ResetUserPassword (без привязки TG), GetUserConfigs, force-revoke, GetStats.
|
- ListUsers, BlockUser (→ отключение конфигов в 3x-ui) / UnblockUser (два отдельных эндпоинта),
|
||||||
- `AuditLog`: запись значимых действий (Web/Telegram/System) + эндпоинт `/api/admin/audit`.
|
ChangeUserRole, ResetUserPassword, GetUserConfigs, ForceRevokeConfig, GetStats.
|
||||||
|
- `AuditLog`: запись значимых действий (активация, блокировка, смена роли, ноды/инбаунды — источник
|
||||||
|
всегда `Web`, т.к. пишется из тех же хендлеров, что вызывает и бот) + эндпоинт `/api/admin/audit`.
|
||||||
- Каталог приложений: админ-CRUD `ClientApp` (`/api/admin/apps`) — название, ссылка, ОС, порядок, вкл/выкл.
|
- Каталог приложений: админ-CRUD `ClientApp` (`/api/admin/apps`) — название, ссылка, ОС, порядок, вкл/выкл.
|
||||||
- Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки.
|
- Фронт: таблицы пользователей/ролей/нод/приложений (обычные `<table>`, без TanStack Table), журнал
|
||||||
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN.
|
аудита, статистика карточками (без графиков — `recharts` установлен, но не подключён).
|
||||||
|
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами;
|
||||||
|
блокировка гасит VPN. ✅ Достигнуто.
|
||||||
|
|
||||||
## M7 — Telegram-бот ✅
|
## M7 — Telegram-бот ✅
|
||||||
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
|
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
|
||||||
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
|
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
|
||||||
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
|
||||||
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
|
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
|
||||||
- Команды бота: `/start`, меню, «Мои конфиги» (`GetMyConfigsQuery`), `/login`, `/unlink`, `/requests`, `/help`.
|
- Команды бота: `/start` (+ `link_<token>`/`login_<requestId>` deep-link payload), «Мои конфиги»
|
||||||
|
(`/configs`, текстовый список, без ссылок/QR), `/unlink`, `/requests`, `/help`.
|
||||||
- **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env).
|
- **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env).
|
||||||
- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам.
|
- **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам.
|
||||||
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто.
|
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто.
|
||||||
|
|||||||
+57
-29
@@ -13,19 +13,29 @@ health checks, DI. `ThreeXui.Net` таргетит `net10.0` — совпаде
|
|||||||
Альтернативы: Vertical Slice (проще для мелких API, но хуже изолирует домен для растущего продукта) —
|
Альтернативы: Vertical Slice (проще для мелких API, но хуже изолирует домен для растущего продукта) —
|
||||||
можно комбинировать: слои + организация Application «по фичам».
|
можно комбинировать: слои + организация Application «по фичам».
|
||||||
|
|
||||||
### CQRS: собственный тонкий диспетчер ✅ (зафиксировано)
|
### CQRS: собственный тонкий диспетчер ✅ (зафиксировано и реализовано)
|
||||||
**Решение принято**: свой `ISender` вместо MediatR (тот с v12 стал платным). ~100 строк:
|
**Решение принято**: свой `ISender` вместо MediatR (тот с v12 стал платным). `ISender.Send()`
|
||||||
`ISender.Send()` резолвит `ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через
|
резолвит `ICommandHandler<,>`/`IQueryHandler<,>` из DI и прогоняет через `IPipelineBehavior<,>`.
|
||||||
`IPipelineBehavior<,>` (валидация → авторизация → транзакция → логирование). Плюсы: нет лицензий и
|
Реализованы три поведения: `ValidationBehavior` (FluentValidation), `LoggingBehavior`,
|
||||||
внешних зависимостей, полный контроль. Доменные события — свой `IDomainEventHandler<T>` +
|
`UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Плюсы: нет лицензий и внешних
|
||||||
диспетчеризация после `SaveChanges`. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели).
|
зависимостей, полный контроль. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели).
|
||||||
|
|
||||||
|
**Отличие от исходного плана**: отдельного диспетчера доменных событий (`IDomainEventHandler<T>`) в
|
||||||
|
итоге не заводили — оказалось, что для текущего размера проекта прямые вызовы `IRealtimeNotifier`/
|
||||||
|
`ITelegramNotifier` и запись `AuditLog` прямо в хендлере команды читаются проще, чем публикация
|
||||||
|
события и поиск обработчика где-то ещё (см. [domain-model.md](domain-model.md#уведомления-и-аудит-без-диспетчера-доменных-событий)).
|
||||||
|
Также нет отдельного `AuthorizationBehavior` — роль проверяется на уровне эндпоинта
|
||||||
|
(`RequireAuthorization(...)`), а более тонкие проверки (владение, активация) — в самом хендлере.
|
||||||
|
|
||||||
### Валидация: FluentValidation
|
### Валидация: FluentValidation
|
||||||
Декларативные валидаторы на команды/запросы, подключаются через `ValidationBehavior`.
|
Декларативные валидаторы на команды/запросы, подключаются через `ValidationBehavior`. Заводится не
|
||||||
|
для каждой команды — только там, где есть что проверить помимo типов (например, у команд без
|
||||||
|
пользовательского ввода валидатора нет).
|
||||||
|
|
||||||
### Маппинг: Mapster
|
### Маппинг: вручную, без Mapster
|
||||||
Быстрый, без коммерческой лицензии (в отличие от AutoMapper, тоже ставшего платным), кодогенерация.
|
В исходном плане был Mapster — на практике для такого числа полей ручной статический метод
|
||||||
Для простых проекций — ручной `Select` в DTO без маппера.
|
`XxxDto.FromDomain(entity)` на самом DTO читается не хуже конфига маппера и не добавляет
|
||||||
|
зависимость. `Mapster` в проект так и не попал.
|
||||||
|
|
||||||
### ORM: EF Core 10 + Npgsql
|
### ORM: EF Core 10 + Npgsql
|
||||||
Миграции, LINQ, `IEntityTypeConfiguration`. Провайдер PostgreSQL — Npgsql.
|
Миграции, LINQ, `IEntityTypeConfiguration`. Провайдер PostgreSQL — Npgsql.
|
||||||
@@ -68,16 +78,23 @@ MVP (не нужен публичный webhook, проще в одиночно
|
|||||||
Явные ошибки вместо исключений для управляемых сценариев; исключения — только для действительно исключительного.
|
Явные ошибки вместо исключений для управляемых сценариев; исключения — только для действительно исключительного.
|
||||||
|
|
||||||
### Логирование: Serilog ✅ (зафиксировано)
|
### Логирование: Serilog ✅ (зафиксировано)
|
||||||
**Решение принято**: структурное логирование — **Serilog** (`Serilog.AspNetCore`). Настройка через
|
**Решение принято**: структурное логирование — **Serilog** (`Serilog.AspNetCore`), настройка через
|
||||||
`appsettings`/env, обогащение контекста (`UserId`/`NodeId`/`ConfigId`/`CorrelationId`), секреты не
|
`appsettings`/env, `UseSerilogRequestLogging()` + `Enrich.FromLogContext()`. Синк MVP — Console.
|
||||||
логируются. Синки MVP: Console (JSON в проде) + rolling file; Seq/OTel-экспорт — опционально позже.
|
Секреты (пароли, JWT, `BotToken`) в логи не попадают. **Не реализовано**: явное обогащение контекста
|
||||||
Наблюдаемость сверх логов (OpenTelemetry-трейсинг, метрики) — вне MVP.
|
полями `UserId`/`NodeId`/`ConfigId`, сквозной `CorrelationId`, rolling file/Seq/OTel-экспорт — было в
|
||||||
|
исходном плане, осталось в backlog. Сегодня для расследования инцидента доступны только то, что даёт
|
||||||
|
`Enrich.FromLogContext()` + запрос/ответ из request-логирования.
|
||||||
|
|
||||||
### API-документация: Swashbuckle (OpenAPI) + Scalar UI
|
### API-документация: нативный OpenAPI (`Microsoft.AspNetCore.OpenApi`) + Scalar UI
|
||||||
Схема OpenAPI используется фронтом для кодогенерации типов. Scalar — современный UI вместо Swagger UI.
|
`AddOpenApi()`/`MapOpenApi()` — встроенная в ASP.NET Core (.NET 9+) генерация схемы, без Swashbuckle.
|
||||||
|
`/openapi/v1.json` используется фронтом для `pnpm gen:api` (openapi-typescript). `/scalar` — Scalar UI
|
||||||
|
вместо Swagger UI. Каждый эндпоинт аннотирован `.Produces<T>()`, чтобы схема полностью описывала
|
||||||
|
и тела запросов, и тела ответов.
|
||||||
|
|
||||||
### Тесты: xUnit + FluentAssertions + NSubstitute + Testcontainers
|
### Тесты: xUnit + NSubstitute + Testcontainers
|
||||||
Юнит-тесты домена/хендлеров (моками портов), интеграционные — с реальным PostgreSQL в Testcontainers.
|
Юнит-тесты домена/хендлеров (без FluentAssertions — обычные `Assert.*` из xUnit хватает для
|
||||||
|
используемых проверок), интеграционные — с реальным PostgreSQL в Testcontainers
|
||||||
|
(`Testcontainers.PostgreSql` + `WebApplicationFactory<Program>`).
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
@@ -104,11 +121,16 @@ MVP (не нужен публичный webhook, проще в одиночно
|
|||||||
### Realtime: @microsoft/signalr
|
### Realtime: @microsoft/signalr
|
||||||
Официальный клиент SignalR; подписки на события хаба обновляют кэш TanStack Query.
|
Официальный клиент SignalR; подписки на события хаба обновляют кэш TanStack Query.
|
||||||
|
|
||||||
### Типы API: OpenAPI codegen (openapi-typescript / orval)
|
### Типы API: openapi-typescript ✅ (зафиксировано)
|
||||||
Типы (и, опц., хуки) генерируются из OpenAPI-схемы бэкенда — single source of truth, никакого дрейфа контрактов.
|
`pnpm gen:api` гоняет `openapi-typescript` по `/openapi/v1.json` живого бэкенда →
|
||||||
|
`shared/api/schema.gen.ts`. На практике фичи импортируют типы из руками написанного
|
||||||
|
`shared/api/types.ts` (см. [frontend.md](frontend.md)) — он логически совпадает со сгенерированной
|
||||||
|
схемой (сверено), но даёт нормальные generic (`PagedList<T>`) и понятные имена, которых нет в JSON
|
||||||
|
Schema. `orval` рассматривался как альтернатива (codegen хуков), не использовался.
|
||||||
|
|
||||||
### Графики: Recharts
|
### Графики: Recharts (установлен, графики не построены)
|
||||||
Декларативные графики трафика/статистики. QR-коды конфигов — `qrcode.react`.
|
Библиотека в зависимостях фронта на будущее — в MVP админская статистика показана карточками с
|
||||||
|
цифрами, без графиков. QR-коды конфигов — `qrcode.react` (реально используется).
|
||||||
|
|
||||||
### i18n: react-i18next, RU + EN ✅ (зафиксировано)
|
### i18n: react-i18next, RU + EN ✅ (зафиксировано)
|
||||||
**Решение принято**: локализация с первого дня, языки **RU + EN** (RU по умолчанию). Тексты — через
|
**Решение принято**: локализация с первого дня, языки **RU + EN** (RU по умолчанию). Тексты — через
|
||||||
@@ -167,16 +189,22 @@ MVP (не нужен публичный webhook, проще в одиночно
|
|||||||
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
|
| Ротация конфига | `Rotate()` — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
|
||||||
| Лимит устройств | Per-config, задаёт юзер (`DeviceLimit` → `limitIp` в 3x-ui; 0 = без лимита) |
|
| Лимит устройств | Per-config, задаёт юзер (`DeviceLimit` → `limitIp` в 3x-ui; 0 = без лимита) |
|
||||||
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
|
| Метка конфига | `Label` — пользователь именует конфиг («Мой телефон») |
|
||||||
| Самоудаление аккаунта | Разрешено: отзыв всех конфигов + удаление данных, аудит анонимизируется |
|
| Самоудаление аккаунта | Разрешено: отзыв всех активных конфигов в 3x-ui + удаление `AppUser`. `AuditLog` уже хранит только `Guid` без PII — отдельной анонимизации задним числом нет, сам факт удаления в аудит тоже не пишется |
|
||||||
| Версионирование API | Без версий в MVP (`/api` без `v1`) |
|
| Версионирование API | Без версий в MVP (`/api` без `v1`) |
|
||||||
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
|
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
|
||||||
| Тема сайта | Светлая + тёмная (+ системная); Tailwind `dark`, выбор в localStorage |
|
| Тема сайта | Светлая + тёмная (+ системная); Tailwind `dark`, выбор в localStorage |
|
||||||
| Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС); стартовый сид из `seed/client-apps.json` |
|
| Инструкции/приложения | Отдельная страница инструкций + каталог `ClientApp` (админ CRUD, юзер — по ОС); стартовый сид из `seed/client-apps.json` |
|
||||||
| Реконсиляция с 3x-ui | На синхронизации сверяем проекцию с панелью, помечаем дрейф, не «воскрешаем» молча |
|
| Реконсиляция с 3x-ui | Не реализована активно — `TrafficSyncService` молча пропускает ноду/клиента при недоступности или несовпадении, без пометки дрейфа (см. [architecture.md](architecture.md)) |
|
||||||
|
|
||||||
Также заложены: CSRF-защита refresh-cookie + Identity lockout; проверка квоты в транзакции; схема
|
Также реализовано: Identity lockout по неудачным входам; проверка квоты под `pg_advisory_xact_lock`;
|
||||||
`ClientEmail = pnv_{userIdShort}_{rand}`; блокировка удаления ноды при наличии конфигов.
|
схема `ClientEmail = pnv_{userIdShort}_{rand}`. Явного анти-CSRF токена на refresh-cookie нет (см.
|
||||||
|
[architecture.md](architecture.md#безопасность) — обоснование, почему `SameSite=Strict` + `HttpOnly`
|
||||||
|
достаточно при мутациях только по Bearer-токену). Удаление ноды с активными конфигами **не
|
||||||
|
блокируется** — это известный пробел, не защита: `DeleteNodeCommandHandler` каскадно удаляет
|
||||||
|
инбаунды ноды без проверки существующих `VpnConfig`.
|
||||||
|
|
||||||
Осталось выбрать позже (не блокирует старт): значение TTL для истории трафика; конкретные синки
|
Не реализовано (осталось на будущее, не блокирует текущую работу): TTL для истории трафика (сейчас
|
||||||
Serilog для прод (файл/Seq/OTel); точные TTL токенов Telegram. Email/SMTP в проекте **не используются**
|
`TrafficRetentionService` работает, но точный порог не вынесен в решение — см. код); прод-синки
|
||||||
(вход по username, восстановление — через Telegram/админа).
|
Serilog (файл/Seq/OTel) и структурное обогащение логов (`UserId`/`CorrelationId`); точные TTL
|
||||||
|
токенов Telegram. Email/SMTP в проекте **не используются** (вход по username, восстановление — через
|
||||||
|
Telegram/админа).
|
||||||
|
|||||||
+119
-96
@@ -1,151 +1,174 @@
|
|||||||
# Telegram Bot
|
# Telegram Bot
|
||||||
|
|
||||||
Telegram-бот — **второй канал доставки** (presentation-адаптер) поверх той же Application-логики,
|
Telegram-бот — **второй канал доставки** (presentation-адаптер) поверх той же Application-логики,
|
||||||
что и REST API. Он не содержит бизнес-правил: команды бота вызывают те же CQRS-команды/запросы
|
что и REST API. Он не содержит бизнес-правил: обработчики апдейтов вызывают те же CQRS-команды/запросы
|
||||||
(`ICommand/IQuery`), что и веб. Бизнес-инварианты живут в домене, а не в обработчиках бота.
|
(`ICommand`/`IQuery` через собственный `ISender`), что и веб. Бизнес-инварианты живут в домене.
|
||||||
|
|
||||||
## Возможности
|
## Возможности (реализовано)
|
||||||
|
|
||||||
1. **Ссылка на сайт** — кнопка/команда, открывающая веб-панель (при желании — с одноразовым
|
1. **Мои конфиги** — `/configs` присылает текстовый список (метка/локация, протокол, статус) —
|
||||||
deep-link авто-входом для уже привязанного пользователя).
|
**без ссылок и QR** в самом боте; за ссылкой/QR пользователь идёт на сайт. Доступно только
|
||||||
2. **Мои конфиги** — список VPN-конфигов пользователя (протокол, локация, трафик, срок, статус),
|
привязанному аккаунту.
|
||||||
ссылка-подписка и QR по каждому. Доступно только привязанному аккаунту.
|
2. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: инициируется на сайте,
|
||||||
3. **Авторизация через Telegram (passwordless)** — вход на сайт без пароля: подтверждение входа
|
подтверждается в боте кнопками «Подтвердить/Отклонить». Требует предварительной привязки Telegram.
|
||||||
в боте. Требует предварительной **привязки Telegram** к аккаунту.
|
3. **Админ: обработка запросов активации** — админ (по Telegram id из `Telegram__AdminTelegramUserIds`)
|
||||||
4. **Админ: обработка запросов активации** — админ (по Telegram id из env) получает уведомление
|
получает сообщение о каждом запросе активации с именем и комментарием заявителя, жмёт
|
||||||
о запросе активации с комментарием заявителя и жмёт «Активировать / Отклонить» прямо в боте.
|
«✅ Активировать / ❌ Отклонить» прямо в сообщении. `/requests` показывает все ожидающие запросы по требованию.
|
||||||
5. **DM-уведомления пользователю** — если Telegram привязан, бот шлёт личные уведомления о ключевых
|
4. **DM-уведомления пользователю** (если Telegram привязан): активация аккаунта, блокировка,
|
||||||
событиях: «аккаунт активирован», «конфиг отозван админом», «вы заблокированы».
|
принудительный отзыв конфига админом.
|
||||||
6. **Восстановление пароля** — реализовано через passwordless-вход: привязанный пользователь входит
|
5. **Отвязка** — `/unlink`.
|
||||||
через бота (`/login`) и меняет пароль в настройках (`ChangePasswordCommand`). Без привязки
|
|
||||||
Telegram сброс делает только админ (`ResetUserPasswordCommand`). Отдельная команда бота
|
|
||||||
`/resetpassword` с одноразовой ссылкой на смену пароля — **backlog**, в MVP не реализована.
|
|
||||||
|
|
||||||
> **Скоуп бота в MVP — просмотр (read-only) по конфигам.** Создание/ротация/отзыв конфигов — только
|
**Не реализовано / backlog:**
|
||||||
> на сайте. Полное самообслуживание в боте (создание/отзыв) — в backlog.
|
- Ссылки/QR/подписка в самом боте (только текстовый список конфигов).
|
||||||
|
- Отдельная команда `/resetpassword` с одноразовой ссылкой — восстановление пароля сейчас идёт
|
||||||
|
только через обычный passwordless-вход (`/start login_<n>`) + смену пароля в настройках на сайте.
|
||||||
|
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
||||||
|
- Регистрация нового аккаунта из бота (только привязка существующего).
|
||||||
|
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам.
|
||||||
|
|
||||||
## Размещение в архитектуре
|
## Размещение в архитектуре
|
||||||
|
|
||||||
- Бот работает **в том же процессе**, что и API, как `BackgroundService`
|
- Бот работает **в том же процессе**, что и API, как `BackgroundService` (`TelegramBotHostedService`,
|
||||||
(`TelegramBotHostedService`) — это укладывается в требование «фронт+бек в одном контейнере».
|
`PnvPanel.Api/Telegram/`) — условие «фронт+бек в одном контейнере».
|
||||||
- Транспорт с Telegram: **long polling** для MVP (не требует публичного webhook-URL, проще в
|
- Транспорт — **только long polling** (`ITelegramBotClient.ReceiveAsync`). Webhook рассматривался на
|
||||||
одиночном контейнере). Webhook — опциональная альтернатива для прод-нагрузки (тогда — секретный
|
этапе планирования, но не реализован: `TelegramOptions` (`Infrastructure/Telegram/TelegramOptions.cs`)
|
||||||
токен заголовка для верификации).
|
содержит только `BotToken`, `BotUsername`, `PublicSiteUrl`, `AdminTelegramUserIds` — полей
|
||||||
- Библиотека — **Telegram.Bot** (де-факто стандарт для C#).
|
`Mode`/`WebhookUrl`/`WebhookSecret` в коде нет.
|
||||||
- Код бота лежит в `PnvPanel.Api/Telegram/` (хендлеры апдейтов, построители клавиатур,
|
- Библиотека — **Telegram.Bot**. Каждый апдейт обрабатывается в своём DI-scope (`PnvBotUpdateHandler`,
|
||||||
форматтеры сообщений). Обращения к домену — **только** через собственный `ISender`.
|
как HTTP-запрос — свежие scoped-сервисы на апдейт).
|
||||||
`Telegram.Bot` не проникает в Application/Domain.
|
- Обращения к домену — **только** через `ISender`. `Telegram.Bot` не проникает в Application/Domain.
|
||||||
|
- Если `Telegram:BotToken` не задан — `TelegramBotHostedService.ExecuteAsync` сразу возвращается,
|
||||||
|
бот не стартует, панель работает без него (лог `Telegram__BotToken не задан — бот не стартует.`).
|
||||||
|
|
||||||
```
|
```
|
||||||
Telegram ──updates──► TelegramBotHostedService (Api)
|
Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandler (Api/Telegram/)
|
||||||
│ ISender.Send(command/query) // свой диспетчер
|
│ ISender.Send(command/query) // свой диспетчер, свой DI-scope на апдейт
|
||||||
▼
|
▼
|
||||||
Application (те же хендлеры, что и REST)
|
Application (те же хендлеры, что и REST)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Модель данных (добавления)
|
## Модель данных (добавления)
|
||||||
|
|
||||||
- `AppUser.TelegramUserId : long?` — id пользователя Telegram (уникальный, nullable до привязки).
|
- `AppUser.TelegramUserId : long?`, `TelegramUsername : string?`, `TelegramLinkedAt : DateTimeOffset?`.
|
||||||
- `AppUser.TelegramUsername : string?`, `AppUser.TelegramLinkedAt : DateTimeOffset?`.
|
|
||||||
- `TelegramLinkToken` — короткоживущий одноразовый токен привязки (`token`, `userId`, `expiresAt`, `consumedAt`).
|
- `TelegramLinkToken` — короткоживущий одноразовый токен привязки (`token`, `userId`, `expiresAt`, `consumedAt`).
|
||||||
- `TelegramLoginRequest` — запрос passwordless-входа: `id/nonce`, `status`
|
- `TelegramLoginRequest` — запрос passwordless-входа: `id`, `status`
|
||||||
(`Pending/Approved/Rejected/Expired/Consumed`), `userId?` (после подтверждения), `createdAt`, `expiresAt`.
|
(`Pending/Approved/Rejected/Expired/Consumed`), `userId?` (после подтверждения), `context?`
|
||||||
|
(IP инициатора, собирается, но **в текст подтверждения в боте не выводится** — известный TODO),
|
||||||
|
`createdAt`, `expiresAt`.
|
||||||
|
|
||||||
Подробности полей — в [domain-model.md](domain-model.md).
|
Подробности полей — в [domain-model.md](domain-model.md).
|
||||||
|
|
||||||
## Флоу 1 — Привязка Telegram к аккаунту
|
## Флоу 1 — Привязка Telegram к аккаунту
|
||||||
|
|
||||||
Предусловие: пользователь уже вошёл на сайте (изначально аккаунт создаётся с username+пароль).
|
Предусловие: пользователь уже вошёл на сайте.
|
||||||
|
|
||||||
1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token` → `{ deepLink }`
|
1. На сайте «Привязать Telegram» → `POST /api/auth/telegram/link-token` → `{ deepLink, expiresAt }`,
|
||||||
вида `https://t.me/<bot>?start=link_<token>` (+ QR). Токен короткоживущий, одноразовый.
|
`deepLink` вида `https://t.me/<bot>?start=link_<token>`. Фронт рисует QR из `deepLink` сам
|
||||||
|
(`qrcode.react`) — бэкенд картинку не генерирует.
|
||||||
2. Пользователь открывает бота по ссылке → `/start link_<token>`.
|
2. Пользователь открывает бота по ссылке → `/start link_<token>`.
|
||||||
3. Бот берёт `from.id` (Telegram user id), валидирует токен (`LinkTelegramCommand`), проставляет
|
3. Бот берёт `from.id`, вызывает `LinkTelegramCommand(token, telegramUserId, telegramUsername)` —
|
||||||
`TelegramUserId`/`TelegramUsername`/`TelegramLinkedAt`, гасит токен.
|
валидирует токен, проставляет `TelegramUserId`/`TelegramUsername`/`TelegramLinkedAt`, гасит токен.
|
||||||
4. Бот подтверждает: «Аккаунт привязан». Сайт узнаёт об успехе (поллинг статуса или SignalR).
|
4. Бот отвечает «✅ Telegram успешно привязан к вашему аккаунту» (или текст ошибки). Сайт узнаёт об
|
||||||
|
успехе поллингом статуса активации/профиля.
|
||||||
|
|
||||||
Инварианты: один `TelegramUserId` ↔ один аккаунт; повторная привязка требует отвязки; токен
|
Инварианты: один `TelegramUserId` ↔ один аккаунт; токен одноразовый и истекает.
|
||||||
нельзя переиспользовать и он истекает.
|
|
||||||
|
|
||||||
## Флоу 2 — Passwordless-вход через бота
|
## Флоу 2 — Passwordless-вход через бота
|
||||||
|
|
||||||
Предусловие: Telegram уже привязан к аккаунту.
|
Предусловие: Telegram уже привязан к аккаунту.
|
||||||
|
|
||||||
1. На сайте «Войти через Telegram» → `POST /api/auth/telegram/login-request` →
|
1. На сайте «Войти через Telegram» → `POST /api/auth/telegram/login-request` →
|
||||||
`{ requestId, deepLink, qr, expiresAt }`. Сайт начинает ждать результат (поллинг
|
`{ requestId, deepLink, expiresAt }`. Сайт начинает поллить
|
||||||
`GET /api/auth/telegram/login-request/{requestId}` или событие SignalR).
|
`GET /api/auth/telegram/login-request/{requestId}`.
|
||||||
2. Пользователь открывает `https://t.me/<bot>?start=login_<nonce>` → бот по `from.id` находит
|
2. Пользователь открывает `https://t.me/<bot>?start=login_<requestId>` → бот по `from.id` находит
|
||||||
привязанный аккаунт и показывает inline-кнопки **«Подтвердить вход / Отклонить»**
|
привязанный аккаунт (если не найден — просит сначала привязать) и показывает сообщение
|
||||||
(с деталями: время, IP/устройство инициатора — для защиты от фишинга).
|
«Кто-то пытается войти в PnvPanel через ваш аккаунт. Подтвердить вход?» с инлайн-кнопками
|
||||||
3. Подтверждение (`ApproveTelegramLoginCommand`) → запрос переходит в `Approved`, привязывается к `userId`.
|
**«✅ Подтвердить вход» / «❌ Отклонить»**.
|
||||||
4. Сайт (по поллингу/SignalR) получает результат: бэкенд выпускает **стандартные JWT** —
|
3. Подтверждение → `ApproveTelegramLoginCommand`/`RejectTelegramLoginCommand` → запрос переходит в
|
||||||
access в теле ответа, refresh в httpOnly cookie. Запрос помечается `Consumed`.
|
`Approved`/`Rejected`.
|
||||||
|
4. Сайт по следующему поллингу получает результат: при `Approved` — `accessToken` в теле,
|
||||||
|
`refresh` уже пришёл в httpOnly cookie (та же логика cookie, что и обычный логин, включая
|
||||||
|
`Secure = request.IsHttps`). Запрос помечается `Consumed`.
|
||||||
|
|
||||||
Если Telegram **не привязан** — passwordless-вход невозможен (бот предлагает сперва привязать
|
Если Telegram **не привязан** — бот сразу сообщает «Сначала привяжите Telegram к аккаунту на сайте»,
|
||||||
аккаунт). Регистрация целиком через Telegram — вне MVP (см. backlog).
|
подтвердить вход невозможно. Регистрация целиком через Telegram — вне MVP.
|
||||||
|
|
||||||
## Флоу 3 — Просмотр конфигов в боте
|
## Флоу 3 — Просмотр конфигов в боте
|
||||||
|
|
||||||
1. Привязанный пользователь: `/configs` или кнопка «Мои конфиги».
|
1. Привязанный пользователь: `/configs`.
|
||||||
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от имени `AppUser`, найденного по `TelegramUserId`.
|
2. Бот вызывает `GetMyConfigsQuery` (тот же, что и веб) от пользователя, найденного по `TelegramUserId`.
|
||||||
3. Ответ — список с трафиком/сроком/статусом; по каждому конфигу — inline-кнопки «Ссылка», «QR».
|
3. Ответ — обычное текстовое сообщение, по строке на конфиг:
|
||||||
QR отдаётся как изображение (генерация на сервере).
|
`• {Label ?? Location} ({Protocol}) — {Status}`. Если конфигов нет — «У вас пока нет конфигов.»
|
||||||
|
**Ссылок, QR и кнопок здесь нет** — за подключением пользователь идёт на сайт.
|
||||||
|
|
||||||
## Флоу 4 — Обработка активации админом в боте
|
## Флоу 4 — Обработка активации админом в боте
|
||||||
|
|
||||||
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`);
|
1. Пользователь отправляет запрос активации (сайт: `POST /api/activation/request { comment }`) →
|
||||||
доменное событие `ActivationRequested`.
|
`RequestActivationCommandHandler` шлёт SignalR `activationRequested` группе `admins` **и** вызывает
|
||||||
2. Бот шлёт сообщение каждому админу (Telegram id из `AdminSeed__TelegramUserIds`) с username/комментарием
|
`ITelegramNotifier.NotifyAdminsActivationRequestedAsync` (прямой вызов из хендлера, без диспетчера событий).
|
||||||
заявителя и inline-кнопками **«✅ Активировать / ❌ Отклонить»**.
|
2. Каждому админу (по `Telegram__AdminTelegramUserIds`) уходит сообщение с именем и комментарием
|
||||||
3. Нажатие → `ApproveActivationCommand`/`RejectActivationCommand` (те же, что на сайте) → пользователь
|
заявителя и кнопками **«✅ Активировать / ❌ Отклонить»**.
|
||||||
активируется, ему уходит realtime-пуш `userActivated`, админам обновляется сообщение (решение зафиксировано).
|
3. Нажатие → `ApproveActivationCommand`/`RejectActivationCommand` (те же, что на сайте) →
|
||||||
4. Действие доступно только Telegram id из списка админов; проверка — на стороне бота перед вызовом команды.
|
пользователь активируется/отклоняется, ему уходит realtime `userActivated` (только при одобрении) +
|
||||||
|
Telegram-DM, если привязан; нажавшему админу приходит короткое подтверждение («✅ Пользователь
|
||||||
|
активирован.»/«❌ Запрос отклонён.») — само сообщение с кнопками не редактируется.
|
||||||
|
4. Проверка прав — на стороне бота (`TrySetAdminCurrentUserAsync`) перед вызовом команды: Telegram id
|
||||||
|
должен быть в `Telegram__AdminTelegramUserIds`.
|
||||||
|
|
||||||
|
`/requests` — тот же список запросов по требованию (до 10 штук, `Pending`), с теми же кнопками; для
|
||||||
|
кого он доступен — та же проверка админ-id.
|
||||||
|
|
||||||
## Команды и клавиатуры
|
## Команды и клавиатуры
|
||||||
|
|
||||||
| Команда / кнопка | Действие | Требует привязки |
|
| Команда / кнопка | Действие | Требует привязки |
|
||||||
| ---------------------- | -------------------------------------------------------------- | ---------------- |
|
| -------------------------- | -------------------------------------------------------------- | ----------------- |
|
||||||
| `/start` | Приветствие + справка по командам | нет |
|
| `/start` | Приветствие + справка по командам | нет |
|
||||||
| `/start link_<t>` | Привязка аккаунта по токену | нет |
|
| `/start link_<token>` | Привязка аккаунта по токену | нет |
|
||||||
| `/start login_<n>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
| `/start login_<requestId>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
|
||||||
| `/configs` | Список конфигов | да |
|
| `/configs` | Текстовый список конфигов | да |
|
||||||
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
| `/unlink` | Отвязать Telegram от аккаунта | да |
|
||||||
| `/help` | Справка | нет |
|
| `/help` | Справка (то же сообщение, что `/start`) | нет |
|
||||||
| «Активировать/Отклонить» | (admin) решение по запросу активации | админ по env |
|
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||||||
| `/requests` | (admin) список ожидающих запросов активации | админ по env |
|
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
||||||
|
|
||||||
> Passwordless-вход и `/resetpassword` инициируются с сайта (кнопка «Войти через Telegram»),
|
Любой другой текст → «Не понимаю эту команду. /help — список команд.»
|
||||||
> не отдельной командой бота — см. пункт 6 выше про `/resetpassword` (backlog).
|
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- Токены привязки и nonce входа: высокоэнтропийные, **короткоживущие** (≈2–5 мин), **одноразовые**.
|
- Токены привязки и `requestId` входа: высокоэнтропийные, короткоживущие, одноразовые (см.
|
||||||
- Подтверждение входа показывает контекст (время/устройство) — защита от несанкционированных запросов.
|
`TelegramLinkToken`/`TelegramLoginRequest` в [domain-model.md](domain-model.md) — точный TTL не
|
||||||
- Верификация источника апдейтов: webhook — секретный заголовок; long polling — прямой канал к Bot API по TLS.
|
вынесен в отдельное конфигурируемое значение, см. [tech-stack.md](tech-stack.md)).
|
||||||
- Rate-limiting на создание login/link-запросов и на команды бота.
|
- Подтверждение входа **не показывает** контекст (время/IP/устройство) инициатора — поле `Context`
|
||||||
- Токен бота — секрет (env/secret-store), в логи не попадает; апдейты логируются без чувствительных данных.
|
собирается (`CreateLoginRequestCommand`), но в текст сообщения бота не подставляется. Если это
|
||||||
- Passwordless-вход выпускает те же JWT/refresh, что и обычный — единые правила сессий и ротации.
|
важно для защиты от фишинга — доработка на будущее, не текущее поведение.
|
||||||
- Альтернатива боту для веб-входа — официальный **Telegram Login Widget** (HMAC-подпись данных
|
- Транспорт — только long polling: прямой канал к Bot API по TLS, без верификации webhook-заголовка
|
||||||
ботом, верификация на бэке). Оставлено как опция; основной путь — подтверждение в боте.
|
(webhook не реализован).
|
||||||
|
- `Telegram:BotToken` — секрет (env/secret-store), в логи не попадает; при пустом токене
|
||||||
|
`TelegramBotClient` конструируется с синтаксической заглушкой вместо падения при старте — реальный
|
||||||
|
HTTP-вызов всё равно не происходит, т.к. `TelegramBotHostedService` и `TelegramNotifier` сами
|
||||||
|
проверяют `BotToken` перед использованием клиента.
|
||||||
|
- Passwordless-вход выпускает те же JWT/refresh, что и обычный (тот же `AuthResult`, та же cookie-логика).
|
||||||
|
- Явного rate-limit на команды бота нет (в отличие от HTTP-эндпоинтов `/api/auth/*`).
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
|
Реальные поля `TelegramOptions` (секция `Telegram`):
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
"Telegram": {
|
"Telegram": {
|
||||||
"BotToken": "…", // секрет (env/secret-store)
|
"BotToken": "…", // секрет; пусто = бот не стартует
|
||||||
"BotUsername": "PnvPanelBot",
|
"BotUsername": "PnvPanelBot", // для deepLink; null/пусто -> deepLink в ответах API тоже null
|
||||||
"Mode": "LongPolling", // или "Webhook"
|
"AdminTelegramUserIds": "123456789,987654321" // через запятую
|
||||||
"WebhookUrl": null,
|
// "PublicSiteUrl" — поле есть в TelegramOptions, но нигде не читается (мёртвый код,
|
||||||
"WebhookSecret": null,
|
// не задавай его — эффекта не будет)
|
||||||
"PublicSiteUrl": "https://panel.example.com"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Telegram id администраторов задаются отдельно — `AdminSeed__TelegramUserIds` (см.
|
Переменные окружения — `Telegram__BotToken`, `Telegram__BotUsername`, `Telegram__AdminTelegramUserIds`
|
||||||
[`.env.example`](../.env.example)); именно они авторизуют админ-кнопки в боте и получают
|
(см. [`.env.example`](../.env.example)). Именно они авторизуют админ-кнопки в боте и определяют,
|
||||||
уведомления о запросах активации.
|
кому слать уведомления о запросах активации — **не** сидируются в БД и не связаны с учёткой
|
||||||
|
сид-админа (`AdminSeed:*`), это независимый список.
|
||||||
|
|
||||||
Сообщения бота локализованы (**RU/EN**) по языку пользователя, синхронно с настройкой языка в вебе.
|
Сообщения бота **не локализованы** по языку пользователя — все тексты на русском независимо от языка
|
||||||
|
интерфейса на сайте (в отличие от веба, где RU/EN переключаются). Синхронизация языка бота с вебом —
|
||||||
Строго типизированные `IOptions<TelegramOptions>` с валидацией на старте; при отсутствии
|
не реализована, backlog.
|
||||||
`BotToken` бот не стартует (панель работает без него).
|
|
||||||
|
|||||||
+4
-2
@@ -75,7 +75,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
|
|||||||
|
|
||||||
### U2. Пользователь следит за трафиком
|
### U2. Пользователь следит за трафиком
|
||||||
- Фоновая синхронизация тянет трафик из 3x-ui; изменения приходят в UI через SignalR (без перезагрузки).
|
- Фоновая синхронизация тянет трафик из 3x-ui; изменения приходят в UI через SignalR (без перезагрузки).
|
||||||
- При достижении лимита/срока конфиг помечается и (опционально) отключается в 3x-ui.
|
- **В MVP это только отображение**: лимиты по трафику/сроку конфига не реализованы — единственная
|
||||||
|
квота — число активных конфигов на роль. Конфиг живёт, пока его явно не отзовут.
|
||||||
|
|
||||||
### A1. Админ подключает ноду и публикует инбаунды
|
### A1. Админ подключает ноду и публикует инбаунды
|
||||||
1. Вводит адрес панели 3x-ui, логин/пароль (шифруются при хранении).
|
1. Вводит адрес панели 3x-ui, логин/пароль (шифруются при хранении).
|
||||||
@@ -121,7 +122,8 @@ PnvPanel **не заменяет** Xray/3x-ui — он оркестрирует
|
|||||||
|
|
||||||
## Нефункциональные требования
|
## Нефункциональные требования
|
||||||
|
|
||||||
- **Безопасность**: секреты нод шифруются at-rest; JWT с коротким TTL + refresh; rate-limiting на создание конфигов и auth.
|
- **Безопасность**: секреты нод шифруются at-rest; JWT с коротким TTL + refresh; rate-limiting на
|
||||||
|
auth/Telegram-эндпоинты и публичную подписку (создание конфигов им пока не покрыто).
|
||||||
- **Наблюдаемость**: структурные логи (Serilog), health-checks нод, метрики.
|
- **Наблюдаемость**: структурные логи (Serilog), health-checks нод, метрики.
|
||||||
- **Отказоустойчивость к нодам**: недоступность одной ноды не роняет панель; операции идемпотентны где возможно.
|
- **Отказоустойчивость к нодам**: недоступность одной ноды не роняет панель; операции идемпотентны где возможно.
|
||||||
- **Производительность**: списки с пагинацией; синхронизация трафика батчами.
|
- **Производительность**: списки с пагинацией; синхронизация трафика батчами.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
Reference in New Issue
Block a user