Архитектура
Компоненты
Section titled “Компоненты”| Компонент | Роль | Развёртывание |
|---|---|---|
| Backend | Control plane: REST API, бизнес-логика, PostgreSQL, рендер конфигураций нод | Один бинарник, Docker Compose |
| Agent | Data plane: управляет локальным Xray, приводит его к желаемому состоянию | Один контейнер на каждой ноде |
| Web | SPA: админ-панель (/admin/*) и кабинет клиента (/portal/*) |
Статика за reverse proxy |
flowchart LR
Admin[Админ] --> Web[Web SPA]
Client[Клиент] --> Web
Client -->|VPN-трафик| X1
Web -->|REST| BE[Backend]
BE --> PG[(PostgreSQL)]
subgraph Node1 [Нода NL-1]
A1[Agent] --> X1[Xray]
end
subgraph Node2 [Нода DE-1]
A2[Agent] --> X2[Xray]
end
A1 -->|"gRPC-стрим (agent → backend)"| BE
A2 -->|"gRPC-стрим (agent → backend)"| BE
Backend — единственный компонент с состоянием. PostgreSQL — единственное хранилище: данные, очередь фоновых задач, блокировки. Redis в системе нет: при целевом масштабе (десятки нод, десятки тысяч клиентов) отдельный кэш не окупает дополнительный stateful-компонент в self-hosted-инсталляции.
Декларативная модель конфигурации
Section titled “Декларативная модель конфигурации”Ключевое архитектурное решение: backend не отправляет нодам команды («добавь инбаунд», «удали пользователя»). Backend вычисляет полное желаемое состояние ноды из данных в PostgreSQL, а агент приводит локальный Xray к этому состоянию (reconciliation).
flowchart LR
DB[(PostgreSQL<br/>нормализованные данные)] -->|рендер| DS["Desired state ноды<br/>(revision N)"]
DS -->|gRPC-стрим| AG[Agent]
AG -->|diff + apply| XR[Xray]
AG -->|"status (applied revision, ошибки)"| DB
Как это работает
Section titled “Как это работает”- Любое изменение, влияющее на ноду (новый инбаунд, отключённый клиент, обновлённый сертификат, изменение цепочки), инкрементирует
desired_revisionэтой ноды. - Backend рендерит desired state — сериализованный снапшот: инбаунды, клиенты по инбаундам, аутбаунды, правила маршрутизации, сертификаты — и отправляет его в открытый стрим агента.
- Агент сравнивает снапшот с фактическим состоянием Xray и применяет разницу через Xray API (или через рестарт с новым конфигом, если изменение не применимо на лету).
- Агент подтверждает
applied_revisionили сообщает ошибку. Backend хранит оба значения; расхождениеdesired != applied— наблюдаемый сигнал, а не потерянное состояние.
Почему не императивные команды
Section titled “Почему не императивные команды”Императивная модель (AddInbound/AddUser/RemoveUser с per-строчными статусами pending/applied/error) требует, чтобы backend отслеживал результат каждой команды на каждой ноде. Это порождает целый класс проблем, каждая из которых в декларативной модели отсутствует по построению:
| Проблема императивной модели | В декларативной модели |
|---|---|
| Дрейф: команда потерялась → состояние ноды неизвестно | Следующий цикл reconciliation устраняет любой дрейф |
| Частичный отказ: применилось 3 команды из 5 → нужен ручной откат | Состояние атомарно на уровне revision; нет промежуточных |
| Порядок: цепочку нужно разворачивать с хвоста, с откатом при ошибке | Каждая нода получает своё полное состояние; сходимость не зависит от порядка |
Ресинхронизация ноды после даунтайма — отдельная процедура (sync:node) |
Это тот же единственный механизм: агент получает актуальный снапшот |
Таблицы синхронизации (user_inbound_sync) и ретраи per-операция |
Не нужны: статус — одно число applied_revision на ноду |
Восстановление ноды с нуля — отдельный ImportConfig |
Не нужен: пустая нода — частный случай дрейфа |
Цена — агент обязан уметь считать diff между снапшотом и живым Xray. Это разовая сложность в одном месте (агенте) вместо распределённой сложности во всех фичах backend.
Источник истины
Section titled “Источник истины”| Данные | Владелец |
|---|---|
| Желаемое состояние (конфигурация) | PostgreSQL |
| Фактическое состояние Xray | Нода; backend хранит только отчёт |
| Счётчики трафика | Нода накапливает, backend — единственное долговременное хранилище |
Локальный диск ноды — кэш последнего применённого состояния (для старта при недоступном backend), никогда не источник истины.
Направление соединения
Section titled “Направление соединения”Агент устанавливает соединение с backend, а не наоборот. Один долгоживущий bidirectional gRPC-стрим поверх mTLS; все взаимодействия (desired state, статусы, отчёты о трафике, команды, логи) мультиплексируются в нём.
Обоснование:
- Нода не открывает management-порт: снаружи видны только порты инбаундов Xray. Сканирование ноды не выдаёт её принадлежность к панели.
- Ноды работают за NAT и в сетях с ограниченным ingress без дополнительной настройки.
- Bootstrap ноды сводится к двум значениям: адрес backend + одноразовый enrollment-токен. Приватный ключ агента генерируется на ноде и никогда её не покидает (см. Безопасность агента).
- Живость соединения — встроенное свойство: разрыв стрима означает недоступность ноды, отдельный heartbeat-протокол не нужен.
Потоки данных
Section titled “Потоки данных”Изменение конфигурации
Section titled “Изменение конфигурации”sequenceDiagram
participant A as Админ
participant BE as Backend
participant AG as Agent
participant X as Xray
A->>BE: PATCH /api/v1/... (изменение)
BE->>BE: транзакция: данные + desired_revision++
BE->>AG: DesiredState(revision N)
AG->>X: diff + apply
AG->>BE: StatusReport(applied_revision = N)
Latency применения — миллисекунды при живом стриме. Если нода офлайн, изменение просто ждёт переподключения — очередь недоставленных команд не нужна, потому что доставляется всегда только последний снапшот.
Учёт трафика
Section titled “Учёт трафика”Агент отправляет отчёты об использовании (push), backend не опрашивает ноды и не использует reset-счётчики Xray с потерей данных при сбое записи. Отчёты идемпотентны; детали — в Трафик и лимиты.
Фоновые процессы backend
Section titled “Фоновые процессы backend”| Процесс | Период | Действие |
|---|---|---|
| Истечение подписок | 1 мин | active → expired, bump revision затронутых нод |
| Продление сертификатов | 12 ч | ACME-перевыпуск за 30 дней до истечения |
| Агрегация трафика | по расписанию | Свёртка почасовых записей в суточные, применение retention |
| Ротация DEK | 30 дней | См. Шифрование |
Все периодические задачи выполняются внутри процесса backend под Postgres advisory lock — при нескольких репликах задачу выполняет одна.
Границы отказов
Section titled “Границы отказов”| Отказ | Поведение |
|---|---|
| Backend недоступен | Ноды продолжают обслуживать VPN-трафик на последнем применённом состоянии. Недоступны: изменения конфигурации, кабинет, новые подписки. |
| Нода недоступна | Backend помечает ноду offline (разрыв стрима). Изменения накапливаются в desired state и применяются при переподключении. |
| PostgreSQL недоступен | Backend деградирует целиком — это единственная жёсткая зависимость. |
| Xray упал на ноде | Агент перезапускает процесс и заново применяет последнее состояние; сообщает инцидент в статусе. |
Масштабирование
Section titled “Масштабирование”Целевой масштаб: до ~100 нод и ~100 тыс. клиентов на одну инсталляцию. В этих пределах: один инстанс backend (вертикальное масштабирование), один PostgreSQL. Архитектура не мешает горизонтальному масштабированию backend (stateless-процесс, состояние в PostgreSQL, advisory locks для фоновых задач, стримы агентов балансируются между репликами), но это не цель MVP и не влияет на схему данных.