- 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.
20 KiB
Tech Stack — решения и обоснование (ADR-lite)
Формат: Решение → короткое обоснование → альтернативы. Отклонения фиксировать здесь же.
Backend
Платформа: .NET 10 + ASP.NET Core Web API
Долгосрочная (LTS-класса) современная платформа, нативная поддержка Minimal API, rate limiting,
health checks, DI. ThreeXui.Net таргетит net10.0 — совпадение целевого фреймворка.
Архитектура: Clean Architecture (4 проекта)
Domain / Application / Infrastructure / Api. Тестируемость, изоляция домена, заменяемость инфраструктуры.
Альтернативы: Vertical Slice (проще для мелких API, но хуже изолирует домен для растущего продукта) —
можно комбинировать: слои + организация Application «по фичам».
CQRS: собственный тонкий диспетчер ✅ (зафиксировано и реализовано)
Решение принято: свой ISender вместо MediatR (тот с v12 стал платным). ISender.Send()
резолвит ICommandHandler<,>/IQueryHandler<,> из DI и прогоняет через IPipelineBehavior<,>.
Реализованы три поведения: ValidationBehavior (FluentValidation), LoggingBehavior,
UnitOfWorkBehavior (транзакция + SaveChangesAsync на команду). Плюсы: нет лицензий и внешних
зависимостей, полный контроль. Отклонены: MediatR (лицензия), FastEndpoints/Wolverine (лишняя связанность/переписывание модели).
Отличие от исходного плана: отдельного диспетчера доменных событий (IDomainEventHandler<T>) в
итоге не заводили — оказалось, что для текущего размера проекта прямые вызовы IRealtimeNotifier/
ITelegramNotifier и запись AuditLog прямо в хендлере команды читаются проще, чем публикация
события и поиск обработчика где-то ещё (см. domain-model.md).
Также нет отдельного AuthorizationBehavior — роль проверяется на уровне эндпоинта
(RequireAuthorization(...)), а более тонкие проверки (владение, активация) — в самом хендлере.
Валидация: FluentValidation
Декларативные валидаторы на команды/запросы, подключаются через ValidationBehavior. Заводится не
для каждой команды — только там, где есть что проверить помимo типов (например, у команд без
пользовательского ввода валидатора нет).
Маппинг: вручную, без Mapster
В исходном плане был Mapster — на практике для такого числа полей ручной статический метод
XxxDto.FromDomain(entity) на самом DTO читается не хуже конфига маппера и не добавляет
зависимость. Mapster в проект так и не попал.
ORM: EF Core 10 + Npgsql
Миграции, LINQ, IEntityTypeConfiguration. Провайдер PostgreSQL — Npgsql.
Запросы-чтения — проекции в DTO (AsNoTracking + Select).
БД: PostgreSQL
Надёжная, богатая по типам (jsonb, массивы), бесплатная. Для истории трафика в будущем — TimescaleDB-расширение.
Auth: ASP.NET Core Identity + JWT
Identity для пользователей/ролей/хэширования; JWT access (короткий TTL) + refresh (httpOnly cookie, ротация). Альтернатива — внешний OIDC (Keycloak/Auth0); отклонено на этом этапе в пользу полного контроля.
RBAC: динамические роли с квотой (AppRole.MaxConfigs)
Роли — стандартный Identity, но AppRole расширен MaxConfigs. Админ создаёт/назначает роли;
доступ к инбаундам — по ролям (Inbound.AllowedRoles). Квота на число конфигов — на роли, а не на Plan.
Активация пользователей
AppUser.IsActivated + ActivationRequest (с комментарием). Неактивированный не создаёт конфиги.
Решение принимает админ на сайте или в Telegram — одними и теми же CQRS-командами.
Сидинг из env
Идемпотентный DbInitializer на старте: системные роли (admin/user), учётка админа и Telegram id
админов — из переменных окружения. Пример — .env.example. Строго типизированные
IOptions<T> с валидацией на старте.
Realtime: SignalR
Нативно для ASP.NET Core, авто-транспорт (WebSocket→SSE→long-poll), группы/пользователи, JWT-авторизация хабов.
Telegram-бот: Telegram.Bot (in-process hosted service)
Де-факто стандартная C#-библиотека. Бот хостится в процессе Api как BackgroundService (условие
единого контейнера) и вызывает те же CQRS-хендлеры, что и REST. Транспорт — long polling для
MVP (не нужен публичный webhook, проще в одиночном контейнере); webhook — опция для прод (с секретным
заголовком). Passwordless-вход выпускает те же JWT/refresh, что и веб. Детали — telegram-bot.md.
Фоновые задачи: BackgroundService + PeriodicTimer (MVP)
Без внешних зависимостей для MVP. При росте (ретраи, расписания, дашборд) — Hangfire или Quartz.NET.
Result-модель: собственный Result<T> (или ErrorOr)
Явные ошибки вместо исключений для управляемых сценариев; исключения — только для действительно исключительного.
Логирование: Serilog ✅ (зафиксировано)
Решение принято: структурное логирование — Serilog (Serilog.AspNetCore), настройка через
appsettings/env, UseSerilogRequestLogging() + Enrich.FromLogContext(). Синк MVP — Console.
Секреты (пароли, JWT, BotToken) в логи не попадают. Не реализовано: явное обогащение контекста
полями UserId/NodeId/ConfigId, сквозной CorrelationId, rolling file/Seq/OTel-экспорт — было в
исходном плане, осталось в backlog. Сегодня для расследования инцидента доступны только то, что даёт
Enrich.FromLogContext() + запрос/ответ из request-логирования.
API-документация: нативный OpenAPI (Microsoft.AspNetCore.OpenApi) + Scalar UI
AddOpenApi()/MapOpenApi() — встроенная в ASP.NET Core (.NET 9+) генерация схемы, без Swashbuckle.
/openapi/v1.json используется фронтом для pnpm gen:api (openapi-typescript). /scalar — Scalar UI
вместо Swagger UI. Каждый эндпоинт аннотирован .Produces<T>(), чтобы схема полностью описывала
и тела запросов, и тела ответов.
Тесты: xUnit + NSubstitute + Testcontainers
Юнит-тесты домена/хендлеров (без FluentAssertions — обычные Assert.* из xUnit хватает для
используемых проверок), интеграционные — с реальным PostgreSQL в Testcontainers
(Testcontainers.PostgreSql + WebApplicationFactory<Program>).
Frontend
React 19 + Vite + TypeScript
Максимальная экосистема, быстрый dev-сервер и сборка Vite, строгая типизация. SPA (не SSR) — для внутренней панели SSR избыточен и усложняет деплой рядом с C# API.
Данные с сервера: TanStack Query
Кэш, инвалидация, фоновые рефетчи, статусы загрузки/ошибок. Идеально для CRUD-панели.
Роутинг: TanStack Router
Типобезопасный роутинг, интеграция с TanStack Query. Альтернатива — React Router 7.
UI: shadcn/ui + Tailwind CSS v4
Копируемые в проект, полностью кастомизируемые компоненты (Radix под капотом), современный вид,
тёмная тема из коробки. Иконки — lucide-react.
Клиентский стейт: Zustand
Лёгкий стор для глобального (авторизация, тема). Серверный стейт — только в TanStack Query.
Формы: react-hook-form + zod
Производительные формы + схемная валидация; те же zod-схемы для типобезопасности API-ответов.
Realtime: @microsoft/signalr
Официальный клиент SignalR; подписки на события хаба обновляют кэш TanStack Query.
Типы API: openapi-typescript ✅ (зафиксировано)
pnpm gen:api гоняет openapi-typescript по /openapi/v1.json живого бэкенда →
shared/api/schema.gen.ts. На практике фичи импортируют типы из руками написанного
shared/api/types.ts (см. frontend.md) — он логически совпадает со сгенерированной
схемой (сверено), но даёт нормальные generic (PagedList<T>) и понятные имена, которых нет в JSON
Schema. orval рассматривался как альтернатива (codegen хуков), не использовался.
Графики: Recharts (установлен, графики не построены)
Библиотека в зависимостях фронта на будущее — в MVP админская статистика показана карточками с
цифрами, без графиков. QR-коды конфигов — qrcode.react (реально используется).
i18n: react-i18next, RU + EN ✅ (зафиксировано)
Решение принято: локализация с первого дня, языки RU + EN (RU по умолчанию). Тексты — через
ключи (react-i18next), не хардкод строк в компонентах.
Инфраструктура
Упаковка: единый образ приложения + PostgreSQL
По требованию — один контейнер на всё приложение (REST + SignalR + Telegram-бот + статика SPA) и отдельный контейнер БД.
- Multi-stage Dockerfile: (1)
nodeсобирает фронт →dist/; (2)dotnet sdkпубликует Api и копирует статику вwwwroot; (3)aspnetruntime запускает Api. Api раздаёт SPA (UseStaticFiles- fallback на
index.html), фронт и бек — один origin.
- fallback на
- docker-compose:
app(единый образ) +db(PostgreSQL) с томом. - Почему не отдельный nginx: единый origin упрощает CORS/куки/деплой и укладывается в требование «фронт+бек в одном контейнере».
- TLS — внешний (решение): HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB) вне
compose;
appотдаёт HTTP и доверяетX-Forwarded-*черезForwardedHeaders. Свой nginx/Caddy не вводим. - Миграции — авто на старте приложения (MVP).
- CI — GitHub Actions, только сборка/тесты:
dotnet build/test,pnpm build/lint/typecheck. Публикация образа и деплой — вручную/позже (в MVP не автоматизируем). - Пакетный менеджер фронта: pnpm (быстрый, экономный по диску).
Принятые решения (по открытым вопросам)
Все ключевые развилки закрыты:
| # | Вопрос | Решение |
|---|---|---|
| 1 | CQRS-медиатор | Собственный тонкий диспетчер (не MediatR) |
| 2 | Ролей у пользователя | Ровно одна роль (квота = MaxConfigs роли) |
| 3 | Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) |
| 4 | Тарифы Plan в MVP |
Backlog — в MVP конфиги без лимитов трафика/срока |
| 5 | i18n | RU + EN с первого дня (react-i18next) |
| 6 | Telegram-транспорт | Long polling |
| 7 | Регистрация через Telegram | Только привязка существующего аккаунта (signup из бота — backlog) |
| 8 | История трафика TrafficSample |
Простая таблица PostgreSQL + TTL (фоновая чистка старше N дней) |
| 9 | Логирование | Serilog (Console + rolling file) |
Продуктовые решения (поведение)
| Тема | Решение |
|---|---|
| Вход | По username (email в системе не используется; SMTP не нужен) |
| Восстановление пароля | Через привязанный Telegram (self-service); без привязки — сброс админом |
| Побуждение привязать TG | Настойчивый баннер/уведомления в UI, пока Telegram не привязан |
| Регистрация | Открытая + гейт активации админом |
| Конфиги в одном инбаунде | Разрешено несколько (ограничение — только общая квота роли) |
| Данные ноды пользователю | Показываем только DisplayName + протокол; адрес/хост/порт скрыты |
| Блокировка пользователя | Отключать все его конфиги в 3x-ui (Disabled); разблокировка — включить обратно |
| Понижение роли | Грандфазеринг: существующие конфиги живут, новые нельзя до входа в квоту |
| Скоуп Telegram-бота (MVP) | Read-only по конфигам (создание/отзыв — на сайте); DM-уведомления юзеру |
| Подписка | Агрегированная на юзера (AppUser.SubscriptionToken) + по конфигу |
| Аудит | AuditLog (append-only): активация, блокировка, смена роли, отзыв, ноды/инбаунды |
| Ротация конфига | Rotate() — перевыпуск UUID/ссылки, квоту не тратит (на случай утечки) |
| Лимит устройств | Per-config, задаёт юзер (DeviceLimit → limitIp в 3x-ui; 0 = без лимита) |
| Метка конфига | Label — пользователь именует конфиг («Мой телефон») |
| Самоудаление аккаунта | Разрешено: отзыв всех активных конфигов в 3x-ui + удаление AppUser. AuditLog уже хранит только Guid без PII — отдельной анонимизации задним числом нет, сам факт удаления в аудит тоже не пишется |
| Версионирование API | Без версий в MVP (/api без v1) |
| Подписка (заголовки) | Subscription-Userinfo (used/total/expire) + profile-update-interval |
| Тема сайта | Светлая + тёмная (+ системная); Tailwind dark, выбор в localStorage |
| Инструкции/приложения | Отдельная страница инструкций + каталог ClientApp (админ CRUD, юзер — по ОС); стартовый сид из seed/client-apps.json |
| Реконсиляция с 3x-ui | Не реализована активно — TrafficSyncService молча пропускает ноду/клиента при недоступности или несовпадении, без пометки дрейфа (см. architecture.md) |
Также реализовано: Identity lockout по неудачным входам; проверка квоты под pg_advisory_xact_lock;
схема ClientEmail = pnv_{userIdShort}_{rand}. Явного анти-CSRF токена на refresh-cookie нет (см.
architecture.md — обоснование, почему SameSite=Strict + HttpOnly
достаточно при мутациях только по Bearer-токену). Удаление ноды с активными конфигами не
блокируется — это известный пробел, не защита: DeleteNodeCommandHandler каскадно удаляет
инбаунды ноды без проверки существующих VpnConfig.
Не реализовано (осталось на будущее, не блокирует текущую работу): TTL для истории трафика (сейчас
TrafficRetentionService работает, но точный порог не вынесен в решение — см. код); прод-синки
Serilog (файл/Seq/OTel) и структурное обогащение логов (UserId/CorrelationId); точные TTL
токенов Telegram. Email/SMTP в проекте не используются (вход по username, восстановление — через
Telegram/админа).