Files
PnvPanel/docs/tech-stack.md
T
Leonid Pershin cdd67f8e2b
CI / Backend (build + test) (push) Successful in 1m15s
CI / Frontend (lint + typecheck + build) (push) Successful in 30s
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.
2026-07-02 14:12:50 +03:00

20 KiB
Raw Blame History

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) aspnet runtime запускает Api. Api раздаёт SPA (UseStaticFiles
    • fallback на index.html), фронт и бек — один origin.
  • 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, задаёт юзер (DeviceLimitlimitIp в 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/админа).