Skip to content

Архитектура

Компонент Роль Развёртывание
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
  1. Любое изменение, влияющее на ноду (новый инбаунд, отключённый клиент, обновлённый сертификат, изменение цепочки), инкрементирует desired_revision этой ноды.
  2. Backend рендерит desired state — сериализованный снапшот: инбаунды, клиенты по инбаундам, аутбаунды, правила маршрутизации, сертификаты — и отправляет его в открытый стрим агента.
  3. Агент сравнивает снапшот с фактическим состоянием Xray и применяет разницу через Xray API (или через рестарт с новым конфигом, если изменение не применимо на лету).
  4. Агент подтверждает 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.

Данные Владелец
Желаемое состояние (конфигурация) PostgreSQL
Фактическое состояние Xray Нода; backend хранит только отчёт
Счётчики трафика Нода накапливает, backend — единственное долговременное хранилище

Локальный диск ноды — кэш последнего применённого состояния (для старта при недоступном backend), никогда не источник истины.

Направление соединения

Section titled “Направление соединения”

Агент устанавливает соединение с backend, а не наоборот. Один долгоживущий bidirectional gRPC-стрим поверх mTLS; все взаимодействия (desired state, статусы, отчёты о трафике, команды, логи) мультиплексируются в нём.

Обоснование:

  • Нода не открывает management-порт: снаружи видны только порты инбаундов Xray. Сканирование ноды не выдаёт её принадлежность к панели.
  • Ноды работают за NAT и в сетях с ограниченным ingress без дополнительной настройки.
  • Bootstrap ноды сводится к двум значениям: адрес backend + одноразовый enrollment-токен. Приватный ключ агента генерируется на ноде и никогда её не покидает (см. Безопасность агента).
  • Живость соединения — встроенное свойство: разрыв стрима означает недоступность ноды, отдельный heartbeat-протокол не нужен.

Изменение конфигурации

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 применения — миллисекунды при живом стриме. Если нода офлайн, изменение просто ждёт переподключения — очередь недоставленных команд не нужна, потому что доставляется всегда только последний снапшот.

Агент отправляет отчёты об использовании (push), backend не опрашивает ноды и не использует reset-счётчики Xray с потерей данных при сбое записи. Отчёты идемпотентны; детали — в Трафик и лимиты.

Процесс Период Действие
Истечение подписок 1 мин active → expired, bump revision затронутых нод
Продление сертификатов 12 ч ACME-перевыпуск за 30 дней до истечения
Агрегация трафика по расписанию Свёртка почасовых записей в суточные, применение retention
Ротация DEK 30 дней См. Шифрование

Все периодические задачи выполняются внутри процесса backend под Postgres advisory lock — при нескольких репликах задачу выполняет одна.

Отказ Поведение
Backend недоступен Ноды продолжают обслуживать VPN-трафик на последнем применённом состоянии. Недоступны: изменения конфигурации, кабинет, новые подписки.
Нода недоступна Backend помечает ноду offline (разрыв стрима). Изменения накапливаются в desired state и применяются при переподключении.
PostgreSQL недоступен Backend деградирует целиком — это единственная жёсткая зависимость.
Xray упал на ноде Агент перезапускает процесс и заново применяет последнее состояние; сообщает инцидент в статусе.

Целевой масштаб: до ~100 нод и ~100 тыс. клиентов на одну инсталляцию. В этих пределах: один инстанс backend (вертикальное масштабирование), один PostgreSQL. Архитектура не мешает горизонтальному масштабированию backend (stateless-процесс, состояние в PostgreSQL, advisory locks для фоновых задач, стримы агентов балансируются между репликами), но это не цель MVP и не влияет на схему данных.