Перейти к содержанию

Архитектура бэкенда

Java Spring Boot бэкенд ShivaVPN. Полный источник: docs/development/BACKEND-ARCHITECTURE-KEY-FACTS.md (monorepo).


Расположение

Компонент Адрес
Backend (blue) 10.99.87.249:8080
Backend (green) 10.99.87.249:8081
Keycloak 10.99.87.249:8180
MySQL 10.99.87.62 (container: vpn-db)
Redis 10.99.87.249 (container: redis)

Деплой

Blue-green auto deploy — push в main → CI автоматически деплоит на неактивный слот и переключает.

Откат: deploy-backend.sh --rollback

Проект GitLab: #33, repo — backend-app/.


Основные компоненты

Файл Что делает
ServerServiceImpl.java Создание/обновление клиентов на VPN-серверах
XrayService.java HTTP-клиент к X-UI API (порт 1443)
CountryServiceImpl.java Агрегация конфигов по странам, хардкод табов
NodeSyncService.java Синхронизация WL-конфигов (без SSH, из DB)
NodeSyncWorker.java 500ms poll, batch=30, обрабатывает очередь sync-задач
VpnSyncScheduler.java Периодические задачи: 6h пересинк, transport_params
VpnConfigSyncEventListener.java Реагирует на события (ServerStatusChanged, AccountCreated)
AuthServiceImpl.java Логин, JWT, async client creation
RedisTokenService.java Хранение/валидация токенов
ConfigConverter.java Конвертация в VLESS URL
VpnSubscriptionController.java Публичные subscription endpoints

API endpoints (клиенты)

Клиент Endpoint Auth
iOS GET /api/v2/countries?lang={lang} Bearer JWT
Android GET /servers/{country}/config Bearer JWT
Desktop GET /api/servers/{country}/config/xray Bearer JWT
Subscription GET /api/v2/subscription/vless/{accountNumber} нет (public)
WL Subscription GET /api/v2/subscription/whitelist/{accountNumber} нет (public)

Subscription endpoints возвращают VLESS-ссылки для v2rayNG/Streisand/Clash без авторизации — 16-значный номер аккаунта является токеном доступа.


Аутентификация

Два метода одновременно (bridge mode):

  • Internal JWTJwtUtils.java, валидация через Redis
  • Keycloak — OAuth2 device auth, два realm: shiva-users / shiva-devices

AuthTokenFilter.java детектирует тип токена и направляет в нужный путь.

Compat-флаги (prod, все true): allow_legacy, bypass_signature, fail-open-on-redis-error.

Структура ключей Redis

sess:{accountId}:{deviceId}              — данные сессии (JSON)
devices:{accountId}                      — SET deviceId-ов аккаунта
web:{accountId}                          — SET deviceId-ов веб-сессий
blacklisted-device:{accountId}:{deviceId} — маркер заблокированного устройства
active-devices-base:{accountId}          — кэш списка устройств

accountIdMySQL UUID аккаунта (не 16-значный номер). UUID в Redis — lowercase с дефисами: ac110002-9ab4-1b28-819a-b42cfccb0001.

Структура JSON в ключе sess::

{
  "jwtId": "b12ccece-be56-4162-ad06-c6d00ae598fa",
  "deviceName": "iPhone",
  "deviceType": "iOS",
  "lastLoginDate": "2025-11-25T13:45:38.929Z",
  "rotatedAt": "2025-11-25T13:45:38.929Z",
  "createdAtSec": 1764078338
}

JWT claims

Claim Значение
sub accountId (UUID)
jti уникальный ID токена (jwtId)
deviceId идентификатор устройства
deviceName "iPhone", "Xiaomi 23129RA5FL" и т.д.
deviceType "iOS", "Android", "Web", "Unknown"
role "USER" или "ADMIN"

Поиск сессий аккаунта (4 шага)

Шаг 1: получить UUID из MySQL

./scripts/ssh-internal.sh 10.99.87.249 'docker exec vpn-db mysql -uvpn -p... vpn \
  -N -e "SELECT HEX(id) FROM account WHERE account = 7157137714593400"'
# Результат: AC1100029AB41B28819AB42CFCCB0001

Шаг 2: перевести в формат Redis

AC1100029AB41B28819AB42CFCCB0001 → ac110002-9ab4-1b28-819a-b42cfccb0001

Шаг 3: получить список устройств

./scripts/ssh-internal.sh 10.99.87.249 \
  'docker exec redis redis-cli -a $REDIS_PASS --no-auth-warning \
    SMEMBERS "devices:{ac110002-9ab4-1b28-819a-b42cfccb0001}"'

Шаг 4: получить данные сессии

./scripts/ssh-internal.sh 10.99.87.249 \
  'docker exec redis redis-cli -a $REDIS_PASS --no-auth-warning \
    GET "sess:{ac110002-9ab4-1b28-819a-b42cfccb0001}:{deviceId}"'

UUID-ы последовательные

Поиск по короткому префиксу ac110002-9ab4-1b28 вернёт несколько аккаунтов. Использовать только полный UUID или паттерн devices:{full-uuid}.


Маппинг аккаунт → VPN-клиент

account.account (16 цифр) = clients[].id на XRAY = VLESS user ID
clients[].email ({account}@vpn) = client_traffics.email

Подробнее: Маппинг аккаунтов


Табы в приложении

Хардкод в CountryServiceImpl.java:

Таб Страны
Все всегда
Для России EE, LV, LT, PL, SE, DE, AT, TR, ER, RU
Whitelist страны с кодом W* (захардкожен список WHITELIST_CODES)
Turbo высокоскоростные страны

server.category_id в MySQL не используется для табов — табы определяются по коду страны.


Синхронизация конфигов (NodeSyncWorker)

Тип Путь
WHITELIST с transport_params быстрый: buildVlessUrl() из DB, без HTTP
XUI с transport_params быстрый: buildVlessUrl() из DB
XUI без transport_params медленный: HTTP к X-UI → addClient → buildConfigs

Максимальные задержки после активации сервера:

Шаг Задержка
Backend event (ServerStatusChanged) мгновенно
NodeSyncWorker ≤ несколько минут
VCS mysql_sync ≤ 10 мин
VCS transport_params ≤ 30 мин
VCS fill_configs ≤ 60 мин
Worst case ~70 мин
С заполненным transport_params ~5 мин

X-UI API

Порт: 1443 (production, HTTPS). Dev: 33081 (HTTP). Аутентификация: Cookie session={token}.

# Проверить клиента на сервере
curl -k "https://<IP>:1443/panel/inbound/list" \
  -H "Cookie: session=<TOKEN>" | \
  jq '.obj[] | select(.protocol=="vless") | .settings.clients[] | select(.id=="<ACCOUNT>")'

Генерация VPN Subscription

Endpoint GET /api/v2/subscription/whitelist/{accountNumber} возвращает base64-encoded VLESS-ссылки (text/plain). Алгоритм 3 шага:

  1. ServerRepository.findAllByChangeIpEnabledTrueAndIsActiveTrue() — все активные WL-серверы
  2. Для каждого сервера VpnConfigBuilder.buildVlessUrl(accountNumber, server):
  3. берёт server.transportParams (Reality-параметры, кэшированные из X-UI)
  4. адрес: server.changeIp (российский proxy IP)
  5. порт: server.port (proxy listen port: 443/8443/2083/52443)
  6. VLESS user ID: accountNumber (16-значный номер — он же clients[].id в X-UI)
  7. Все ссылки склеиваются через \n и base64-кодируются

Endpoint /vless/{accountNumber} делает 302 redirect → market-plus.vip/api/sub/{shortUuid}. Оба — без JWT (permitAll в SecurityConfig.java).


Известные баги

vpn_user_server_config не обновляется при смене transport_params

Статус: частично исправлен (5 Feb 2026).

При изменении server.transport_params в MySQL старые конфиги в vpn_user_server_config.config_string не обновляются автоматически. Пользователи продолжают получать сломанные конфиги.

Что не вызывает инвалидацию:

Изменение Обновит конфиги?
server.transport_params изменился в DB нет
server.change_ip изменился нет
IP сервера изменился нет
Reality ключи ротированы нет
Новый inbound добавлен нет
Параметры inbound изменились в X-UI только если scheduler обнаружил И needsSync=true

Ручной workaround:

-- Удалить сломанные конфиги
DELETE c FROM vpn_user_server_config c
JOIN server s ON c.server_id = s.id
WHERE s.name = 'SERVER_NAME' AND c.config_string LIKE '%OLD_PARAM%';

-- Сбросить bindings → пересинк
UPDATE vpn_user_server_account vusa
JOIN server s ON vusa.server_id = s.id
SET vusa.status = 'PENDING'
WHERE s.name = 'SERVER_NAME';

Ключевые файлы: VpnSyncScheduler.java:450-482, NodeSyncService.java:169-183, ServerRepository.java:124-129.

findByIp и дублирующиеся IP (WL + XUI)

Статус: исправлен (7 Jan 2026).

Когда WL-сервер и XUI-сервер имеют одинаковый ip (одна физическая машина), findByIp() бросал IncorrectResultSizeDataAccessException. Исправлено: добавлен DISTINCT в findAllIpsByIsActiveTrue() и findFirstByIpAndIsActiveTrue() вместо findByIp().


Связанные страницы


См. также: Backend Deploy · Config Delivery Flow · VPN Config Service · Репозитории · Backend ошибки