Files
PnvPanel/docs/architecture.md
T
Leonid Pershin fb320fbb31
CI / Backend (build + test) (push) Failing after 55s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s
Update package versions and enhance IXuiPanelGateway interface
- Updated the version of `ThreeXui.Net` to 1.0.2 in `Directory.Packages.props`.
- Enhanced the `IXuiPanelGateway` interface to include detailed documentation on the new `ForcedFingerprint` and `ForcedPacketEncoding` parameters for the `BuildConnectionStringAsync` method, clarifying their roles in client application interactions.
- Refactored `XuiPanelGateway` to implement the new parameters, ensuring compatibility with client requirements for TLS fingerprinting and packet encoding based on transport type.
- Updated architecture documentation to reflect changes in connection string handling and the implications for client applications.
2026-07-23 00:24:02 +03:00

345 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architecture
## Обзор
PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **Clean Architecture** с **CQRS**,
и SPA-фронтенд на **React + Vite**. Backend хранит проекцию домена в **PostgreSQL** и
оркестрирует панели **3x-ui** через библиотеку **ThreeXui.Net**. Живые обновления — по **SignalR**.
```
┌──────────────────────────────────────────────────────────────────────────┐
│ React SPA (Vite + TS) │
│ TanStack Query/Router · shadcn/ui · @microsoft/signalr · zod │
└───────────────┬───────────────────────────────┬──────────────────────────┘
│ REST (JSON, JWT Bearer) │ WebSocket (SignalR)
┌───────────────▼───────────────────────────────▼──────────────────────────┐
│ PnvPanel.Api (Presentation) │
│ Minimal API endpoints · SignalR Hubs · Middleware · DI composition root │
└───────────────┬────────────────────────────────────────────────────────── ┘
│ ICommand / IQuery (свой диспетчер)
┌───────────────▼──────────────────────────────────────────────────────────┐
│ PnvPanel.Application │
│ Command/Query handlers · Validators · DTOs · Ports (interfaces) · │
│ Pipeline behaviors · Result<T> │
└───────────────┬───────────────────────────────┬──────────────────────────┘
│ implements ports │ uses
┌───────────────▼───────────────┐ ┌────────────▼──────────────────────────┐
│ PnvPanel.Infrastructure │ │ PnvPanel.Domain │
│ EF Core (Npgsql) · Identity · │ │ Entities · Value Object · Enums · │
│ JWT · XuiPanelGateway · │◄──┤ Domain Exceptions │
│ Background sync · Telegram │ │ (no external dependencies) │
└───────────────┬────────────────┘ └───────────────────────────────────────┘
(SignalR-хаб/пуш — в PnvPanel.Api, см. ниже)
┌───────────▼──────────┐ ┌──────────────────────────┐
│ PostgreSQL │ │ 3x-ui panels (nodes) │
│ (Npgsql / EF Core) │ │ via ThreeXui.Net (HTTP) │
└──────────────────────┘ └──────────────────────────┘
```
## Слои (Clean Architecture)
Зависимости направлены **внутрь**: `Api → Infrastructure → Application → Domain`.
Внутренние слои не знают о внешних. Инверсия зависимостей — через интерфейсы (порты) в
`Application`, реализуемые в `Infrastructure`.
### 1. `PnvPanel.Domain`
Ядро без внешних зависимостей. Диспетчера доменных событий нет — уведомления и аудит вызываются
напрямую из CQRS-хендлеров (см. [domain-model.md](domain-model.md#уведомления-и-аудит-без-диспетчера-доменных-событий)).
- **Entities**: `Node`, `Inbound`, `VpnConfig`, `ActivationRequest`, `ClientApp`, `AuditLog`,
`TelegramLinkToken`, `TelegramLoginRequest`, `TrafficSample` (см. [domain-model.md](domain-model.md)).
- **Value Objects**: `NodeCredentials` (логин + зашифрованный пароль ноды) — единственный VO;
connection string строит `IXuiPanelGateway` на лету, лимиты трафика не реализованы.
- **Enums**: `VpnProtocol`, `ConfigStatus`, `NodeStatus`, `ActivationStatus`, `AuditSource`,
`TelegramLoginStatus`, `OsPlatform`.
- **Domain Exceptions**: `DomainException` — брошенный при нарушении инварианта в самой сущности
(например, `Revoke()` уже отозванного конфига); хендлеры такие нарушения не ожидают в норме.
- Инварианты и бизнес-правила инкапсулированы в сущностях (rich domain model: приватные сеттеры,
фабричные методы, поведенческие методы), а не в хендлерах.
> `AppUser`/`AppRole` (Identity) живут в `Infrastructure` (наследуют `IdentityUser<Guid>`/
> `IdentityRole<Guid>`), а домен ссылается на пользователя/роль только по `Guid`, чтобы не тащить
> Identity в ядро.
### 2. `PnvPanel.Application`
Сценарии приложения через CQRS.
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы),
организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`, `IFileStorage`
(вложения тикетов поддержки — диск в контейнере, см. `Infrastructure/Storage/DiskFileStorage`).
- **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
[backend-conventions.md](backend-conventions.md)).
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
DTO, без маппера (Mapster/AutoMapper).
- **Pipeline behaviors** (порядок: Logging → Validation → RequireActivation → UnitOfWork):
`LoggingBehavior`, `ValidationBehavior`, `RequireActivationBehavior` (403 `Auth.NotActivated` для
запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений, тикеты поддержки),
`UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет —
роль проверяется через `RequireAuthorization()`/`RequireRole(...)` на эндпоинте либо явной проверкой
в начале хендлера (например, «инбаунд доступен роли пользователя»).
- **Result<T>**: явная модель успеха/ошибки (`Result`/`Result<T>`, `Error` с `ErrorType`) вместо
исключений для управляемых сценариев.
### 3. `PnvPanel.Infrastructure`
Технические детали и реализации портов.
- **Persistence**: `AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>`, реализует `IAppDbContext`;
`IEntityTypeConfiguration<T>` для маппингов; миграции EF Core. Репозиториев нет — хендлеры работают
через `IAppDbContext` напрямую (`DbSet<T>` + LINQ).
- **Identity & Auth**: ASP.NET Core Identity, `JwtTokenService` (access + refresh), `RefreshTokenService`
(хранение/ротация/отзыв refresh-токенов), `RoleService`, `DbInitializer` (сидинг).
- **3x-ui интеграция**: `XuiPanelGateway : IXuiPanelGateway` поверх `ThreeXui.Net`; кэш клиентов per-node
внутри самого гейтвея (см. ниже — отдельного класса-фабрики нет).
- **Background jobs**: `TrafficSyncService`, `NodeHealthCheckService`, `TrafficRetentionService`,
`BillingService` (`BackgroundService` + `PeriodicTimer`).
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
- **Storage**: `DiskFileStorage : IFileStorage` — вложения тикетов поддержки, файлы на диске под
GUID-именем (`FileStorage:RootPath`, том `ticket_uploads` в docker-compose, как `dp_keys`).
> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.**
> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext<PanelHub>`, а сам `PanelHub`
> определён там же — не было смысла тащить эту связку через слой. `Application` всё равно видит
> только порт `IRealtimeNotifier`, так что граница зависимостей не нарушена.
### 4. `PnvPanel.Api` (Presentation)
Композиционный корень и транспорт.
- **Minimal API**-эндпоинты, сгруппированные по фичам — файлы в `Endpoints/`
(`AuthEndpoints`, `ActivationEndpoints`, `ConfigEndpoints`, `AppEndpoints`, `SubscriptionEndpoints`,
`TelegramEndpoints`, `AdminUserEndpoints`, `AdminAppEndpoints`, `AdminStatsEndpoints`, `NodeEndpoints`,
`InboundEndpoints`, `RoleEndpoints`, `SupportEndpoints`, `AdminSupportEndpoints`); полный список
маршрутов — [api-design.md](api-design.md). `SupportEndpoints`/`AdminSupportEndpoints` — первые в
проекте с `multipart/form-data` (`[FromForm]` + `IFormFileCollection`, `.DisableAntiforgery()`
антифоржери-мидлварь в пайплайне не подключена, но ASP.NET Core минимал-API требует явно снять
требование для form-эндпоинтов).
Каждый эндпоинт аннотирован `.Produces<T>()`, чтобы OpenAPI-схема полностью описывала тело ответа
(нужно для `pnpm gen:api` на фронте).
- **SignalR Hubs**: `PanelHub` (`Hubs/`).
- **Telegram-бот**: `TelegramBotHostedService` + `PnvBotUpdateHandler` в `Telegram/` (см. отдельный раздел).
- **Статика SPA**: раздача собранного фронта из `wwwroot` + SPA-fallback (единый контейнер).
- **Ошибки**: встроенные `AddProblemDetails()` + `UseExceptionHandler()` (ASP.NET Core, без кастомного
middleware) конвертируют необработанные исключения в `application/problem+json`.
- **DI**: `AddApplication()` (Application), `AddInfrastructure()` (Infrastructure) + прямая регистрация
в `Program.cs` для того, что специфично Api-слою (SignalR, Telegram-клиент, rate limiting).
- **OpenAPI**: нативный `Microsoft.AspNetCore.OpenApi` (`/openapi/v1.json`) + `Scalar.AspNetCore`
UI (`/scalar`) — без Swashbuckle.
## CQRS
- **Команды** меняют состояние, возвращают `Result` / `Result<T>`; выполняются в транзакции (UnitOfWorkBehavior).
- **Запросы** только читают; могут ходить в БД проекциями (`Select` в DTO) без загрузки сущностей целиком.
- Диспетчер — **собственный тонкий `ISender`**: резолвит хендлер команды/запроса из DI и прогоняет
через pipeline behaviors. Без внешних CQRS-библиотек (MediatR/и т.п.). Абстракции — `ICommand<T>`,
`IQuery<T>`, `ICommandHandler<,>`, `IQueryHandler<,>`, `IPipelineBehavior<,>`.
Пример потока «создать конфиг» (`backend/src/PnvPanel.Application/Configs/Create/CreateVpnConfigCommandHandler.cs`):
```
POST /api/configs
→ CreateVpnConfigCommand (IRequiresActivation)
→ ValidationBehavior (FluentValidation — формат inboundId/label)
→ RequireActivationBehavior (403 Auth.NotActivated, если аккаунт не активирован)
→ CreateVpnConfigCommandHandler
· проверяет роль инбаунда (доменная проверка)
· SELECT pg_advisory_xact_lock(hashtext(userId)) — сериализует параллельные создания
· пересчитывает текущее число активных конфигов и сверяет с AppRole.MaxConfigs
· IXuiPanelGateway.AddClientAsync(node, inbound, ...) // 3x-ui, получает ClientExternalId
· VpnConfig.Create(...) + AssignRemoteClient(id), сохраняет через IAppDbContext
· при сбое SaveChanges после успешного AddClientAsync — компенсация (RemoveClientAsync)
→ UnitOfWorkBehavior (commit транзакции)
→ 200 OK VpnConfigDto { id, label, protocol, location, usedUpBytes, usedDownBytes,
expiresAt, status, createdAt }
```
Ссылка подключения в ответ создания **не входит** — фронт запрашивает её отдельно,
`GET /api/configs/{id}/link`, по кнопке на карточке конфига (см. [api-design.md](api-design.md)).
## Интеграция с 3x-ui (ThreeXui.Net)
`ThreeXui.Net` конфигурируется на **один** `BaseAddress`, а у нас **несколько нод**. Поэтому:
- Порт `IXuiPanelGateway` инкапсулирует все операции с панелями и принимает `Node`:
`ListInboundsAsync`, `AddClientAsync`, `UpdateClientAsync`, `RemoveClientAsync`,
`GetClientTrafficAsync`, `BuildConnectionStringAsync`, `ProbeAsync`, `ValidateBaseAddress`,
`InvalidateClient(nodeId)` (вызывается после смены креденшлов ноды).
- `XuiPanelGateway` — единственная реализация, держит `ConcurrentDictionary<Guid, Lazy<IXuiClient>>`
(ключ — `NodeId`), создавая клиента из расшифрованных `NodeCredentials` лениво при первом обращении
к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`.
- Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу.
- Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды).
- Синхронизация инбаундов ноды (`SyncNodeCommandHandler`) реконсилирует пропажу инбаунда с панели:
без привязанных конфигов запись удаляется, с конфигами — помечается `IsAvailable=false` (детали и
инварианты — [domain-model.md](domain-model.md#inbound--прокси-inbound-на-ноде)).
- `BuildConnectionStringAsync` передаёт в `ThreeXui.Net` (`XuiConnectionStringRequest.ForcedFingerprint:
"firefox"`) принудительный TLS-fingerprint клиента для tls/reality-ссылок vless/trojan/vmess,
независимо от того, что задано в `streamSettings` ноды; shadowsocks (без TLS) и ссылки без security
библиотека не трогает. Для транспорта `xhttp` дополнительно передаётся
`ForcedPacketEncoding: "xudp"` (не 3x-ui-настройка, а требование части клиентских приложений).
Правится в одном месте — применяется и к сайту, и к боту, и к подписке.
- **Дрейф с 3x-ui активно не реконсилируется**: `TrafficSyncService` при недоступной ноде или
при отсутствии клиента в ответе панели (`GetClientTrafficAsync`) просто пропускает его в этом цикле
синхронизации — не помечает конфиг рассинхронизованным и не шлёт алерт. Если клиента удалили прямо
в 3x-ui в обход панели, локальная запись `VpnConfig` продолжит существовать до следующего
явного действия пользователя/админа (`Revoke`/`Rotate`), которое обнаружит несоответствие по ответу
гейтвея. Активной сверки/алертинга по дрейфу нет.
## Telegram-бот (presentation-адаптер)
Бот — **второй канал доставки** поверх той же Application-логики, что и REST API (не содержит
бизнес-правил). Полное описание — в [telegram-bot.md](telegram-bot.md). Ключевое для архитектуры:
- Хостится **в процессе Api** как `BackgroundService` (`TelegramBotHostedService`) — это условие
для упаковки «фронт+бек в одном контейнере». Транспорт — только **long polling**
(`ITelegramBotClient.ReceiveAsync`); webhook рассматривался, но не реализован — конфигурации
`Telegram:Mode`/`WebhookUrl` в коде нет.
- Обращения к домену — только через собственный `ISender`, теми же командами/запросами, что и веб
(`GetMyConfigsQuery`, `LinkTelegramCommand`, `ApproveTelegramLoginCommand`, ...). `Telegram.Bot`
не проникает в Application/Domain.
- **Passwordless-вход**: бот подтверждает `TelegramLoginRequest`, после чего Api выпускает те же
JWT/refresh, что и обычный логин (единые правила сессий). Требует предварительной привязки Telegram.
## Realtime (SignalR)
- Хаб `PanelHub` (`/hubs/panel`), авторизация по тому же JWT.
- **Группы** (`GroupNames` в `Api/Hubs/PanelHub.cs`): `user:{userId}` (личные события), `admins`
(события нод/системы/активации) — пользователь при подключении добавляется в свою `user:{userId}`
и, если он админ, дополнительно в `admins`.
- **События сервер→клиент**: `configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`,
`activationRequested`, `userActivated`, `newsPublished` — точные payload'ы см.
[api-design.md](api-design.md#signalr--hub-hubspanel). `newsPublished` — единственное
широковещательное событие (`Clients.All`), а не по группе — новости видны всем без исключения.
- Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`, реализация в `Api/Hubs/`),
вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую.
## Фоновые задачи
- **TrafficSyncService** — периодически (`PeriodicTimer`) обходит активные ноды, тянет трафик по
клиентам через `IXuiPanelGateway.GetClientTrafficAsync`, пишет `VpnConfig.UpdateTraffic(...)` и
`TrafficSample`, шлёт `configTrafficUpdated`. Трафик используется только для отображения — лимиты
и автоотключение по превышению не реализованы (см. [domain-model.md](domain-model.md)).
- **NodeHealthCheckService** — health-probe нод (`IXuiPanelGateway.ProbeAsync`), обновляет `NodeStatus`,
шлёт `nodeStatusChanged` группе `admins`.
- **TrafficRetentionService** — чистит `TrafficSample` старше N дней (TTL-ретеншн истории трафика).
- **BillingService** — раз в час обходит пользователей с billing-ролью (`AppRole.BillingEnabled`):
гасит конфиги при просрочке оплаты (`VpnConfig.Suspend()`), шлёт предупреждение за 3 дня до
истечения. Пропускает пользователей с `PaymentRequest` в `AwaitingConfirmation` — конфиги не
гасятся, пока админ не подтвердит/отклонит заявку (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)).
- Реализованы как обычные `BackgroundService` + `PeriodicTimer`, без внешнего джоб-раннера
(см. [tech-stack.md](tech-stack.md)).
## Сидирование и старт
При старте приложения выполняется идемпотентный сидинг (`DbInitializer`), управляемый переменными
окружения (см. [`.env.example`](../.env.example)):
- **Системные роли**: `admin` (без лимита конфигов) и `user` (`MaxConfigs = Roles__DefaultUserMaxConfigs`, по умолчанию 3).
- **Учётка администратора**: создаётся из `AdminSeed__Username` / `AdminSeed__Password`, если ещё нет;
сразу активирована и с ролью `admin`. Seed-админ **не привязывается к Telegram автоматически** —
привязка делается вручную в UI, как у любого пользователя.
- **Каталог приложений** (`ClientApp`): если таблица пуста — сидируется из
[`seed/client-apps.json`](../seed/client-apps.json) (стартовый набор клиентов по ОС). Дальше — правки через админ-CRUD.
Сидинг не перезаписывает существующие данные. Принудительной смены сид-пароля при первом входе
**нет** — задавайте сильный `AdminSeed__Password` сразу; сменить пароль можно в приложении.
Отдельно от сидинга — **Telegram id админов** (`Telegram__AdminTelegramUserIds`, через запятую)
читаются `TelegramOptions` **напрямую при каждой проверке** (не пишутся в БД): именно этот список
авторизует нажатие «Активировать/Отклонить» в боте и определяет, кому слать уведомления о новых
запросах активации.
## RBAC — динамические роли и активация
- `AppRole` расширяет `IdentityRole<Guid>` полем `MaxConfigs`. **У пользователя ровно одна роль**;
квота = `MaxConfigs` его роли (`admin` — без лимита). Админ создаёт роли и меняет роль пользователя.
- **Активация**: `AppUser.IsActivated`; `ActivationRequest` (с комментарием) обрабатывается админом
на сайте (`Approve/Reject`-команды) или в Telegram. `ApproveActivation` ставит `IsActivated = true`
и шлёт realtime-пуш пользователю.
- **Ролевой доступ к инбаундам**: `Inbound.AllowedRoles` (M:N с `AppRole`); при создании конфига
доменный инвариант проверяет активацию, квоту и пересечение роли пользователя с `AllowedRoles`.
- **Понижение роли — грандфазеринг**: смена роли на меньшую квоту разрешена; существующие конфиги
сохраняются, создание новых блокируется до входа в квоту.
- **Блокировка пользователя**: `IsBlocked = true` → вход запрещён + все конфиги `Disabled` (отключение
клиентов в 3x-ui); разблокировка — обратная операция. Пишется в `AuditLog`.
- Уведомления: запросы активации → группа `admins` (SignalR) + Telegram (по
`Telegram__AdminTelegramUserIds`); решения/блокировки → пользователю (SignalR + Telegram-DM, если привязан).
## Безопасность
- **AuthN**: ASP.NET Core Identity + JWT, **вход по `UserName`** (email в системе не используется).
Access-token — короткий TTL (in-memory на клиенте); refresh-token — httpOnly Secure cookie,
ротация при использовании, хранение хэша в БД.
- **Восстановление пароля**: через привязанный Telegram (passwordless-вход → смена пароля); без
привязки — сброс админом (`ResetUserPasswordCommand`). Email/SMTP не используются. Смена пароля
вошедшим — `POST /api/auth/change-password`.
- **AuthZ**: именованных policy нет — либо `.RequireAuthorization()` (любой вошедший) или
`.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin))` на группе эндпоинтов, либо
явная проверка внутри хендлера (владение конфигом — сравнение `VpnConfig.UserId` с `ICurrentUser`).
Активация — не в хендлере, а в pipeline behavior (`RequireActivationBehavior`, срабатывает на
запросах с маркером `IRequiresActivation`: конфиги, новости, каталог приложений) — `AuthErrors.NotActivated`.
- **Секреты нод**: шифруются `ISecretProtector` (Data Protection) перед сохранением; в API/логи не попадают.
- **CSRF**: явного анти-CSRF токена нет — все мутации API читают авторизацию только из
`Authorization: Bearer` (JS должен явно прочитать access-token из памяти и подставить заголовок,
чужой сайт этого сделать не может). Refresh-cookie (`pnv_refresh_token`) — единственное, что браузер
шлёт автоматически; она `HttpOnly`, `SameSite=Strict`, `Path=/api/auth`, и `Secure` выставляется по
`HttpContext.Request.IsHttps` (учитывает `ForwardedHeaders` за прокси) — этого достаточно, т.к. сама
по себе она не даёт мутировать данные, только обменивается на access-token эндпоинтом `/api/auth/refresh`.
- **Brute-force**: Identity lockout по числу неудачных входов; rate-limit на `/auth/*` (настраиваемый
лимит, `RateLimiting:AuthPermitLimit`, по умолчанию 20 запросов/мин).
- **Rate limiting**: встроенный `RateLimiter` .NET, один fixed-window лимит (`RateLimiting:AuthPermitLimit`,
по умолчанию 20/мин) применён к `/api/auth/*`, `/api/auth/telegram/*` и `/sub/{token}`; остальные
эндпоинты (в т.ч. создание конфигов) им не покрыты.
- **Валидация входа**: FluentValidation + жёсткая типизация DTO; ошибки — единый `ProblemDetails`.
- **CORS**: не настроен вообще (`AddCors`/`UseCors` в коде нет) — фронт и бек всегда один origin: в
проде раздаются из одного образа, в dev Vite проксирует `/api`/`/hubs`, так что браузер никогда не
делает кросс-origin запрос. Отдельного allowlist-конфига для CORS сейчас не существует.
- **Аудит**: значимые действия (активация, блокировка, смена роли, отзыв, ноды/инбаунды) пишутся в
`AuditLog` (append-only) с источником `Web`/`Telegram`/`System`.
## Обработка ошибок
- Управляемые ошибки → `Result`/`Result<T>` (`ResultExtensions.ToHttpResult`) → маппинг `ErrorType` в
HTTP-статус + `application/problem+json` (400/401/403/404/409/422).
- Непредвиденные исключения → встроенные `AddProblemDetails()` + `UseExceptionHandler()` → 500 без
утечки деталей + `Serilog` request-логирование (`UseSerilogRequestLogging`, обогащение —
`Enrich.FromLogContext()`). Сквозного `CorrelationId`/явного обогащения `UserId`/`NodeId`/`ConfigId`
в логах пока нет — задел на будущее, а не то, на что стоит полагаться при расследовании инцидентов сегодня.
- Доменные исключения (нарушение инвариантов) — `DomainException`, ожидаются только как баг, а не
штатный путь (штатные отказы — через `Result.Failure`, не исключения).
## Развёртывание (единый контейнер приложения)
По требованию — **один контейнер на всё приложение** (фронт + бек + бот) и отдельный контейнер
PostgreSQL:
- **Единый образ**: ASP.NET Core (`PnvPanel.Api`) обслуживает REST (`/api`), SignalR (`/hubs`),
хостит Telegram-бота (long polling) **и** раздаёт статику React-SPA (`UseStaticFiles` +
SPA-fallback на `index.html` для клиентских маршрутов). Фронт и бек — один origin, база API — относительный `/api`.
- **Multi-stage Dockerfile**:
1. `node` — сборка фронта (`pnpm build`) → `dist/`.
2. `dotnet sdk` — `dotnet publish` Api; статика фронта копируется в `wwwroot`.
3. `dotnet aspnet` runtime — финальный образ запускает Api.
- **docker-compose**: сервис `app` (этот образ) + сервис `db` (PostgreSQL). Всё приложение — в `app`.
- **TLS — внешний**: HTTPS терминирует внешний прокси/шлюз (nginx/Traefik/cloud LB администратора),
вне нашего compose; `app` внутри отдаёт HTTP. Приложение доверяет `X-Forwarded-Proto/For` через
`ForwardedHeaders`-middleware — иначе Secure-cookie и определение схемы за прокси работать не будут.
Отдельный nginx/Caddy в compose **не** вводим.
- **Миграции**: применяются **автоматически на старте** приложения. При масштабировании на несколько
инстансов миграции стоит вынести в отдельный шаг/джобу.
- Конфигурация через `appsettings.{Env}.json` + переменные окружения / secrets (строка подключения,
JWT-ключ, ключ шифрования секретов, `Telegram:BotToken`).
```
[ внешний прокси/шлюз: TLS termination ] ← HTTPS, вне нашего compose
│ HTTP + X-Forwarded-*
┌────────────────── docker-compose ──────────────────┐
│ app (единый образ) db (postgres) │
│ ├─ REST /api └─ том с данными │
│ ├─ SignalR /hubs/panel │
│ ├─ Telegram bot (long polling) │
│ └─ статика SPA (wwwroot, fallback → index.html) │
└─────────────────────────────────────────────────────┘
```