Блог Clearway

3x-ui: подключение клиентов — ссылка, подписка и лимиты

Коротко

Разовому клиенту хватит прямой ссылки vless://… из панели. Всем, кому вы продаёте доступ, выдавайте подписку https://sub.example.com/sub/<subId>: она позволяет менять ноду, домен и Reality-ключи без перевыпуска ключей, показывает в приложении остаток трафика и дату окончания и отзывается одним переключателем. Включается в Panel Settings → Subscription (Listen IP 127.0.0.1, порт 2096, путь /sub/), наружу публикуется через nginx на отдельном домене с Cache-Control: no-store. Лимиты живут на клиенте: totalGB (в API — байты), expiryTime (unix в миллисекундах; отрицательное значение = отсчёт с первого подключения), limitIp (работает только при включённом access-логе Xray). Всё ниже — про ветку 3x-ui 2.x.

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/БДСмысл
Emailemailуникальный ID в пределах панели, он же ключ статистики и строка в access-логе. Ставьте техничный идентификатор (u1042, tg_38291), не почту клиента
ID / PasswordidUUID для VLESS/VMess, пароль для Trojan/Shadowsocks
Flowflowxtls-rprx-vision — только TCP + Reality/TLS. Для WS/gRPC/XHTTP поле обязано быть пустым, иначе соединение не поднимется
Subscription IDsubIdключ подписки. Одинаковый subId в разных инбаундах = один URL со всеми конфигами
Total GBtotalGBв UI гигабайты, в API байты. 0 = безлимит
Expiry DateexpiryTimeunix в миллисекундах. 0 = бессрочно, отрицательное = отсчёт с первого коннекта
IP LimitlimitIpмаксимум одновременных IP; требует access-лога Xray
Resetresetпериод автосброса трафика в днях, 0 = не сбрасывать
Enableenableрубильник доступа

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 IP127.0.0.1порт 2096 наружу не светим
Port2096
Path/sub/должен совпасть с location в nginx
JSON Path/json/полный конфиг Xray для клиентов, которым нужен JSON
Certificate / Keyпустосертификат держит nginx
URIhttps://sub.example.com/sub/что панель подставит в кнопку «копировать подписку»
Update Interval12часы, уезжает в заголовок profile-update-interval
Enable Encryptionbase64plain тоже читают почти все; 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; }
}

Три вещи, на которых спотыкаются чаще всего:

  1. proxy_pass http://127.0.0.1:2096; — без пути и без слэша в конце. Стоит дописать / — nginx отрежет префикс /sub/, и панель ответит 404.
  2. add_header внутри location отменяет все add_header, унаследованные от server. Если у вас там HSTS и прочее — продублируйте их в этом же блоке.
  3. 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
Бэкап в TelegramGET /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), и сразу выпишите себе команду отката в тот же терминал, чтобы не сочинять её в момент, когда всё уже лежит.

Частые вопросы

Чем 3x-ui подписка принципиально лучше прямой ссылки?

Тем, что конфиг остаётся под вашим контролем. Прямая ссылка — снимок параметров на момент выдачи: смена домена, ротация Reality-ключей или переезд на другой IP требуют перевыпуска всем вручную. Подписка отдаёт актуальный список при каждом обновлении, умеет складывать несколько нод в один URL и передаёт приложению остаток трафика и дату окончания. Минус ровно один: домен подписки — дополнительная точка отказа, которая обязана быть доступна пользователю всегда, в том числе когда сама нода уже недоступна.

Можно ли одной ссылкой отдать клиенту несколько серверов?

Да. Заведите клиента в каждом нужном инбаунде и поставьте всем одинаковый subId — подписка /sub/<subId> вернёт все конфиги списком, и в приложении это выглядит как несколько серверов в одном профиле. Учтите: трафик и срок считаются по каждой записи отдельно, общего лимита на профиль в 3x-ui нет. Значения totalGB и expiryTime придётся синхронизировать самому, обычно скриптом через updateClient.

Почему приложение не показывает остаток трафика и дату?

Три причины по частоте. Первая — клиент не читает заголовок Subscription-Userinfo, лечится включением Show Info. Вторая — заголовок теряется по дороге: сравните вывод curl -sI напрямую к 127.0.0.1:2096 и через публичный домен, разница укажет на прокси или CDN. Третья — у клиента totalGB=0 и expiryTime=0, то есть безлимит и бессрочно, и приложению просто нечего рисовать.

Как выдать доступ на 30 дней с отсчётом от первого подключения?

Поставьте expiryTime отрицательным: -2592000000, это 30 дней в миллисекундах. Панель трактует такое значение как длительность и записывает реальную дату окончания в момент первого коннекта — ключ можно отдавать заранее, он не сгорит на полке. Побочный эффект для отчётов: до первого подключения в client_traffics лежит отрицательное expiry_time, и наивный datetime(expiry_time/1000,'unixepoch') покажет 1969 год.

Работает ли ограничение по количеству устройств?

Поле IP Limit ограничивает число одновременных IP, но берёт их из access-лога Xray. Если в конфиге ядра loglevel выставлен в none или путь к access-логу пуст, лимит не сработает вообще и панель не покажет ни ошибки, ни предупреждения. Значение 1 даёт ложные срабатывания — мобильная сеть меняет IP при переключении вышки; рабочий минимум 2–3. И помните, что включённый access-лог означает хранение IP пользователей на диске.

Можно ли отдавать подписку с того же домена и порта, что и панель?

Технически да, разными location в одном vhost. На практике разводите: панель — на нестандартном порту, с длинным webBasePath и по возможности под IP-фильтром, подписка — на отдельном домене и публичном 443. Адрес админского интерфейса не должен расходиться по рукам вместе с подписками, а инцидент с доменом подписки не должен отрезать вас от управления нодой.

Что делать, если домен подписки перестал открываться у пользователей?

Сначала разделите проблемы: попросите открыть URL подписки в браузере, там должна быть простыня base64. Если не открывается ни у кого в одной сети, а с зарубежного хоста открывается — вопрос не в панели. Дальше два пути: менять домен и IP, что повторится через некоторое время, либо поставить перед подпиской фронт на адресе, который не фильтруется, — например whitelist-CDN. Правило одно: канал доставки конфигов не должен зависеть от того же IP, что и транспорт, иначе потеря ноды сразу лишает вас возможности переключить клиентов.

Whitelisted-вход для вашего VPN-сервиса

Clearway даёт «белый» CDN-вход перед вашей нодой — устойчивый к троттлингу операторов. Первые 10 ГБ бесплатно, без карты.

Попробовать →