3x-ui подключение: ссылка для клиента или подписка
В 3x-ui два способа отдать доступ, и они решают разные задачи.
Прямая ссылка — строка, которую панель собирает из параметров инбаунда и клиента (кнопка QR/копировать в таблице инбаунда):
vless://[email protected]:443?type=tcp&security=reality&pbk=Jk9...s0&fp=chrome&sni=www.microsoft.com&sid=a1b2c3d4&spx=%2F&flow=xtls-rprx-vision#node1-u1042
Всё, что здесь зашито — хост, порт, pbk, sni, sid, flow — остаётся у клиента навсегда. Ротация Reality-ключей, переезд на другой IP, вторая нода — и вы пишете каждому лично.
3x-ui подписка — HTTP-эндпоинт, который по subId отдаёт актуальный список конфигов (base64 или plain) плюс служебные заголовки с остатком трафика и датой окончания.
| Критерий | Прямая ссылка | Подписка /sub/<subId> |
|---|---|---|
| Смена IP/домена ноды | клиент правит вручную | подхватывается при обновлении |
| Несколько нод в профиле | несколько ссылок | один URL, все инбаунды с этим subId |
| Остаток трафика и срок в приложении | нет | да, через Subscription-Userinfo |
| Отзыв доступа | enable=0 в инбаунде | то же + подписка отдаёт пустоту |
| Что публикуете наружу | ничего сверх ноды | ещё один домен, обязанный быть доступным |
| Цена утечки ссылки | один конфиг | вся ваша топология нод |
Правило: тест, друзья, одноразовый доступ — ссылка; всё, что живёт дольше недели и приносит деньги — подписка. Одно другому не мешает, QR из панели никуда не девается.
Важный нюанс планирования: подписка — это второй канал, который обязан работать, когда нода уже не работает. Если домен подписки резолвится в тот же IP, что и транспорт, то в момент блокировки ноды вы теряете и возможность переключить людей на резерв. Разносите их: подписка — на адрес, который не фильтруется (отдельный хостинг, реверс-прокси, whitelist-CDN), транспорт — куда угодно.
Поля клиента, которые определяют выдачу
Клиенты хранятся в JSON инбаунда (inbounds.settings в /etc/x-ui/x-ui.db), счётчики — в отдельной таблице client_traffics. Что заполнять осознанно:
| Поле в UI | Ключ в API/БД | Смысл |
|---|---|---|
email | уникальный ID в пределах панели, он же ключ статистики и строка в access-логе. Ставьте техничный идентификатор (u1042, tg_38291), не почту клиента | |
| ID / Password | id | UUID для VLESS/VMess, пароль для Trojan/Shadowsocks |
| Flow | flow | xtls-rprx-vision — только TCP + Reality/TLS. Для WS/gRPC/XHTTP поле обязано быть пустым, иначе соединение не поднимется |
| Subscription ID | subId | ключ подписки. Одинаковый subId в разных инбаундах = один URL со всеми конфигами |
| Total GB | totalGB | в UI гигабайты, в API байты. 0 = безлимит |
| Expiry Date | expiryTime | unix в миллисекундах. 0 = бессрочно, отрицательное = отсчёт с первого коннекта |
| IP Limit | limitIp | максимум одновременных IP; требует access-лога Xray |
| Reset | reset | период автосброса трафика в днях, 0 = не сбрасывать |
| Enable | enable | рубильник доступа |
subId — это фактически bearer-токен: кто им владеет, тот получает конфиги. Не выводите его из email и не делайте коротким, генерируйте случайные 16 hex-символов:
head -c 8 /dev/urandom | xxd -p # 5f3a9c11d2e40b76
Срез состояния по всем клиентам одной командой:
sqlite3 -header -column /etc/x-ui/x-ui.db \
"SELECT email, enable, inbound_id AS inb,
printf('%.1f', (up+down)/1073741824.0) AS used_gb,
printf('%.0f', total/1073741824.0) AS limit_gb,
CASE WHEN expiry_time=0 THEN 'never'
WHEN expiry_time<0 THEN 'pending'
ELSE datetime(expiry_time/1000,'unixepoch') END AS expires
FROM client_traffics ORDER BY (up+down) DESC LIMIT 20;"
Писать в эту БД напрямую не надо: панель держит конфиг Xray в памяти и перезаписывает файл при своих операциях — правки мимо панели молча теряются. Читать — сколько угодно.
Подписка: включение, nginx и защита URL
Panel Settings → Subscription. Продакшен-набор, когда TLS терминирует nginx:
| Параметр | Значение | Зачем |
|---|---|---|
| Enable Subscription | вкл | |
| Listen IP | 127.0.0.1 | порт 2096 наружу не светим |
| Port | 2096 | |
| Path | /sub/ | должен совпасть с location в nginx |
| JSON Path | /json/ | полный конфиг Xray для клиентов, которым нужен JSON |
| Certificate / Key | пусто | сертификат держит nginx |
| URI | https://sub.example.com/sub/ | что панель подставит в кнопку «копировать подписку» |
| Update Interval | 12 | часы, уезжает в заголовок profile-update-interval |
| Enable Encryption | base64 | plain тоже читают почти все; base64 безопаснее по мусору в ответе |
| Show Info | вкл | добавляет в список псевдо-конфиг с остатком и датой |
Проверка до публикации наружу:
curl -si http://127.0.0.1:2096/sub/5f3a9c11d2e40b76 | head -20
nginx на отдельном домене:
limit_req_zone $binary_remote_addr zone=sub:10m rate=20r/m;
server {
listen 443 ssl;
http2 on;
server_name sub.example.com;
ssl_certificate /etc/letsencrypt/live/sub.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sub.example.com/privkey.pem;
location ~ ^/(sub|json)/ {
limit_req zone=sub burst=10 nodelay;
proxy_pass http://127.0.0.1:2096;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;
}
location / { return 404; }
}
Три вещи, на которых спотыкаются чаще всего:
proxy_pass http://127.0.0.1:2096;— без пути и без слэша в конце. Стоит дописать/— nginx отрежет префикс/sub/, и панель ответит 404.add_headerвнутриlocationотменяет всеadd_header, унаследованные отserver. Если у вас там HSTS и прочее — продублируйте их в этом же блоке.20r/mна IP — с запасом приUpdate Interval = 12, но клиенты за одним NAT (офис, мобильный оператор) попадают в общий счётчик; если пошли 503 — поднимайтеburst, а не rate.
Заголовки no-store — не паранойя. По наблюдениям, мобильные сборки (особенно на iOS) отдают пользователю закэшированный ответ и после смены конфигов на ноде продолжают показывать старые серверы, пока подписку не удалить и не добавить заново. Проверять после каждой правки nginx:
curl -sI https://sub.example.com/sub/5f3a9c11d2e40b76 \
| grep -iE 'cache-control|subscription-userinfo|profile-'
Трафик, срок и IP: как это считается на самом деле
Трафик. Панель складывает up + down из статистики Xray и сравнивает с total. Счётчики снимаются по таймеру, а не в реальном времени, поэтому перерасход на сотни мегабайт — штатное поведение, а не баг. При исчерпании клиент получает enable = 0 и исчезает из конфига ядра; живая сессия рвётся при следующей пересборке конфига, то есть отключение сдвинуто на десятки секунд. Продавать «ровно 100 ГБ» с точностью до мегабайта на этой архитектуре нельзя — закладывайте допуск.
Срок. expiryTime — миллисекунды:
date -d "+30 days" +%s000 # 1785600000000
Отложенный старт. Отрицательное значение панель трактует как длительность и начинает отсчёт с первого подключения. Незаменимо, когда ключ уходит заранее — промо, оплата вперёд, раздача через бота:
-2592000000 # 30 дней с первого коннекта
-604800000 # 7 дней, триал
В client_traffics такой клиент до первого коннекта виден с отрицательным expiry_time — учитывайте это в своих отчётах, иначе datetime() покажет 1969 год.
IP-лимит. limitIp считает одновременные IP из access-лога Xray. Panel → Xray Configs, блок логов должен быть примерно таким:
"log": {
"access": "./access.log",
"error": "./error.log",
"loglevel": "warning"
}
При "loglevel": "none" или пустом access лимит молча не работает: клиент раздаёт ключ друзьям, панель не показывает ни ошибки, ни предупреждения. Обратная сторона — вы пишете на диск IP пользователей. Панель периодически сама подчищает этот файл, разбирая его для списка онлайн и IP-лимита, но если хотите гарантий — ротация только через copytruncate, ядро держит дескриптор открытым:
/usr/local/x-ui/bin/access.log {
daily
rotate 2
compress
copytruncate
missingok
notifempty
}
Значение limitIp = 1 даёт ложные срабатывания: мобильный интернет меняет адрес при переключении вышки, а телефон и ноутбук за домашним роутером выходят с одного IP только пока оба дома. Рабочий минимум — 2–3.
Сброс и продление. Reset Traffic обнуляет up/down; поле Reset в днях включает циклический автосброс — ровно то, что нужно ежемесячной подписке. Перед любой массовой операцией:
cp /etc/x-ui/x-ui.db /root/x-ui.db.$(date +%F-%H%M)
Массовая выдача через API
После двадцати клиентов выдача мышкой заканчивается. В 3x-ui есть HTTP-API поверх той же сессии, что и веб-панель. База — https://<host>:<port>/<webBasePath>.
BASE="https://panel.example.com:54321/aBcDeF"
COOKIE=/root/.3xui.cookie
curl -s -c $COOKIE -X POST "$BASE/login" -d "username=admin" -d "password=***"
Если включена двухфакторка или secret-token — логин через curl так не пройдёт, нужен дополнительный параметр из настроек безопасности панели.
Узнать id нужного инбаунда:
curl -s -b $COOKIE "$BASE/panel/api/inbounds/list" \
| jq '.obj[] | {id, remark, protocol, port}'
Добавление клиента. Два подвоха: settings передаётся строкой с экранированным JSON, а totalGB — в байтах. Собирайте тело через jq, чтобы не считать слэши руками:
UUID=$(cat /proc/sys/kernel/random/uuid)
EMAIL="u1042"
SUBID=$(head -c 8 /dev/urandom | xxd -p)
INBOUND=1
CLIENTS=$(jq -nc --arg id "$UUID" --arg em "$EMAIL" --arg sub "$SUBID" \
'{clients:[{id:$id,email:$em,flow:"xtls-rprx-vision",
totalGB:107374182400,expiryTime:-2592000000,
limitIp:3,subId:$sub,tgId:"",enable:true,reset:0}]}')
curl -s -b $COOKIE -X POST "$BASE/panel/api/inbounds/addClient" \
-H "Content-Type: application/json" \
-d "$(jq -nc --argjson id $INBOUND --arg s "$CLIENTS" '{id:$id,settings:$s}')" \
| jq '{success, msg}'
echo "Подписка: https://sub.example.com/sub/$SUBID"
107374182400 — это 100 ГБ; -2592000000 — 30 дней с первого подключения. Ответ вида {"success": true} означает только «панель приняла», а не «клиент работает» — проверяйте через getClientTraffics.
| Действие | Метод и путь |
|---|---|
| Статистика клиента | GET /panel/api/inbounds/getClientTraffics/{email} |
| Обновить клиента | POST /panel/api/inbounds/updateClient/{uuid} (тело как у addClient) |
| Сбросить трафик | POST /panel/api/inbounds/{inboundId}/resetClientTraffic/{email} |
| Удалить клиента | POST /panel/api/inbounds/{inboundId}/delClient/{uuid} |
| Кто онлайн | POST /panel/api/inbounds/onlines |
| Бэкап в Telegram | GET /panel/api/inbounds/createbackup |
Главное предостережение: правка клиентов приводит к пересборке конфига и, в большинстве сборок, к перезапуску ядра Xray, а рестарт рвёт все активные соединения на ноде. Поведение зависит от версии — измерьте на своей, прежде чем гонять цикл на 300 клиентов днём:
ps -o etimes= -C xray # секунды аптайма ядра до и после addClient
Если число сбросилось в единицы — операции батчами и в ночное окно. Откат при неудачном скрипте:
x-ui stop && cp /root/x-ui.db.2026-07-26-0300 /etc/x-ui/x-ui.db && x-ui start
Что видит клиент в приложении
Подписка отдаёт не только конфиги, но и служебные заголовки — именно из них приложение рисует остаток и дату:
Subscription-Userinfo: upload=1048576; download=3221225472; total=107374182400; expire=1785600000
profile-update-interval: 12
profile-title: base64:Q2xlYXJ3YXk=
profile-web-page-url: https://example.com/cabinet
total=0рисуется как «безлимит»,expire=0— как «бессрочно»;expireздесь в секундах, тогда какexpiryTimeв панели и API — в миллисекундах. Это первая причина «даты 1970 года» в самописных прокси перед подпиской;profile-update-interval— рекомендация, а не команда: у приложений своё расписание, часть обновляется только вручную.
Поддержка по клиентам (по наблюдениям, сборки меняются быстро — перепроверяйте перед тем, как обещать пользователю):
| Клиент | Подписка | Трафик/срок из заголовков | Замечание |
|---|---|---|---|
| v2rayNG (Android) | да | частично | обновление вручную или по расписанию |
| Happ (iOS/Android) | да | да | склонен кэшировать ответ; лечится no-store и передобавлением подписки |
| Streisand (iOS) | да | да | корректно показывает остаток |
| Shadowrocket (iOS) | да | да | требует валидный сертификат, self-signed не примет |
| Hiddify | да | да | предсказуемее с JSON-подпиской /json/ |
| NekoBox (Android) | да | частично |
Если приложение не читает Subscription-Userinfo, выручает Show Info: панель добавляет в список фиктивную запись, и остаток с датой видны прямо в названии сервера.
JSON-подписка (/json/<subId>) отдаёт готовый конфиг Xray со всеми полями транспорта — это самый надёжный способ довезти до клиента нетривиальные параметры XHTTP, которые не всегда переживают сериализацию в vless://. Здесь работает жёсткое правило: значение extra на ноде и extra у клиента должны совпадать байт в байт, а экзотические поля обфускации ломают совместимость между версиями ядра — если у части аудитории старые сборки приложений, держитесь минимального набора параметров и меняйте его редко.
Диагностика: типовые поломки при выдаче
Подписка открывается, но пустая. У клиента не заполнен subId, либо он лежит в отключённом инбаунде, либо уже исчерпан:
sqlite3 /etc/x-ui/x-ui.db "SELECT id, remark, enable, port FROM inbounds;"
curl -s http://127.0.0.1:2096/sub/<subId> | base64 -d
404 на публичном домене, локальный curl работает. Почти всегда proxy_pass с путём или со слэшем на конце — nginx срезал префикс. Второй вариант: Path в настройках подписки не совпал с location.
Конфиги приходят, соединение не встаёт. Сверьте flow: xtls-rprx-vision допустим только на TCP + Reality/TLS, на WS/gRPC/XHTTP поле обязано быть пустым. Дальше — время на сервере, для VMess и проверки сроков это критично: timedatectl status.
Приложение показывает старые серверы. Кэш на клиенте или на промежуточном прокси. Убедитесь, что Cache-Control: no-store доезжает до пользователя (curl -sI выше); если доезжает — просите удалить и добавить подписку заново, дальше это уже поведение конкретной сборки.
Работает у части пользователей, у остальных нет. Классика для XHTTP за CDN. У Yandex CDN нет POST, поэтому аплинк уходит методом GET, а GET в XHTTP допустим только при явно заданном "mode": "packet-up". Если режим не прописан и на ноде, и в том конфиге, который вы отдаёте подпиской, одни клиентские сборки угадывают его сами, другие нет — отсюда «в одном приложении работает, в другом нет». Проверяется тем, что mode присутствует в обеих сторонах:
grep -o '"mode":"[a-z-]*"' /usr/local/x-ui/bin/config.json
curl -s http://127.0.0.1:2096/json/<subId> | jq '.outbounds[0].streamSettings.xhttpSettings'
Куда смотреть:
journalctl -u x-ui -n 100 --no-pager # панель и sub-сервис
tail -f /usr/local/x-ui/bin/access.log # подключения, email виден в строке
tail -f /usr/local/x-ui/bin/error.log # ошибки ядра
x-ui # меню: рестарт, порт, сброс пароля, бэкап
Пути к логам берутся из блока log конфига Xray и относительны рабочему каталогу ядра — если вы его меняли, ищите файлы там, куда указали.
Привычка, экономящая нервы: перед любой правкой в проде — cp /etc/x-ui/x-ui.db /root/x-ui.db.$(date +%F-%H%M), и сразу выпишите себе команду отката в тот же терминал, чтобы не сочинять её в момент, когда всё уже лежит.