Архитектура бэкенда¶
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 JWT —
JwtUtils.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} — кэш списка устройств
accountId — MySQL 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
Шаг 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 шага:
ServerRepository.findAllByChangeIpEnabledTrueAndIsActiveTrue()— все активные WL-серверы- Для каждого сервера
VpnConfigBuilder.buildVlessUrl(accountNumber, server): - берёт
server.transportParams(Reality-параметры, кэшированные из X-UI) - адрес:
server.changeIp(российский proxy IP) - порт:
server.port(proxy listen port: 443/8443/2083/52443) - VLESS user ID:
accountNumber(16-значный номер — он жеclients[].idв X-UI) - Все ссылки склеиваются через
\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 ошибки