Compare commits

...
185 Commits
Author SHA1 Message Date
hacker07 edabfd9a83 подпись релиза 1.2.1.6
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 17:55:19 +03:00
hacker07 741a70c9bf v1.2.1.6: доступы к панели в конце установки; все файлы релиза скачиваются и сверяются (рабочая заглушка) 2026-10-14 17:53:23 +03:00
hacker07 0d2af45bdb подпись релиза 1.2.1.5
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 17:34:04 +03:00
hacker07 a02650311e v1.2.1.5: пункт меню «Доступ к панели, ссылки и заглушка» + kit local access 2026-10-14 17:31:58 +03:00
hacker07 2b57e6f6d7 подпись релиза 1.2.1.4
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 17:16:10 +03:00
hacker07 df8a99bb78 v1.2.1.4: короткий итог установки, автовыбор в общедоступных сайтах, kit local ports, без дампа ссылок 2026-10-14 17:06:23 +03:00
hacker07 47328b33b3 подпись релиза 1.2.1.3
Синхронизация с автором / sync (push) Failing after 2s
2026-10-14 16:51:06 +03:00
hacker07 776b7a8c5e v1.2.1.3: ссылки и QR — в меню kit (и kit local links), в итоге видно сертификат панели 2026-10-14 16:49:45 +03:00
hacker07 2d63136853 подпись релиза 1.2.1.2
Синхронизация с автором / sync (push) Failing after 2s
2026-10-14 16:32:58 +03:00
hacker07 1b85a4bb23 v1.2.1.2: отказ сертификата на IP — с датой повтора; «без домена» продолжает установку вместо остановки 2026-10-14 16:31:54 +03:00
hacker07 b80c44077c подпись релиза 1.2.1.1 (база автора 3X-UI KIT 1.2.1)
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 16:19:59 +03:00
hacker07 f894b48cd2 сборка на базе автора 1.2.1: версия 1.2.1.1, швы пересобраны, удаление через kit uninstall, номера протоколов через пробел 2026-10-14 16:08:14 +03:00
hacker07 bddef5b8d3 v1.2.27: свой домен вводится заново при промахе; провал сертификата не обрывает установку 2026-10-14 15:51:03 +03:00
hacker07 53cb694206 подпись релиза 1.2.26
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 14:51:24 +03:00
hacker07 5c22cbf4fe v1.2.26: лимит Let-Encrypt называется прямо; готовый сертификат переиспользуется вместо нового заказа 2026-10-14 14:44:47 +03:00
hacker07 a7b4586bc3 подпись релиза 1.2.25
Синхронизация с автором / sync (push) Failing after 3s
2026-10-14 14:38:07 +03:00
hacker07 cec5246e47 release.sh: понятная подсказка при неверной парольной фразе ключа 2026-10-14 14:36:44 +03:00
hacker07 151d510495 v1.2.25: вопрос про панель (IP/домен) в интерактивном выборе домена; понятнее блок про мягкую защиту 2026-10-14 14:34:27 +03:00
hacker07 f9940026dc подпись релиза 1.2.24
Синхронизация с автором / sync (push) Successful in 1s
2026-10-14 14:08:14 +03:00
hacker07 6d7ebf234e FORK.md: как ведёт себя release.sh — проверки, проба GitHub, парольная фраза ключа 2026-10-14 13:59:31 +03:00
hacker07 41092d4cd1 release.sh: проба связи с GitHub (нет связи — проверки пропускаются сразу, без многосекундных зависаний), warn объявлен 2026-10-14 13:58:11 +03:00
hacker07 78cf3c05dc release.sh: исправлен continue вне цикла (обрывал прогон), connect-timeout 30с и 5 повторов на медленной сети 2026-10-14 13:46:19 +03:00
hacker07 83d9b11c5c release.sh: недоступный GitHub не блокирует подпись (предупреждение вместо стопа), реальное расхождение хешей по-прежнему стоп 2026-10-14 13:40:34 +03:00
hacker07 1fbf27e07c release.sh: повторы и понятная ошибка при сетевых сбоях до GitHub 2026-10-14 13:39:23 +03:00
hacker07 9571e93d46 v1.2.24: свой домен → панель и подписка по домену; предупреждение при панели по IP 2026-10-14 13:36:24 +03:00
hacker07 9dfb4e7957 швы: якоря теперь уникальны и в дереве (чистая проверка) 2026-10-14 13:27:36 +03:00
hacker07 51c35dd2c1 v1.2.23: убран дубль доступов, заглушка сразу наша, ссылки только для нескольких протоколов 2026-10-14 13:26:48 +03:00
hacker07 3f3db5b04c Релиз v1.2.22: подпись
Синхронизация с автором / sync (push) Successful in 3s
2026-10-14 12:51:11 +03:00
hacker07 8fd5572450 v1.2.22: 80 в ufw открывается перед выпуском сертификата; точная причина сбоя 2026-10-14 12:24:03 +03:00
hacker07 01ab468e24 Релиз v1.2.21: подпись
Синхронизация с автором / sync (push) Successful in 1s
2026-10-14 00:18:01 +03:00
hacker07 b20fd6af77 v1.2.21: проверка правил 80/443 в ufw после установки 2026-10-14 00:14:32 +03:00
hacker07 bc18fd354e v1.2.21: порты 80/443 и понятная ошибка сертификата; блок про SSH-порт в конце 2026-10-14 00:14:05 +03:00
hacker07 25e25232b2 Релиз v1.2.20: подпись
Синхронизация с автором / sync (push) Successful in 2s
2026-10-14 00:03:57 +03:00
hacker07 6a9a6ed07e v1.2.20: после сноса меню не показывается; update_hint терпит отсутствие /etc/kit 2026-10-14 00:02:26 +03:00
hacker07 d127a92785 v1.2.19: extra объявлена пустой (set -u валил установку) + правило в FORK.md 2026-10-13 23:59:29 +03:00
hacker07 f38aeefcbc Релиз v1.2.18: подпись
Синхронизация с автором / sync (push) Successful in 1s
2026-10-13 23:53:22 +03:00
hacker07 d0bfb2dc38 v1.2.18: свой домен из меню не подменяется запасным сайтом (не перезаписывать выбор, не проверять TLS своего домена) 2026-10-13 23:52:02 +03:00
hacker07 a0df115616 Релиз v1.2.17: подпись
Синхронизация с автором / sync (push) Successful in 1s
2026-10-13 23:45:13 +03:00
hacker07 f4cf157293 release.sh: гейт «определение раньше вызова» больше не обрывает релиз (set -e + пустой grep) 2026-10-13 23:44:16 +03:00
hacker07 73754dbc01 v1.2.17: меню маскировки первым (меню автора не перебивает) + исправление ask_yes 2026-10-13 23:39:50 +03:00
hacker07 c261d27b80 v1.2.16: возвращён ask_yes; гейт на «определение раньше вызова» 2026-10-13 23:34:02 +03:00
hacker07 fa9e7225ea Релиз v1.2.15: подпись
Синхронизация с автором / sync (push) Successful in 2s
2026-10-13 23:22:16 +03:00
hacker07 4a9e452e3e v1.2.15: меню 9/10/11, снос с добивкой, меню маскировки как просил, быстрый скан, швы пересобраны (63 шва, воспроизведение побайтово) 2026-10-13 23:20:18 +03:00
hacker07 d034e207da docs: README под новые возможности; журнал версий в порядке убывания
Синхронизация с автором / sync (push) Successful in 1s
2026-10-13 22:59:43 +03:00
hacker07 22b9925b0c Релиз v1.2.14: подпись
Синхронизация с автором / sync (push) Successful in 2s
2026-10-13 22:57:13 +03:00
hacker07 9aa93fad37 v1.2.14: меню выбора сайта маскировки по скану + снос не обрывается на set -e 2026-10-13 22:52:33 +03:00
hacker07 5558314849 Релиз v1.2.13: подпись
Синхронизация с автором / sync (push) Successful in 3s
2026-10-13 22:40:27 +03:00
hacker07 b778ce4428 v1.2.13: вернул заголовок раздела действий 2026-10-13 22:39:04 +03:00
hacker07 143b41c84e v1.2.13: вопросы до установки (доступы в самом конце) + гейты по выбранным протоколам 2026-10-13 22:38:23 +03:00
hacker07 01662ff039 v1.2.12: не сканировать соседей по подсети, если XHTTP и MTProto не выбраны 2026-10-13 22:33:23 +03:00
hacker07 04a69f3ae3 Релиз v1.2.11: подпись
Синхронизация с автором / sync (push) Successful in 2s
2026-10-12 22:57:16 +03:00
hacker07 0daaf65b5c v1.2.11: снос убирает kit-limit и находит свои службы по шаблонам 2026-10-12 22:55:04 +03:00
hacker07 64796e0f53 Релиз v1.2.10: подпись
Синхронизация с автором / sync (push) Successful in 1s
2026-10-12 22:47:40 +03:00
hacker07 b7834d5f5c FORK.md: правило — main двигается только с подписанным релизом 2026-10-12 22:41:02 +03:00
hacker07 77f98da99a v1.2.10: убран мусор в выводе установщика + гейт на строки-сироты 2026-10-12 22:40:05 +03:00
hacker07 6bb4f55f33 Релиз v1.2.9: подпись
Синхронизация с автором / sync (push) Successful in 1s
2026-10-12 22:38:17 +03:00
hacker07 6fb7d6c59c v1.2.9: снос убирает всё (список по коду установщика) + проверка после сноса + флаг --clean у установщика 2026-10-12 22:35:57 +03:00
hacker07 ad7b66dbf8 Релиз v1.2.8: подпись
Синхронизация с автором / sync (push) Successful in 1s
2026-10-12 22:28:03 +03:00
hacker07 6323c28533 v1.2.8: доступ к панели в конце установки; kit local uninstall + пункт 10 в меню (SSH не трогает) 2026-10-12 22:26:47 +03:00
hacker07 6da8650d1d Релиз v1.2.7: подпись (имя 3X-UI_autoVPS, баннер Hacker 07, шапка в двух строках)
Синхронизация с автором / sync (push) Successful in 1s
2026-10-12 21:35:42 +03:00
hacker07 8d470f3eb1 3X-UI_autoVPS: баннер Hacker 07, новое имя вместо 3X-UI KIT, терпимая шапка релиза; -дублирование подсказки SSH; робот без --apply 2026-10-12 21:30:39 +03:00
hacker07 c270b09891 kit help: on/off отдельными строками (проверка справки проходит) 2026-10-12 21:21:43 +03:00
hacker07 a1ce2f1c54 kit help: полный справочник по категориям + проверка, что справка не отстала от команд 2026-10-12 21:21:21 +03:00
hacker07 65f29782fe FORK.md: как работает автоматическая синхронизация на раннере 2026-10-12 21:19:31 +03:00
hacker07 db549348e5 Заглушки: живые кнопки и ссылки с окном «раздел в разработке» 2026-10-12 21:17:55 +03:00
hacker07 7cc7e684d5 v1.2.7: в конце установки — подписка, QR-код и ссылки по протоколам (--no-sub); расписание возвращено на 03:30
Синхронизация с автором / sync (push) Successful in 1s
2026-10-12 21:16:00 +03:00
hacker07 5e316fb1be temp: расписание раз в 5 минут для проверки планировщика
Синхронизация с автором / sync (push) Successful in 1s
2026-10-12 21:11:32 +03:00
hacker07 6b497b047d Робот ежесуточной синхронизации с автором + workflow Gitea Actions (расписание 03:30)
Синхронизация с автором / sync (push) Successful in 2s
2026-10-12 21:10:32 +03:00
hacker07 13c86723be Релиз v1.2.6: подпись
CI / check (push) Canceled after 0s
2026-10-12 20:54:25 +03:00
hacker07 96823d74f1 README переписан целиком (команда, флаги, протоколы, заглушки, команды, канал) + свои картинки 2026-10-12 20:42:07 +03:00
hacker07 ca5a57db55 v1.2.6: заглушка №1 — дата-аналитика вместо хостинга; гейт по переводам строк выше проверки коммита 2026-10-12 20:37:50 +03:00
hacker07 628130992d LF во всех файлах релиза + гейт в release.sh (CR ломает суммы и запуск на сервере) 2026-10-12 20:31:20 +03:00
hacker07 445c066791 v1.2.6: 10 ИТ-сайтов-заглушек вместо авторских + умолчание протоколов REALITY и AWG-3.1 2026-10-12 20:30:14 +03:00
hacker07 1fdc4945f1 v1.2.6: вопрос про смену порта SSH вместо второго порта; меню и вопросы больше не попадают в значения переменных 2026-10-12 20:25:15 +03:00
hacker07 cc17b11bd1 v1.2.6: вопрос о протоколах при установке (+--yes, --harden) и правка разбора ответов 2026-10-12 20:22:17 +03:00
hacker07 4ed9c5ceb5 v1.2.6: обёртка спрашивает про ping, автообновления, порт SSH и пароль 2026-10-12 20:14:07 +03:00
hacker07 0f31fc1739 docs: шапка README с предупреждением о форке + пояснение в FORK.md
CI / check (push) Canceled after 0s
2026-10-12 20:01:01 +03:00
hacker07 619b2120ce docs: FORK.md под новую установку одной командой и все команды kit local
CI / check (push) Canceled after 0s
2026-10-12 19:57:46 +03:00
hacker07 b61a3225d8 Релиз v1.2.5: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:46:46 +03:00
hacker07 70e2a499c7 v1.2.5: суммы релиза в POSIX-виде (без звёздочки) — kit сверяет имя строго 2026-10-12 19:40:04 +03:00
hacker07 a70dd14542 Релиз v1.2.4: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:37:43 +03:00
hacker07 934077c56f v1.2.4: флаг --verify-only и проверка подписи до всех предполётных проверок 2026-10-12 19:37:36 +03:00
hacker07 ada50ae24e Релиз v1.2.3: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:36:08 +03:00
hacker07 2e875aaa2f v1.2.3: установка одной командой (3x-ui-local.sh) — релиз из нашего канала + защита сервера 2026-10-12 19:36:00 +03:00
hacker07 88af9444c0 docs: команды защиты сервера в FORK.md
CI / check (push) Canceled after 0s
2026-10-12 19:25:19 +03:00
hacker07 e0ddf8fe25 Релиз v1.2.2: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:24:26 +03:00
hacker07 c83fde40e4 v1.2.2: команды защиты сервера (ssh/ping/passwd/upgrades/hardening из vps-setup, безопаснее) 2026-10-12 19:24:18 +03:00
hacker07 211053a36b Релиз v1.2.1: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:04:16 +03:00
hacker07 80258f1d45 fork: полный набор швов + свои записи о версии (FORK-CHANGELOG.md) 2026-10-12 19:04:01 +03:00
hacker07 7b30cd9b23 fork: швы поверх upstream 1.2 — источник Gitea, ключ подписи форка, версия 1.2.1 2026-10-12 19:03:16 +03:00
hacker07 45b8d1c783 fork: инструменты сборки и локальные дополнения (швы, синхронизация с автором) 2026-10-12 15:16:32 +03:00
maxdmitrov 47cdfd34a5 Подпись релиза 1.2 (с подсказкой о новой версии)
CI / check (push) Canceled after 0s
2026-10-06 19:33:59 +03:00
maxdmitrov ddfbabf0c2 kit: подсказка о новой версии в меню, после kit user add и kit net 2026-10-06 19:32:49 +03:00
maxdmitrov a94f755317 CHANGELOG 1.2: «Спасибо …» в конце пункта, чтобы терминал не показывал имена 2026-10-06 19:30:32 +03:00
maxdmitrov cd99a12c29 CHANGELOG 1.2: короче и от первого лица; README: без строки про вопросы по XKeen 2026-10-06 19:30:23 +03:00
maxdmitrov 2c6549a3af README: протоколы в строку, «распознают», без блока про DNS, диагностика в командах KIT, KIT заглавными 2026-10-06 19:26:04 +03:00
maxdmitrov 64fffa5e73 Подпись релиза 1.2 2026-10-06 19:19:12 +03:00
maxdmitrov 5913dcee09 CI: тесты проверки копии для --restore на GNU grep и bash 2026-10-06 18:58:50 +03:00
maxdmitrov 018d9cefa2 --restore: файлы настроек копии – только печатные ASCII, нулевой байт больше не выключает проверку
Нулевой байт в kit.env, install-result.env или kit-backup.env заставлял GNU grep считать
файл двоичным и молчать: safe_env «проходил», а bash при «.» выкидывал нулевые байты
и выполнял остальное (лишние имена вроде PATH и KIT_CLI_URL).

- проверка архива отклоняет три файла настроек с байтами вне 0x20–0x7E и \n (и больше 64 КиБ),
  3x-ui.txt – не UTF-8 или с управляющими символами (кроме \n и \t), больше 1 МиБ;
  проверяются все записи с таким именем, в том числе повторные;
- safe_env: сначала tr – только печатные ASCII и \n, затем grep -a под LC_ALL=C;
- grep -a там, где kit и установщик читают свои файлы настроек (kit.sh: замена токена API);
- отрицательные тесты: \0, \r, \x1b, \x80, \xff в начале, в середине, в конце, отдельной строкой
  и в значении в каждом из трёх файлов, повторная запись в архиве, 3x-ui.txt.
2026-10-06 18:58:38 +03:00
maxdmitrov 436acffc22 Мануал: --restore проверяет и значения в настройках копии 2026-10-06 18:40:45 +03:00
maxdmitrov 25df91e79a Hysteria2: заглушка байт в байт как у nginx
$(cat …) срезал последний перевод строки, и страница masquerade была на
байт короче той, что отдаёт nginx. Читаю файл целиком (установщик и
kit net masq on), запасная запись страницы – без лишнего перевода строки.
2026-10-06 18:40:01 +03:00
maxdmitrov 387d6ae66e nginx: путь панели и подписки перед сборкой конфига – только буквы, цифры, _ и -
XUI_WEB_BASE_PATH из install-result.env (при --restore – из копии) шёл в
«location ... {» как есть: строка с фигурными скобками добавляла в nginx
чужие директивы. Значение теперь проверяется при --restore (прошлая правка)
и ещё раз там, где собирается nginx: setup_nginx в 3x-ui.sh, kit net panel
и пути подписки в kit.
2026-10-06 18:38:08 +03:00
maxdmitrov 397804faa7 --restore: значения в файлах настроек копии – по формату каждого поля
Имена в kit.env, kit-backup.env и install-result.env проверялись по белому
списку, а значения – нет: MTPROTO_INNER из чужой копии попадал в выражение
sed в kit (флаг e – выполнение команды). Теперь у каждого имени свой формат
(порты – цифры, пути – буквы и цифры, хосты, флаги – допустимые слова),
кавычки – только пустые (''), неверное значение – копия отклоняется.
kit: sed с портом MTProto – только если порт число. Тесты на все поля.
2026-10-06 18:36:48 +03:00
maxdmitrov 5449a73fd8 Заглушка: окончания названий распределены ровнее
«Studio» было в вариантах у девяти тем из двадцати и заканчивало каждое пятое
название. Теперь ни одно окончание не встречается больше чем в двух вариантах
(около 3% названий); тест проверяет, что самое частое окончание – меньше 8%.
2026-10-06 17:06:26 +03:00
maxdmitrov f7a8475c88 kit-sub: Connection: keep-alive и ALPN http/1.1, как у nginx
На ответах, после которых соединение остаётся открытым, – «Connection: keep-alive»,
перед закрытием – «Connection: close» (раньше заголовка не было вовсе). На своём TLS
(--multi-port) kit-sub соглашается на ALPN http/1.1, как nginx без http2; клиент,
предложивший только h2, получает соединение без ALPN. Тесты – в kitsub_http_test.py.
2026-10-06 17:03:57 +03:00
maxdmitrov 220d82c01e kit-sub: на мусорную строку запроса – «HTTP/1.1 400 Bad Request», как у nginx
Python считал такой запрос HTTP/0.9 и отвечал одним телом, без строки статуса и
заголовков. Теперь ошибки разбора запроса идут со строкой статуса HTTP/1.1,
Server: nginx и Connection: close. Тесты – в tools/test/kitsub_http_test.py.
2026-10-06 17:02:34 +03:00
maxdmitrov 1aa9a84e96 --restore: копия с пустой папкой сертификата отклоняется до установки
Архив с root/cert/self или root/cert/custom без fullchain.pem/privkey.pem проходил
проверку, ставил 3X-UI и падал на копировании сертификата. Теперь проверка архива
требует оба непустых файла, а для копии со своим сертификатом (BACKUP_SSL=custom) –
папку root/cert/custom. Отрицательные тесты – в tools/test/restore_archive_test.py.
2026-10-06 17:00:59 +03:00
maxdmitrov 25848a24c1 --restore: самоподписанный сертификат, если Let's Encrypt не выдал его на новый IP
Подключения из копии ссылаются на /root/cert/ip, а без файла Xray не стартует,
хотя установщик писал «Готово». Теперь перед запуском панели проверяю файлы
сертификатов из базы; если нет – кладу самоподписанный на тот же путь (только
/root/cert/ip или /root/cert/self) и закрепляю отпечаток в подключениях, как при
обычной установке. Предупреждение – сразу и в блоке «Внимание».

В конце жду процесс Xray: если не поднялся – вместо «Готово» красное сообщение
и код возврата 1. Тест: tools/test/restore_archive_test.py.
2026-10-06 16:58:53 +03:00
maxdmitrov 972757ed75 kit net: перезапуск ядра Xray, если правка на ходу закрыла порт; kit check видит упавшее ядро и UDP
Панель 3.9.0 меняет подключение через API ядра: старое удаляет, а новое с маскировкой
Hysteria2 ядро 26.6.27 не принимает, и 443/udp закрывается (kit net masq on, kit net fp,
kit net on). Теперь после правки kit сверяет порты и при нужде перезапускает ядро
(server/restartXrayService, запасной путь – systemctl restart x-ui).

kit check: процесс Xray (дочерний у x-ui) и порты всех включённых подключений,
TCP и UDP отдельно (nginx на 443/tcp больше не считается за Hysteria2). kit fix
перезапускает ядро. Тест: tools/test/kit_ports_test.py.
2026-10-06 16:50:04 +03:00
maxdmitrov 159c1b271d Hysteria2: отдельный установщик убран из 1.2, на его месте заглушка
scripts/hysteria2.sh остаётся в подписанном релизе: старая `hy2 update`
скачает его, сверит SHA256, запустит `__update_binary`, получит код 1
с подсказкой и не заменит команду hy2. Заглушка ничего не удаляет и не
останавливает. Hysteria2 по-прежнему входит в основную установку.

Убраны мануал hysteria2.md и его картинки, проверка одинаковых ключей
kit/hy2 в release.sh и CI, строка Hysteria в check-upstream.sh.
2026-10-06 16:10:07 +03:00
maxdmitrov 888a4cb46b Заглушка: случайная страница при установке вместо девяти фиксированных
stub_site собирает страницу из частей: 20 тем (кафе, пекарня, фотограф, заметки,
мастерские, документация и т. п.) × 5 вёрсток = 100 вариантов, плюс случайное
название, подзаголовок, 3 пункта из 5, адрес, палитра, шрифт и скругление.
Без внешних ресурсов, UTF-8. Вымышленный бренд из старых шаблонов убран, в том числе
из запасной страницы kit net masq. Страница сохраняется в /var/www/kit/index.html
в обоих режимах: её показывают nginx и Hysteria2, её берёт kit net masq, её
возвращает --restore. Тест: tools/test/stub_test.py.
2026-10-06 15:54:00 +03:00
maxdmitrov da07108a83 core-compat: mihomo и sing-box – только с проверкой SHA256
Если GitHub не прислал digest архива, клиенты больше не ставятся без проверки:
скрипт останавливается с понятной ошибкой. Сумму можно закрепить вручную
(MIHOMO_SHA256, SINGBOX_SHA256), формат – 64 hex.
2026-10-06 15:43:30 +03:00
maxdmitrov 42be16a5a2 kit-sub: ответы по HTTP/1.1, как у nginx
В режиме --multi-port kit-sub отвечал строкой статуса HTTP/1.0 и «Server: nginx »
с пробелом. Теперь protocol_version = HTTP/1.1, заголовок Server без хвоста;
на ответы, после которых соединение закрывается (ошибки разбора, POST и т. п.),
добавлен Connection: close. Тест: tools/test/kitsub_http_test.py.
2026-10-06 15:42:30 +03:00
maxdmitrov 58fc3b4704 CHANGELOG: опечатка «иностранских» → «иностранных» 2026-10-06 15:41:20 +03:00
maxdmitrov 43835928bb --restore: файлы настроек из копии – только по белому списку имён, без подключения в основной shell
kit-backup.env больше не подключается через «.» в установщике: значения читаются
в подоболочке (env_get). safe_env проверяет имена для каждого файла (kit-backup.env,
install-result.env, kit.env); лишнее имя – «Это не резервная копия 3X-UI KIT…».
RESULT, XUI_ENV, PATH, IFS, KIT_* из архива не принимаются, HOST в kit.env должен
совпадать с адресом копии, порт панели – число. Тест: tools/test/restore_env_test.py.
2026-10-06 15:41:16 +03:00
maxdmitrov 7741f26eb4 Убрана фраза «проверено настоящими клиентами» из changelog, инструкции, комментария и README матрицы 2026-10-06 15:23:34 +03:00
maxdmitrov d2a299a088 README: плашка со звёздами 2026-10-06 15:22:32 +03:00
maxdmitrov 69796f9ed8 README: «Возможности» без команд, список 11 протоколов, переезд на новый сервер 2026-10-06 15:22:03 +03:00
maxdmitrov 1f495d70f3 README: предупреждение под шапкой, без меню-ссылок, плашка версии, новые «Возможности», без отдельной установки Hysteria2 2026-10-06 15:16:36 +03:00
maxdmitrov 53823c6734 README: врезка про XKeen в одну строку 2026-10-06 15:09:26 +03:00
maxdmitrov 5dc1140bf3 README: донат через Tribute вместо CloudTips 2026-10-06 15:08:32 +03:00
maxdmitrov 51388780dc README: команды kit свёрнутыми группами (details), все основные на месте 2026-10-06 15:07:15 +03:00
maxdmitrov 6e3da5774d README: команды kit коротко (8 строк), полный список – в инструкции 2026-10-06 15:05:38 +03:00
maxdmitrov a2e67e2521 README: команды kit по группам (пользователи, сеть, раздельная маршрутизация, обслуживание, hy2) таблицами 2026-10-06 15:04:14 +03:00
maxdmitrov 795a71f54b README: kit net split в таблице команд 2026-10-06 15:03:17 +03:00
maxdmitrov da6abc665d CHANGELOG 1.2: маскировка (свой домен, затем соседи по подсети), короче про DNS 2026-10-06 15:02:23 +03:00
maxdmitrov 1b11c966ff README: врезка о версии 1.2 вместо 1.1.2 2026-10-06 14:58:01 +03:00
maxdmitrov 635c909426 Откат 28a7917: формат sing-box для Karing и Hiddify не применяется клиентами (Karing игнорирует правила, Hiddify не показывает серверы) 2026-10-06 14:12:41 +03:00
maxdmitrov 8e2215f9f4 kit net split: Karing и Hiddify получают конфиг sing-box с правилами (route, dns без утечек, наборы .srs через VPN); kit net split check сверяет и наборы sing-box; тесты 2026-10-06 13:58:01 +03:00
maxdmitrov bb42586454 hysteria2.sh: убран перенос старого файла настроек прежнего проекта 2026-10-06 13:48:02 +03:00
maxdmitrov c1710d10d5 kit-sub: JSON-подписка с Content-Type text/plain определяется по телу и получает encryption none; RecursionError ловится; интеграционные тесты 2026-10-06 13:28:48 +03:00
maxdmitrov 81ae0337b4 kit-sub: JSON-подписка панели – vless без encryption получает none (обе формы, непустое не трогаем) 2026-10-06 13:25:14 +03:00
maxdmitrov 7d6f13d315 --restore: адреса из таблицы hosts и link_host меняются при смене IP; проверка панели перебирает http и https, пока панель не ответит 2026-10-06 12:08:36 +03:00
maxdmitrov 3efbc327f5 kit: при отказе токена API берёт новый командой панели (cli-fallback) и сохраняет в install-result.env 2026-10-06 11:21:28 +03:00
maxdmitrov 73bbaadd48 kit net split: маршрутизация для Happ (заголовок Routing) и JSON-подписки Xray через настройки панели, kit net split apply; проверено клиентами Mihomo и Xray 2026-10-06 11:16:20 +03:00
maxdmitrov d74ef02d65 kit net split: правила только приложениям на ядре Mihomo (Karing и другие на sing-box получают подписку прежней) 2026-10-05 23:10:15 +03:00
maxdmitrov 3a7b760dfc Тексты: без ссылок на операторов и блокировки в мануале и комментарии установщика 2026-10-05 21:57:06 +03:00
maxdmitrov a8a4aae6b3 kit net split: полный список популярных иностранных сервисов, проверка GEOSITE и GEOIP по базе, прощающий разбор файла, проверка прав на файл 2026-10-05 21:54:28 +03:00
maxdmitrov 88f2c1066b kit net split: больше категорий в шаблоне (комментарии), проверка названий GEOSITE по базе в kit net split check, честное сообщение об ошибке 2026-10-05 21:50:38 +03:00
maxdmitrov 3ea6c0c868 kit net split: раздельная маршрутизация в Clash-подписке (rules.yaml, без утечек DNS), в копию и восстановление; kit backup работает не только из /; понятное сообщение при отказе панели 2026-10-05 21:46:51 +03:00
maxdmitrov 1c71ff5c24 tools/core-compat.sh: проверка нового ядра Xray клиентами Mihomo и sing-box с подменой ядра и возвратом; check-upstream показывает версии клиентов 2026-10-05 18:15:45 +03:00
maxdmitrov 31bc2d5480 Установщик: «рекомендуем» у своего домена, без «mihomo» в строке о проекте, без ссылок «открыть в приложении» и без деления протоколов на группы; обновление панели проверяет подключения клиентом и откатывает при сбое; README: отдельная Hysteria2 наравне с 11 протоколами 2026-10-05 18:01:28 +03:00
maxdmitrov 97718bd3e6 Страница подписки: адреса Clash и JSON работают через kit-sub и nginx (с «Авто» и DNS), подключаются при установке и при kit update 2026-10-05 17:48:10 +03:00
maxdmitrov 2a8cfe6ea2 kit-sub: файлы страницы подписки для браузера (assets); ссылки «открыть в приложении» для Hiddify, Clash Verge, FlClash, Mihomo Party, v2rayNG 2026-10-05 17:40:48 +03:00
maxdmitrov c4e6ed1a5d Ссылки «открыть в приложении» (Karing, Happ) рядом с подпиской: в kit user link и в итоге установки 2026-10-05 17:39:43 +03:00
maxdmitrov e818d22c65 Сертификат домена: до трёх попыток при временных сбоях Let's Encrypt 2026-10-05 17:20:00 +03:00
maxdmitrov 7425c672b8 Правки по независимому аудиту: --restore (сертификат домена, xver при сбое, LINK_HOST), REALITY-2 при панели по домену, резервная копия базы через SQLite, ответы kit-sub как у nginx, меньше параллельность подбора сайта, перемешивание запасных сайтов, нейтральные тексты 2026-10-05 16:51:26 +03:00
maxdmitrov 00319a6655 Панель и подписка по домену или по IP: kit net panel, --panel-on при установке, выбор в диалоге, возврат при --restore 2026-10-05 16:38:41 +03:00
maxdmitrov f22e043902 Тексты: нейтральные формулировки 2026-10-05 16:28:55 +03:00
maxdmitrov 26019b39f4 README: подсказка про выбор протокола 2026-10-05 16:28:55 +03:00
maxdmitrov 3422bee5e1 README: подсказка про выбор протокола (2) 2026-10-05 16:28:55 +03:00
maxdmitrov 7161ef6fe8 systemd старше 247: установщик останавливается с понятным сообщением (kit-sub без root требует LoadCredential), kit update не трогает юнит 2026-10-05 16:01:52 +03:00
maxdmitrov 21c3ac18c2 DNS: безопасный dns в Clash-подписке (kit-sub), DoH в Xray JSON (kit net dns), AmneziaWG вне автовыбора; проверено клиентами Mihomo и Xray 2026-10-05 16:00:59 +03:00
maxdmitrov eb152b201f Hysteria 2.13.0: закреплена проверенная версия (суммы сверены, чистая установка и подключение клиента проверены) 2026-10-05 11:18:46 +03:00
maxdmitrov a6b70c154a shellcheck: SC2155 в kit sni rotate 2026-10-04 20:46:31 +03:00
maxdmitrov 2b5b9c8d9e manuals: меню kit, kit net, kit check --deep/--fix, --restore 2026-10-04 20:46:03 +03:00
maxdmitrov 58af616ced changelog 1.2: уточнил, за что спасибо 2026-10-04 20:45:32 +03:00
maxdmitrov 0f5912b198 README и changelog 1.2: меню kit, kit net, --restore, раздел «Если связь пропала» 2026-10-04 20:45:24 +03:00
maxdmitrov 85f3535a1c Hysteria2: ответ страницей сайта на чужой HTTP/3-запрос (masquerade, kit net masq); kit check --deep проверяет и Hysteria2, с повтором при перезапуске ядра 2026-10-04 20:44:45 +03:00
maxdmitrov 844ac083b3 Установщик: соседи для XHTTP и MTProto, повтор итоговой проверки, имена с сомнительными словами не берём; kit net site предупреждает о запасном сайте 2026-10-04 20:41:24 +03:00
maxdmitrov 4c795ea6e0 kit check: предупреждения о сайте-бренде на чужом IP и о запасных портах с пустым ответом 2026-10-04 20:36:12 +03:00
maxdmitrov f7ac1ddf49 Установщик: шаги [1/6]–[6/6], выбор маскировки (домен, сосед по подсети, свой сайт), блок ссылок по группам, итог с блоком «Внимание», адрес в ссылках LINK_HOST (домен в ссылках и MTProto), REALITY-2 не ставится по умолчанию, новая заглушка вместо «Welcome to nginx» 2026-10-04 20:28:24 +03:00
maxdmitrov 346159210b Подбор сайта по соседям: блок /23, проверка ответа на «/», без Cloudflare и брендов; подтверждение перед сменой 2026-10-04 20:22:54 +03:00
maxdmitrov 0a9cce18b6 kit 1.2: меню как у x-ui (kit без аргументов), kit net (протоколы, порты, сайт, отпечаток, вкл/выкл), блок ссылок по группам, kit check --fix, kit update --panel; версия 1.2 2026-10-04 20:20:20 +03:00
maxdmitrov 4246031ff9 Второй REALITY на высоком порту: ставится установщиком, kit reality add для существующих серверов; kit check --deep подписывает XHTTP и порт 2026-10-04 19:57:17 +03:00
maxdmitrov 4b336d7395 --restore: чистка WAL-файлов перед подменой базы (иначе база «битая»), включение таймера общего лимита 2026-10-04 19:39:02 +03:00
maxdmitrov 31db2d30a4 --restore: восстановление из kit backup возвращено (проверка архива, смена IP), будет проверено на чистом сервере 2026-10-04 19:28:32 +03:00
maxdmitrov 2bcfb95b05 kit port, kit port set: смена порта подключения с ufw и проверкой занятости по tcp и udp 2026-10-04 19:27:00 +03:00
maxdmitrov c9fc0c25be kit check --deep: порты всех подключений и подключение настоящим клиентом Xray через REALITY и XHTTP 2026-10-04 19:25:24 +03:00
maxdmitrov a19f60c8b0 kit sni rotate --nearby: без динамических доменов, только сайты с проверенным сертификатом; подсказка проверить сайты 2026-10-04 19:23:17 +03:00
maxdmitrov d6109a9b3a kit-sub: в Clash-подписке группа «Авто» (url-test) по умолчанию, ручной выбор протокола остаётся 2026-10-04 19:04:00 +03:00
maxdmitrov 88feba6190 Маскировка: свой домен предлагается первым, предупреждение о чужом сайте; три заглушки под облачный сервис; kit sni и kit sni rotate (в том числе --nearby) 2026-10-04 19:03:01 +03:00
maxdmitrov 5232e431de REALITY: у новых пользователей включён xtls-rprx-vision; kit user vision для существующих; kit check сообщает, у кого он выключен 2026-10-04 02:03:56 +03:00
maxdmitrov 2e40b85fd4 tools/check-upstream.sh: сравнение закреплённых версий с последними релизами 2026-10-04 01:48:13 +03:00
maxdmitrov d751093bbd kit panel update: проверенная версия панели без смены ядра и настроек, откат при сбое; kit fix возвращает проверенное ядро; предупреждения о версии панели; tools/release.sh сверяет суммы 2026-10-04 01:42:55 +03:00
maxdmitrov ec25f0e0e8 3X-UI v3.9.0: закреплён установщик; установка ядра не падает на ответе панели с ошибкой GitHub API 2026-10-04 00:59:03 +03:00
maxdmitrov ca74a1dee3 1.2: kit user link --amnezia и --telegram; kit fix: ставит systemd-timesyncd на Debian 13 без NTP-клиента; kit check проверяет содержимое подписки 2026-10-03 23:23:59 +03:00
maxdmitrov 0f52237286 kit: общий лимит трафика (сумма по записям пользователя), kit user enforce, таймер раз в 5 минут, проверка в kit check и kit fix 2026-10-03 23:20:04 +03:00
maxdmitrov 173c227c2e README: команды kit и hy2 списком с короткими комментариями 2026-10-03 23:16:54 +03:00
maxdmitrov 0e7372efb7 kit user link: в режиме «панель через SSH-туннель» показываются отдельные ссылки на протоколы, --all работает с https-подпиской 2026-10-03 22:35:51 +03:00
maxdmitrov 67bb5bc231 Релиз v1.1.2: подпись
CI / check (push) Canceled after 0s
2026-10-03 21:38:08 +03:00
maxdmitrov 3d3f3e2d9d 1.1.2: исправление установки с сертификатом Let's Encrypt на IP, ufw и порты SSH, параметры, kit-sub, kit user 2026-10-03 21:36:22 +03:00
maxdmitrovandClaude Sonnet 5.5 fe065d445a Баннер: ровная табличка
CI / check (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:05:19 +03:00
maxdmitrovandClaude Sonnet 5.5 71d40360ab Релиз v1.1.1: подпись
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:00:30 +03:00
099117c2a7 1.1.1: kit check и kit fix, заголовки Happ, безопасная загрузка ядра, инструкция XKeen
- kit check и kit fix: проверка служб, сертификата, подписки, сайта маскировки, прав на файлы; безопасная починка
- kit-sub: пропускает заголовки Happ (Routing и др.) до клиентов
- ядро Xray: запасная загрузка через зеркало только с проверкой SHA256, tools/release.sh сверяет суммы с официальными
- установка со своим доменом: при отказе Let's Encrypt предлагается самоподписанный сертификат
- README: инструкция XKeen, команды kit, новый баннер; правки инструкций и changelog

Co-authored-by: xlok1e <124461726+xlok1e@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 16:47:12 +03:00
42 changed files with 9598 additions and 1155 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+32
View File
@@ -0,0 +1,32 @@
name: Синхронизация с автором
# Раз в сутки проверяем обновления у автора проекта и, если они есть, переносим наши правки
# на свежий код и публикуем в ветку dev. Релиз и подпись здесь НЕ делаются: закрытый ключ
# подписи лежит только на рабочей станции владельца.
#
# Ручной запуск: в Gitea → Actions → «Синхронизация с автором» → Run workflow.
on:
schedule:
- cron: "30 3 * * *" # каждую ночь в 03:30 по времени сервера Gitea
workflow_dispatch: {}
push:
paths:
- ".gitea/workflows/fork-sync.yml"
concurrency:
group: fork-sync
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Подтянуть обновления автора, проверить и опубликовать
env:
GITEA_URL: http://10.10.10.54:3000
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
curl -fsSL "$GITEA_URL/hacker07/3X-UI_autoVPS/raw/branch/main/tools/fork-sync-bot.sh" -o /tmp/fork-sync-bot.sh
bash /tmp/fork-sync-bot.sh
+4 -4
View File
@@ -18,16 +18,16 @@ jobs:
run: python3 -m py_compile scripts/kit-sub.py run: python3 -m py_compile scripts/kit-sub.py
- name: Генераторы - name: Генераторы
run: node --test tools/test/*.test.js run: node --test tools/test/*.test.js
- name: Проверка копии для --restore (GNU grep и bash)
run: |
python3 tools/test/restore_env_test.py
python3 tools/test/restore_archive_test.py
- name: Версия в скриптах совпадает с VERSION - name: Версия в скриптах совпадает с VERSION
run: | run: |
v=$(cat VERSION) v=$(cat VERSION)
grep -q "^KIT_VERSION=\"$v\"" scripts/3x-ui.sh grep -q "^KIT_VERSION=\"$v\"" scripts/3x-ui.sh
grep -q "^KIT_VERSION=\"$v\"" scripts/hysteria2.sh grep -q "^KIT_VERSION=\"$v\"" scripts/hysteria2.sh
grep -q "^KIT_VERSION=\"$v\"" scripts/kit.sh grep -q "^KIT_VERSION=\"$v\"" scripts/kit.sh
- name: Ключ подписи одинаковый в kit и hy2
run: |
signers() { awk '/^KIT_SIGNERS=\(/ {on = 1; next} on && /^\)/ {exit} on' "$1"; }
diff <(signers scripts/kit.sh) <(signers scripts/hysteria2.sh)
- name: Подпись релиза совпадает с файлами - name: Подпись релиза совпадает с файлами
run: | run: |
[ -f SHA256SUMS ] || { echo "SHA256SUMS нет – релиз ещё не подписан"; exit 0; } [ -f SHA256SUMS ] || { echo "SHA256SUMS нет – релиз ещё не подписан"; exit 0; }
+3
View File
@@ -0,0 +1,3 @@
*.pyc
.DS_Store
__pycache__/
+32 -20
View File
@@ -1,28 +1,40 @@
# Что нового # Что нового
## v1.2: панель 3X-UI v3.9.0, меню `kit` и маскировка по соседям
* Панель 3X-UI обновлена до v3.9.0, а ядро Xray я оставил проверенным: новое не всегда лучше, это показал тест. `kit update --panel` обновит панель без смены ядра и настроек, а при сбое откатит всё назад.
* `kit` без аргументов открывает меню, как у x-ui: нажали цифру – готово. Всё про сеть собрано в `kit net`: порты, протоколы, сайт маскировки, отпечаток. `kit check --deep` подключается к серверу настоящим клиентом, а `--fix` лечит безопасное.
* Раздельная маршрутизация: `kit net split on` – и через VPN идёт только список популярных иностранных сервисов (нейросети, видео, соцсети, мессенджеры, GitHub, Google и другие), а всё остальное напрямую. Правила приезжают вместе с подпиской: Mihomo-приложения берут их из подписки, Happ – из заголовка, приложения на JSON-подписке Xray – из конфига. Список лежит в `/etc/kit-sub/rules.yaml`, проверка – `kit net split check`, после правки – `kit net split apply`. У v2rayN, Karing и Hiddify подписка прежняя. Спасибо [VasilevVitalii](https://github.com/VasilevVitalii) за идею и первый набросок.
* Маскировка: у каждого сервера теперь своя заглушка, она собирается при установке из двадцати тем и пяти вёрсток со случайным названием и цветами (пекарня, переводчик или мастерская по велосипедам – как повезёт). Со своим доменом на сервере стоит ваш сайт, без него – сайт среди соседей по подсети. REALITY теперь с `xtls-rprx-vision`, а Hysteria2 отвечает на чужой запрос той же страницей, байт в байт.
* Подписка и переезд: ссылка подписки в браузере открывается страницей с трафиком, сроком и кнопками «открыть в приложении», в Clash есть «Авто», а общий лимит считается по всем подключениям пользователя. Со своим доменом панель и подписку можно открывать по домену: `--panel-on domain` при установке или `kit net panel domain` потом. Вернулся `--restore`: чистый сервер поднимается из `kit backup`, как из резервной копии настроек телефона. Если Let's Encrypt не выдал сертификат на новый IP, я ставлю самоподписанный и честно об этом говорю. Спасибо [xlok1e](https://github.com/xlok1e) за исправление времени на Debian 13.
* Исправил то, что спотыкалось: `kit backup` теперь работает из любой папки, `kit net masq on` не роняет Hysteria2, JSON-подписка снова открывается в приложениях на Xray, а `kit check` смотрит, запущено ли ядро и слушает ли каждое подключение свой порт.
* Отдельный установщик Hysteria2 ушёл на пенсию: он и так входит в основную установку. Кто ставил его отдельно – сервер работает как работал, а `hy2 update` расскажет, что дальше. Нужен один Hysteria2 без панели – официальный установщик: `bash <(curl -fsSL https://get.hy2.sh/)`.
Сервер обновится сам этой ночью, нетерпеливым – `kit update`.
## v1.1.2: исправление установки и мелкие починки
* Исправил «Ошибка в строке 452»: установка спотыкалась о собственную проверку, как человек о порог в тёмной комнате. Теперь идёт ровно. Спасибо [ram0973](https://github.com/ram0973), который сообщил первым, и [xlok1e](https://github.com/xlok1e) за точную правку.
* `ufw` больше не запирает вас снаружи на нестандартном порту SSH (сервер, закрытый от хозяина, – так себе охрана). Имя с точкой не цепляет чужих, а `kit fix` и откат обновления доводят дело до конца.
* Параметры прощают `--port=8443` и `VPN.Example.COM`: я перестал придираться к регистру.
Сервер обновится сам этой ночью, нетерпеливым – `kit update`.
## v1.1.1: kit check и kit fix, исправления для Happ и скачивания ядра
* `kit check` осматривает сервер, как терапевт: «где болит?». `kit fix` лечит безопасное, без наркоза и без героизма.
* Заголовки Happ доходят до клиентов, а не теряются по дороге, как носки в стиралке. Спасибо [xlok1e](https://github.com/xlok1e) за находку.
* Xray ставится, даже когда GitHub молчит: через зеркало, но только с проверкой SHA256. Чужой бинарник не пройдёт, я никому не верю.
* Со своим доменом установка не рвётся, если Let's Encrypt отказал в сертификате на IP. Инструкция для XKeen теперь в README.
## v1.1: безопасность, автообновление и новый генератор для роутера ## v1.1: безопасность, автообновление и новый генератор для роутера
**Новое** * Обновления приходят сами по ночам и только с моей подписью: «мам, удали старый VPN» больше не нужно. Не любите сюрпризы: `kit update --manual`. Копия сервера: `kit backup`.
* Маскировка на выбор: чужой сайт или свой домен. На случайный заход сервер изображает кофейню, фотографа или блог про Linux.
* Один генератор для роутера вместо двух: Keenetic и OpenWrt, Xray или Mihomo. Меньше вкладок, меньше «а я точно там нажал?».
* Безопасность: установщики проверяются по SHA256 (подмену, как незнакомца с «выгодным предложением», не пускаем), Hysteria2 не исполняет почту как команду, `kit-sub` больше не root.
* Обновления приходят сами. Раз в сутки ночью сервер проверяет подпись автора и обновляет `kit`, а пользователи и ссылки остаются прежними. «Мам, удали старый VPN, вот новый» больше не нужно. Подпись не сошлась или подписка замолчала – откат. Не любите сюрпризы: `kit update --manual`. С 1.0 один раз: `curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update`
* Копия сервера одной командой. `kit backup` складывает подключения, ключи и пользователей в один файл. Восстановление из него придёт в 1.2: решили выпускать только то, что успели проверить на настоящем сервере, а не то, что «вроде работает».
* Маскировка на выбор. Установщик спрашивает: стандартный чужой сайт (работает сразу) или свой домен (надёжнее: настоящая страница и сертификат Let's Encrypt). Без вопросов: `--domain vpn.example.com`. Теперь «под кого косим» решаете вы, а не скрипт за вас.
* Заглушка с характером. На случайный заход сервер показывает кофейню, фотографа или блог про Linux. Тысячи серверов 3X-UI KIT больше не выглядят как близнецы.
* Один генератор для роутера вместо двух. Выбираете Keenetic или OpenWrt, ядро Xray или Mihomo, вставляете ссылки и получаете конфиг плюс команду, которая сама положит его на роутер. Меньше вкладок, меньше «а я точно там нажал?». Старые адреса генераторов продолжают работать.
* OpenWrt через Nikki (бета). Генератор собирает профиль Mihomo и команду, которая включит его и перезапустит Nikki. Xray на OpenWrt не предлагаем: Nikki работает только на Mihomo, и мы не даём нажать то, что не заработает.
**Исправлено**
* Установщик 3X-UI сверяется по SHA256. Если его кто-то подменит, скрипт посмотрит на него как на незнакомца в чате с «выгодным предложением» и запускать не станет. Файлы теперь берём из тега версии, а не из `main`.
* Hysteria больше не проверяется по справке, которую сама же принесла. Контрольная сумма записана в скрипте. Проверять человека по его же рекомендательному письму – так себе идея.
* Hysteria2 перестала исполнять почту. Особо изобретательный адрес раньше мог выполниться как команда. Теперь `me@gmail.com;rm -rf` получит вежливый отказ.
* Подписка больше не root. Даже если `kit-sub` взломают, ключей от сервера у него нет. Пусть попробует открыть хоть одну дверь.
**Как обновиться с 1.0** (один раз, дальше само)
`curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update`
Hysteria2: `hy2 update`. Новые установки получают всё сразу.
## v1.0 ## v1.0
+432
View File
@@ -0,0 +1,432 @@
## v1.2.1.6: доступы к панели в конце установки и рабочая заглушка
* **Исправлено: доступы к панели не выводились.** Автор пишет строки в `/root/3x-ui.txt` без ведущих
пробелов, а наш фильтр требовал два пробела — отсюда «данных панели нет». Теперь адрес панели, логин и
пароль печатаются в конце установки (и в пункте меню «9. Доступ к панели, ссылки и заглушка»).
Рядом указывается, что подписка и ссылки по протоколам лежат в том же файле.
* **Исправлено: наша заглушка не готовилась до установщика.** Обёртка скачивала из релиза только три файла,
а `scripts/kit-local.sh` (там функция страницы) не качала — падало с «kl_site_html: command not found».
Теперь качаются все файлы релиза, и каждый сверяется с подписанным списком (проверка целостности стала
полной, а не только для установщика).
## v1.2.1.5: доступ к панели, ссылки и заглушка — одним пунктом меню
* **В меню kit пункт «9. Доступ к панели, ссылки и заглушка»** — открывает одним экраном: адрес панели,
логин и пароль (из /root/3x-ui.txt), состояние сертификата панели (доверенный на IP или
самоподписанный, с датой повтора при лимите Let's Encrypt), ссылки на подключения с QR-кодом и что
сейчас стоит заглушкой. Раньше доступ к панели в меню не показывался вообще, а про заглушку было
непонятно, как её посмотреть.
* Меню перенумеровано: «10. Панель x-ui», «11. Удалить KIT с сервера» (логика удаления — авторская).
* То же самое доступно из консоли: `kit local access [имя]`.
## v1.2.1.4: короткий итог установки, автовыбор в общедоступных сайтах, команда про порты
* **Итог установки вместо простыни текста** — компактный блок: сборка, сервер, протоколы, состояние
сертификата панели, одна строка проверки. Дальше только то, что нужно человеку: доступ к панели (адрес,
логин, пароль — по просьбе из ранних правок), указатель на меню kit для ссылок и QR, открытые порты и
команда для закрытия лишних. Длинные отчёты (состояние защиты, полный вывод kit check) убраны из
установки — они остались доступны командами, в том числе `kit local hardening`.
* **Ссылки на подключения больше не попадают в вывод установки даже косвенно**: раньше печатался весь
файл /root/3x-ui.txt, а в нём лежат и ссылки vless://. Теперь из него берутся только строки панели —
адрес, логин, пароль.
* **В меню общедоступных сайтов последним пунктом добавлен автовыбор** (раньше можно было только выбрать
номером или вернуться).
* **Новая команда `kit local ports`** — что открыто в ufw, что реально нужно (порты подключений и SSH),
что лишнее; `kit local ports --close-extra` закрывает лишнее.
* Ошибка подготовки нашей заглушки теперь печатает причину, а не просто «не смог».
## v1.2.1.3: ссылки и QR — в меню kit; сертификат панели видно в итоге
* **Ссылки для подключения и QR-код больше не печатаются в выводе установки.** Их смотрят позже, когда
настраивают телефон, и они всегда доступны заново: `kit → Пользователи → Показать ссылки`. В итоговом
блоке вместо них — короткий путь к этому пункту и команды `kit local links` (подписка и QR) и
`kit user link <имя> --all` (все протоколы). Доступы к панели (адрес, логин, пароль) по-прежнему
в конце установки: их вы просили показывать один раз и сразу.
* **Добавлена команда `kit local links`** — подписка, QR-код и ссылки по протоколам одной командой;
пользователя можно указать, иначе берётся первый.
* **В итоговом блоке видно состояние сертификата панели.** Панель и подписка висят на IP, поэтому
печатается: «Let's Encrypt на IP — доверенный, браузер не ругается» либо «самоподписанный» с датой,
после которой Let's Encrypt выпустит сертификат на IP (тот же лимит, что и для домена).
## v1.2.1.2: сертификат на IP — с датой повтора; «без домена» продолжает установку
* **Отказ Let's Encrypt на IP теперь объясняется честно.** Как и для домена, при лимите в выводе дата,
после которой выпуск возможен, и прямо сказано, что порт 80 и файрвол тут не при чём. Раньше печатались
«частые причины» и человек шёл проверять хостера.
* **Пункт «без своего домена» больше не останавливает установку.** При отказе с сертификатом домена выбор
«3» продолжает работу сразу: маскировка берётся из соседей по подсети (или известный сайт, если соседей
нет), панель и подписка переключаются на IP, дальше установка идёт своим ходом. Перезапускать скрипт
и заново отвечать на вопросы не нужно.
## v1.2.1.1: сборка на новой базе автора (3X-UI KIT 1.2.1)
**Правило нумерации:** берём версию автора и через точку добавляем свою, начиная с 1. У автора 1.2.1 —
значит наша сборка 1.2.1.1; когда выйдет его 1.2.2, наша будет 1.2.2.1.
**Взяли от автора (его код теперь работает вместо наших заплаток):**
* проверка A-записи домена сверяется с **публичным DNS**, а не с кэшем самого сервера — та самая причина,
когда запись уже есть, а сервер её «не видит»;
* пропал наш шов, открывавший порт 80: автор делает это сам перед выпуском сертификата **и закрывает обратно**;
* пропал наш шов переиспользования сертификата: автор тоже берёт прошлый;
* повторный запуск после неудачной установки: автор заново спрашивает протоколы и сайт маскировки и не
оставляет сервер на полпути — прежнее наше «доустанавливаю» больше не нужно;
* полное удаление: наш `kit local uninstall` теперь **делегирует авторской команде `kit uninstall`**
(она снимает и сам kit) и остаётся синонимом;
* меню `kit` — авторское (в том числе «9. Панель 3X-UI (меню x-ui)», «10. Удалить KIT с сервера»);
* попутно приехали его починки: Hiddify 4.x снова принимает подписку, ссылка MTProto ведёт на 443, смена
сайта маскировки не обрывает клиентов (прежнее имя живёт неделю), `nginx -t` в проверке сервера,
устойчивость к битому apt-репозиторию, длинные имена сайта в nginx, проверка копии (в т.ч. U+202E),
честная глубокая проверка, предупреждение «обновлять панель и ядро только через kit».
**Сохранили своё:**
* наш файл `scripts/kit-local.sh` и наша обёртка установщика — автор их не трогает;
* наш канал обновлений (GitHub → наш Gitea) и **только наш ключ подписи** в `KIT_SIGNERS`: сервер обязан
принимать обновления лишь из нашего канала;
* наш экран выбора протоколов (13 пунктов, дефолт REALITY + AmneziaWG-3.1) — теперь номера вводятся
**через пробел** («1 12»), как вы просили; запятые по-прежнему принимаются;
* наши меню маскировки REALITY и XHTTP/MTProto, вопрос «панель по IP или по домену», возврат «ввести
другой домен» без обрыва установки;
* наша заглушка ставится сразу, авторская не устанавливается;
* финальный блок: доступы, подписка и QR печатает наш установщик один раз, в самом конце (авторский дубль
«Готово! Сервер работает» убран, предупреждения остаются).
**Технически:** из 77 прежних швов 66 легли без изменений, 8 больше не нужны (их работу делает автор),
3 перенастроены на его новый код (ключ подписи, защита наших меню, финальный блок).
## v1.2.27: ошибка с доменом больше не обрывает установку
* **Свой домен вводится заново, пока не заработает.** Раньше при промахе (A-запись ещё не разошлась
по DNS, опечатка в имени) установка молча маскировалась под чужой сайт, а при домене из флага —
обрывалась с «исправьте причину и запустите скрипт снова». Теперь сразу после ввода домена идёт
проверка и при промахе — выбор: «проверить снова» (DNS расходится от минут до часов), «ввести другой
домен», «без своего домена — маскировка соседом по подсети».
* **Провал сертификата на шаге подключений не прерывает установку.** Предлагается повторить попытку
(пока ждали обновления DNS или лимит Let's Encrypt), ввести другое имя (с той же проверкой A-записи)
или остановиться с понятной инструкцией, как поставить без домена. Причина и дата повтора при лимите
печатаются в самом отказе, лог остаётся в /var/log/kit-domain-cert.log.
## v1.2.26: лимит Let's Encrypt виден прямо, готовый сертификат переиспользуется
* **Причина отказа теперь называется честно.** Let's Encrypt разрешает 5 сертификатов на точный набор имён
за 7 суток; при повторных установках лимит выбирается, и acme.sh отвечает `rateLimited`, а установщик
писал про «A-запись, порт 80 или Cloudflare» — то есть отправлял искать причину не там. Теперь в выводе
дата, после которой повтор сработает, и подсказка взять другое имя (`node3.…` — лимит считается на набор имён).
* **Готовый сертификат переиспользуется.** Если у acme.sh уже лежит годный сертификат на этот домен,
новый не заказывается — лишний заказ и упирался в лимит. Заодно не тратится время на повторные попытки.
* Итоговая ошибка установщика больше не повторяет догадки про порт и DNS: причина печатается выше,
а лог остаётся в /var/log/kit-domain-cert.log.
## v1.2.25: вопрос про панель возвращён, блок про защиту переписан понятнее
* **Снова спрашиваем, где открывать панель и подписку.** У автора этот вопрос задавался только когда
домен передан флагом `--domain`; при выборе «ввести свой домен» в меню маскировки его не было, и панель
молча оставалась по IP с самоподписанным сертификатом — то самое «панель на это ругается». Теперь:
выбрали свой домен → сразу вопрос «По IP (как раньше) или По домену» (`https://домен/…`, сертификат
Let's Encrypt, браузер не ругается).
* В отчёте защиты блок «чего здесь нет» больше не ссылается на сторонний проект без пояснений: сказано,
что именно сделано мягче (без полного обновления системы, без автоперезагрузки, без общесистемного
запрета ICMP) и почему — панель и ядро закреплённых версий, сервер не должен вставать сам.
## v1.2.24: сертификат идёт вслед за выбором «домен или IP»
* Выбрали в меню маскировки **свой домен** — панель и подписка тоже переезжают на домен:
`https://ваш.домен/...` и открываются тем же сертификатом Let's Encrypt, без предупреждений браузера.
Раньше сертификат выписывался только для маскировки, а панель оставалась по IP с самоподписанным —
отсюда ругань и в панели, и в подписке.
* Если выбрали соседа или общедоступный сайт (своего домена нет) — панель остаётся по IP, и в итоговом
блоке прямо сказано: сертификат самоподписанный, браузер предупредит; перейти на домен потом —
`kit net panel domain`.
* Домен, который не указывает на этот сервер, используется только как маска (и об этом сообщается),
панель в этом случае остаётся по IP.
## v1.2.23: чистый вывод — без дублей доступов, без авторской заглушки, без лишних ссылок
* **Убран дублирующий блок автора.** Он печатал «Готово! Сервер работает» с адресом панели, логином,
паролем, подпиской, QR-кодом и ссылками — а потом то же самое выводил наш финальный блок.
Теперь авторский блок заканчивается строкой «Установка завершена» и списком предупреждений;
доступы и подписка печатаются один раз, в самом конце.
* **Авторская заглушка больше не ставится.** Наш сайт кладётся до установщика, а его страница из
релиза убирается — иначе она перезаписывала нашу на шаге сборки сайта. Заодно пропадает лишний
шаг «поставить поверх» и его бэкап.
* **Ссылки по протоколам — только если протоколов больше одного.** При одном REALITY печатается
подписка и QR-код, без списка «отдельные ссылки на каждый протокол».
* Самопроверка в конце установки больше не перечисляет чужие протоколы («REALITY, XHTTP и
Hysteria2») — пишет те, что реально созданы. Предупреждение про XHTTP/MTProto показывается только
если эти протоколы выбраны.
## v1.2.22: 80 в ufw открывается прямо перед выпуском сертификата
* Настоящая причина сбоя сертификата (проверено на живом сервере): установщик открывает список
портов в ufw **позже**, чем пробует выпустить сертификат. На свежем сервере ufw в этот момент
разрешает только 22, поэтому проверка Let's Encrypt доходила до сервера и «проглатывалась» —
в логе acme `Timeout during connect (likely firewall problem)`. Провайдер тут ни при чём:
с настоящим слушателем 80 и 443 отвечают снаружи нормально (проверено в том числе с другого сервера).
* Теперь 80/tcp открывается в ufw непосредственно перед `issue_domain_cert`, а формулировка ошибки
в установщике исправлена: сначала проверяем ufw, потом хостера.
## v1.2.21: порты до выпуска сертификата и понятный блок про SSH-порт
* **Порты до сертификата.** Установщик теперь сам открывает 80 и 443 в ufw перед работой и прямо
предупреждает, что такие же правила нужны в файрволе хостинга: частая причина срыва — порт 80
закрыт именно у провайдера, а не в сервере. Если сертификат всё же не выпустился, в выводе
разбирается причина: при `Timeout during connect (likely firewall problem)` пишется, что делать
(открыть 80 в панели хостинга) и предлагается вариант без своего домена (тогда сертификат домена
не нужен, панель остаётся на самоподписанном). После установки 80 и 443 в ufw проверяются ещё раз.
* **Смена порта SSH — с понятным завершением.** Прежние порты запоминаются до смены, и в конце
печатается блок: новый порт, какие прежние ещё открыты, команда проверки входа во втором окне и
готовая команда `kit local ssh --close-old <новый>` для закрытия прежних. Строка про SSH-порт
добавлена и в итоговый блок с доступом к панели и подпиской.
## v1.2.20: после сноса меню не показывается, и нет ошибок про latest-version
* Удаление из меню (`kit`, пункт 9) теперь завершается сразу: печатается «Готово: сервер в состоянии
до установки» — и выход. Раньше меню перерисовывалось на уже удалённом сервере и сыпало
`touch: cannot touch '/etc/kit/latest-version'`.
* `update_hint` больше не пытается что-либо писать, если каталога `/etc/kit` уже нет.
* Входит исправление 1.2.19: `extra` объявлена пустой (иначе `set -u` валил установку на шаге
«[6/6] Проверка» сообщением `extra: unbound variable`).
## v1.2.19: исправлено «extra: unbound variable» при установке
* На середине установки (после проверки протоколов) скрипт падал: `extra: unbound variable`.
Причина — моя правка v1.2.13: присваивание ссылок legacy-подписок завёрнуто в `if`, а переменная
объявлялась как `local extra` без значения. Авторский скрипт работает с `set -u`, поэтому при
невыполненном условии (AWG и MTProto не выбраны) чтение `$extra` валило установку. Объявлена пустой.
* Правило записано в FORK.md: оборачивая авторское присваивание в `if`, объявляй переменную пустой —
`local extra=""`, иначе `set -u` обрывает установку на середине.
## v1.2.18: выбранный «свой домен» больше не подменяется соседом
* Что было: после выбора «ввести свой домен» установщик всё равно брал запасной сайт из подсети.
Две причины, обе в моём коде: (1) выбор перезаписывался сырым значением меню (`own:домен`)
вместо самого домена; (2) домен проверялся на «TLS 1.3 + HTTP/2» — а до установки на нём ещё нет
сертификата, поэтому проверка честно не проходила и срабатывал откат на соседа. Теперь для своего
домена проверка TLS не делается (он указывает на этот сервер — установщик сам выпустит
сертификат Let's Encrypt), а автоподбор и запасной сайт включаются только если вы НЕ выбрали
ничего (автовыбор).
## v1.2.17: меню маскировки идёт ПЕРВЫМ (меню автора больше не перебивает)
* Причина, по которой меню не было видно: **меню спрашивал сам установщик** («Свой домен / Сосед /
Свой сайт») — и до нашего скана подсети дело не доходило. Теперь, когда включено наше меню,
меню автора не показывается вообще: сразу скан, список найденных доменов и выбор — из найденных,
автовыбор, общедоступный или свой домен. Отключить и вернуть поведение автора: `--auto-sni`.
* Ответ меню помечается: если вы ввели домен, который указывает на этот сервер, маскировка идёт
«под свой домен» (сертификат Let's Encrypt выпустит установщик); если не указывает — маскируюсь
под него как под чужой сайт и прямо об этом сообщаю.
* Входит исправление 1.2.16: возвращён `ask_yes` (в 1.2.13–1.2.15 при установке печаталось
`ask_yes: command not found`, и значения брались по умолчанию) + гейт «определение раньше вызова».
## v1.2.16: возвращён ask_yes (вылезало «command not found» при установке)
* При переносе вопросов наверх (v1.2.13) потерялось определение функции `ask_yes` — установщик
писал `ask_yes: command not found` на двух строках и молча брал значения по умолчанию:
ping оставался открытым, автообновления выключались. Скрипт при этом не падал, поэтому
ошибку было видно, но установку она не срывала.
* Почему не поймали: гейт в релизе прогонял только `--dry-run`, а он выходит ДО вопросов.
Добавлена статическая проверка: каждая наша функция должна быть определена раньше первого
вызова (`ask_yes`, `ask_ssh_port`, `ask_protocols`, `interactive`, `clean_previous` и др.).
## v1.2.15: меню выбора сайта в вашем виде, быстрый скан, снос с добивкой
* Меню маскировки выглядит так, как и просили. Если соседи нашлись — их список по номерам,
затем `автовыбор из списка`, `выбрать общедоступный` (отдельным списком) и `ввести свой домен`.
Если в подсети ничего не нашлось — сразу «общедоступный» или «свой домен» (ничего лишнего).
Enter — автовыбор. Общедоступные сайты предлагаются и для REALITY, и для XHTTP/MTProto.
* **Скан подсети стал быстрым.** Было: адреса проверялись по 8 сразу, а найденные имена —
по очереди (до 40 имён × три проверки с сетевыми запросами ≈ минуты). Стало: 32 адреса сразу
и имена проверяются параллельно по 8; таймаут проверки адреса 3 с вместо 4.
* В выводе видно, сколько соседей нашлось (раньше было не понять, работает ли скан).
* После нашего меню автор больше не спрашивает «продолжить с запасным сайтом?» — выбор уже сделан.
* В меню `kit`: **9 — «Удалить всё установленное», 10 — «Выход»** (меню панели `x-ui` переехало
в пункт 11).
* **`kit local uninstall` доводит дело до конца:** после явного списка идёт добивка по шаблонам
(`/etc/kit*`, `/usr/local/lib/kit*`, юниты `kit-*`/`x-ui*`, `/var/www/kit`, сертификаты), повторная
проверка и внятный итог. Появился флаг `--yes` — снести без вопроса.
## v1.2.14: выбор сайта маскировки по результатам скана + снос, который не обрывается
* **Появилось меню маскировки.** Сначала один скан подсети (~250 обращений к 443 соседних адресов),
потом выбор: номер из найденного, `0` — ввести адрес вручную, Enter — автоподбор. Отдельно для
**REALITY** (найденное или свой) и отдельно для **XHTTP/MTProto** (найденное, свой или известный
сайт — список известных расширен Microsoft, AMD, Intel, NVIDIA). Если XHTTP/MTProto не выбраны,
второго меню не будет. Отключить меню: `--auto-sni`.
* **Исправлено: `kit local uninstall` обрывался на середине.** `kit` запускает наши команды
с `set -e`, и подсчёт чужих сайтов nginx (`find | grep | wc -l`, где grep не находит ничего и
возвращает 1) убивал скрипт сразу после остановки служб — файлы и `/usr/local/bin/kit` оставались,
поэтому `kit help` продолжал работать. Теперь снос выполняется с отключённым `set -e` и все
подобные конвейеры защищены; проверка после сноса это подтверждает списком, если что-то осталось.
## v1.2.13: вопросов после показа доступов нет, и никакой работы для невыбранных протоколов
* **Все вопросы — до установщика.** Раньше после установки (когда на экране уже были данные входа
в панель) скрипт спрашивал про ping, автообновления, порт SSH и пароль root — доступы терялись
в потоке вопросов. Теперь спрашиваем всё заранее, а действия выполняем после установки; последнее,
что видит человек, — доступ к панели, подписка и QR-код.
* Автор тянет подписки «-awg» и «-tg» (наследие установок kit < 1.1) всегда — теперь только если
AmneziaWG или MTProto выбраны.
* Наши команды помнят выбор: установщик пишет список протоколов в `/etc/kit/kl-protos`, и
`kit local site` не синхронизирует ответ Hysteria2, если Hysteria2 не ставился (раньше он это делал
и выдавал предупреждение про отсутствующий Hysteria2).
## v1.2.12: не сканируем соседей, когда они не нужны
* Автор подбирал сайты-соседи для XHTTP и MTProto **всегда**, когда маскировка доверенная («панель по IP»)
и всё висит на 443 — независимо от того, выбраны ли эти протоколы. При установке одного REALITY это
давало ~250 обращений к чужим адресам впустую. Теперь скан идёт только если XHTTP или MTProto выбраны;
иначе имена SNI2/SNI3 берутся из общего списка сайтов автора (как и раньше при неудаче скана).
# Что нового в сборке форка
Здесь записи о наших версиях. Файл автора (`CHANGELOG.md`) мы не трогаем — так обновления
автора не конфликтуют с нашими записями.
## v1.2.11: снос знает про kit-limit и находит свои службы сам
* В списке сноса не было `kit-limit.service` и `kit-limit.timer` (юнит «общий лимит трафика» из kit) —
его создаёт установка, а `kit local uninstall` его не убирал. Из-за этого после сноса оставался
работающий таймер, который раз в 5 минут дёргал уже удалённый `kit` и падал.
* Теперь снос берёт список служб **и из явного перечня, и по шаблонам `x-ui*`/`kit*`** из systemd —
новая служба, добавленная автором позже, не останется незамеченной. Проверка после сноса ищет
по тем же шаблонам и показывает всё найденное.
* Флаг `--clean` у установщика использует тот же список.
## v1.2.10: убран мусор в выводе установщика
* В шапке `scripts/3x-ui-local.sh` один из флагов потерял `#` и **при каждом запуске печаталось
`--dry-run: command not found`** (до `set -e` — потому и не роняло установку, но выглядело как ошибка).
Исправлено, заодно выровнены строки справки.
* В релизный гейт добавлена проверка, которая такое ловит: холостой прогон установщика, чей вывод в stderr
содержит «command not found / syntax error / bad substitution», релиз не собирается.
## v1.2.9: снос действительно убирает всё, повторная установка после сноса
* Список удаляемого в `kit local uninstall` собран заново **по коду установщика** и дополнен тем, чего
не хватало: `/usr/local/lib/kit-sub`, `/root/kit-backup-*` и `/root/x-ui-before-*` (копии kit),
`/var/log/kit-install-check.log`, `/usr/bin/x-ui.new`, строка `include kit-stream.conf` в `nginx.conf`,
симлинки в `sites-enabled`/`streams-enabled`, файл автообновлений безопасности, пользователь `kit-sub`.
Из-за пропущенных файлов установщик после сноса отказывался ставить заново («3X-UI уже установлена»).
* После сноса печатается проверка **теми же условиями, что у установщика**, и список того, что осталось
на диске — вместо бодрого «удалено» вслепую.
* Флаг `--reset-ssh`: снести и наш ssh-дроп-ин (порт возвращается к системному). По умолчанию SSH не трогаем.
* У установщика появился флаг **`--clean`**: убрать прошлую установку и поставить заново. Если установщик
всё-таки упал на «уже установлена», в подсказке прямо сказано запустить с `--clean`.
## v1.2.8: доступ к панели в конце установки и снос сервера из меню
* В конце установки (перед подпиской) печатается **доступ к панели 3X-UI**: адрес, логин и пароль из
файла `/root/3x-ui.txt` — раньше это было видно только в общем потоке вывода установщика.
* **`kit local uninstall`** — снос установки целиком: панель и служба `x-ui`, ядро Xray, службы и файлы
kit (`kit-sub`, автообновление, cron), конфиги nginx и сайт-заглушка, сертификаты (acme.sh),
наши правки системы (правило ICMP, обновления безопасности), наши бэкапы и файл с данными панели.
Правила ufw для 80/443 снимаются, ответ на ping возвращается. Спрашивает подтверждение словом
«удалить», есть `--dry-run`.
**SSH не трогается** — ни порт, ни конфиги, ни правила для него: иначе легко потерять доступ к серверу.
В главном меню `kit` появился пункт **10. Удалить всё установленное**.
## v1.2.7: имя 3X-UI_autoVPS, справка kit help, подписка с QR, живые кнопки на заглушках
* **Сборка называется `3X-UI_autoVPS`** (было `3X-UI KIT`): баннер сервера — `Hacker 07` в том же
стиле, новое имя в справке, версии, описаниях служб и в имени профиля в клиенте. В файлах автора
это сделано швами (`tools/fork_apply.py`), поэтому обновление автора правки не затирает.
* **Совместимость шапки релиза.** В подписанном `SHA256SUMS` этого релиза шапка пишется **двумя
строками** (старая и новая), а проверка на сервере принимает обе. Иначе уже установленные серверы
отказались бы ставить обновление: они сверяют точный вид строки. Со следующего релиза останется
только новое имя.
* Убрано дублирование подсказки «Дальше обязательно» при смене порта SSH: она печаталась и
установщиком, и командой `kit local ssh`.
* **`kit help` — полный справочник по категориям**: пользователи, подключения и порты, маскировка и
сайт, проверка и починка, обслуживание и обновление, защита сервера (наш слой). Раньше `kit help`
показывал короткий список автора и не упоминал наши команды. Чтобы справка не отставала молча,
добавлена проверка `tools/check-help.sh`: она сверяет справку с диспетчером команд и встроена в
сборку релиза (`tools/release.sh`).
* У сайтов-заглушек все ссылки и кнопки **живые**: нажатие открывает окно «раздел в разработке,
приносим извинения» (закрыть — кнопкой, кликом мимо окна или Esc). Никаких внешних запросов:
скрипт и стили внутри страницы.
* В самом конце установки (после всех проверок) печатается то, что нужно сохранить: **подписка
с QR-кодом** и ссылки по протоколам — авторской командой `kit user link <имя> --all`, поэтому
вывод совпадает с тем, что показывает `kit` потом. Отключается флагом `--no-sub`.
* Набор по умолчанию — **REALITY + AmneziaWG-3.1** (самый совместимый минимум); ответ «все»
ставит полный набор автора. Умолчание задаётся в одном месте (`DEFAULT_PROTOS`).
* **Сайт-заглушка переделан на ИТ-тематику:** десять правдоподобных сайтов (дата-аналитика, DevOps,
мониторинг, разработка, аудит, ИТ-поддержка, колокейшн, заметки про Linux, бэкапы, документация)
вместо авторских «кофеен и пекарен». Ставится случайный, конкретный — `kit local site 7`,
список — `kit local site --list`. Команда обновляет и файл для nginx, и копию страницы внутри
подключения Hysteria2 (`kit net masq on`): если менять файл вручную, 443/tcp и UDP-порт начнут
отвечать разными страницами — это заметно снаружи.
* Обёртка спрашивает, **какие протоколы ставить** (нумерованный список, `1,3,11` или «все»,
Enter — набор автора): невыбранные протоколы не создаются вообще — ни подключения в панели,
ни записей в nginx, ни ссылок в подписке. Список и умолчание взяты из самого установщика
(`ALL_PROTOS`/`DEFAULT_PROTOS`), передаётся его же флагом `--protocols`, так что поведение
не расходится с авторским. Свой `--protocols` можно задать флагом — тогда вопроса не будет.
* Обёртка `scripts/3x-ui-local.sh` теперь спрашивает, а не решает за вас: закрывать ли ответ на ping,
включать ли автообновления безопасности, **менять ли порт SSH** (Enter — оставить текущий,
число — перевести на него; мусор и занятый порт переспрашивает), менять ли пароль root.
Маскировку и домен, как и раньше, спрашивает установщик автора.
* Смена порта SSH никогда не закрывает прежний порт сама: сначала добавляется новый, затем скрипт
печатает две команды — проверить вход во втором окне и закрыть прежние (`kit local ssh --close-old`).
* Запуск без флагов = «одна команда, дальше вопросы». Флаг `--yes` — наоборот, ничего не спрашивать
(для автоматизации): ping закрывается, автообновления включаются, порт SSH и пароль не трогаются.
* Флаг `--harden` теперь действительно существует (раньше он уходил установщику автора неизвестным
параметром и ронял установку): закрыть ping и включить автообновления без вопросов.
* `--dry-run` показывает, что именно будет спрошено или сделано по умолчанию.
## v1.2.6: вопросы при установке
* **README.md переписан целиком:** убрано авторское описание с его командой установки, добавлены
актуальная команда, флаги, таблица протоколов, список сайтов-заглушек, команды `kit` и `kit local`,
канал обновлений и оговорки. Две картинки — наши (`manuals/assets/fork-banner.svg` и
`manuals/assets/script-3x-ui-local.svg`, терминал собран из настоящего вывода скрипта).
* Шов в README больше не нужен (`tools/fork_apply.py`): файл наш целиком, как `kit-local.sh`.
## v1.2.5: суммы релиза в формате, который понимает kit
* Исправлена ошибка нашего релизного конвейера: на Windows `sha256sum` пишет суммы как
`hash *файл`, а `kit` на сервере сверяет имя строго (`$2 == "файл"`) — то есть `kit update`
отклонил бы релизы v1.2.1–v1.2.4 как «в подписанном списке нет такого файла». Теперь
`tools/release.sh` приводит список к POSIX-виду (`hash файл`) **до** подписи.
* `scripts/3x-ui-local.sh` принимает оба формата, чтобы не спотыкаться о суммы, собранные на
другой машине.
* Проверка теперь повторяет логику самого `kit`: подпись `SHA256SUMS` + строгая сверка имени
каждого файла из подписанного списка (а не только `sha256sum -c`, который звёздочку прощает).
## v1.2.4: проверка релиза отдельной командой
* Флаг `--verify-only`: скачать релиз, проверить подпись `SHA256SUMS` и SHA256 установщика и выйти —
можно проверить канал на сервере, ничего не устанавливая.
* Порядок шагов изменён: проверка подписи теперь идёт до предполётных проверок и до любых
изменений на сервере, чтобы чужой код не запускался раньше проверки.
## v1.2.3: установка одной командой (`scripts/3x-ui-local.sh`)
* Один скрипт на чистом VPS: проверка сервера → скачивание нашего релиза → проверка подписи
`SHA256SUMS` и SHA256 самого установщика → установщик автора (панель 3X-UI, протоколы,
сайт-заглушка, маскировка, сертификат, подписка, ufw) → наши дополнения → проверка.
* По умолчанию досыпает: закрыть ответ на ping (правилом ufw), включить обновления безопасности,
`kit fix` (время и безопасные починки), в конце `kit check` и сводку `kit local hardening`.
Отключается флагами `--no-harden`, `--no-time`, `--no-check`.
* Второй порт SSH — только по явному флагу `--ssh-port N`: прежний порт продолжает работать,
закрытие — отдельным шагом `kit local ssh --close-old N`. Автоматически доступ не запираем.
* Все флаги установщика автора (`--domain`, `--sni`, `--panel-on`, `--host`, `--restore` …)
передаются как есть; есть `--dry-run` (план без изменений) и `--help`.
* Публичный ключ форка вшит в обёртку — подпись проверяется до запуска чужого кода; при смене
ключа обновляется и здесь, и в `kit`.
## v1.2.2: команды защиты сервера (поведение из StressOzz/vps-setup)
* Новые команды `kit local`: `ssh <порт>` (добавить порт SSH), `ssh --close-old` (закрыть прежние
порты), `ping off|on` (не отвечать на ICMP echo), `passwd [--generate]` (сменить пароль),
`upgrades on|off|status` (автообновления безопасности), `hardening` (сводка по защите сервера).
* Сделано безопаснее, чем в источнике: конфиг sshd проверяется через `sshd -t` **до** перезапуска,
новый порт сначала открывается в ufw, прежний порт продолжает работать до отдельного шага
`--close-old`, у правок есть `--dry-run`, а копии изменённых файлов остаются в
`/root/kit-local-backup-<дата>/` (не в `/tmp`).
* `apt full-upgrade` из источника не выполняется: вместо него включаются обновления безопасности
(только security, без самовольной перезагрузки) — это не затрагивает закреплённые версии панели
и ядра, которые ставятся не из apt.
* Учтён Ubuntu 24.04: если SSH слушает systemd (`ssh.socket`), порт добавляется socket-drop-in'ом,
а закрытие старых портов не делается автоматически — там ошибка запирает доступ к серверу.
* Ping отключается правилом ufw (`before.rules`), а не общесистемным `icmp_echo_ignore_all`:
видно в `ufw status`, отменяется одной командой и не глушит ICMP для внутренних интерфейсов.
## v1.2.1: сборка форка поверх 3X-UI_autoVPS 1.2
* Источник файлов и обновлений — свой репозиторий Gitea: установка и `kit update` берут релиз
оттуда, а подпись релиза проверяется ключом форка. Авторский ночной апдейт приходит в наш
канал, поэтому наши доработки не стираются ночью.
* Дополнения форка живут отдельным файлом `/usr/local/lib/kit/kit-local.sh` и подключаются одной
строкой: `kit local help | version | channel | seams | masking`.
* Наш файл едет в том же подписанном релизе, что и код автора: он входит в `SHA256SUMS`,
проверяется по подписи и обновляется вместе с `kit`.
* Швы в коде автора описаны в `tools/fork_apply.py` (источник правды), синхронизация с автором и
проверки — `tools/fork-sync.sh`.
Основа — 3X-UI_autoVPS 1.2 (панель 3X-UI v3.9.0, ядро Xray v26.6.27). Что нового у автора — в `CHANGELOG.md`.
+254
View File
@@ -0,0 +1,254 @@
# Форк 3X-UI_autoVPS (hacker07) — своя сборка и свой канал обновлений
Коротко: это [3X-UI_autoVPS](https://github.com/itsnotkubrick/3X-UI_KIT) со своими доработками. Наш код
живёт рядом с кодом автора и не ломается, когда автор выпускает новую версию, а серверы ставятся и
обновляются **из этого репозитория** — релизы подписываются нашим ключом.
## С чего начать: новый VPS
1. Если нужен свой домен для маскировки — заранее направьте его A-запись на IP сервера и освободите
порт 80 (иначе Let's Encrypt не выпустит сертификат). Без домена этот шаг пропускается.
2. Одна команда на сервере (от root) — **дальше скрипт спросит сам**: какие протоколы ставить
(Enter — REALITY + AmneziaWG-3.1, «все» — набор автора), какую маскировку и домен,
закрывать ли ответ на ping, включать ли автообновления, менять ли порт SSH (Enter — оставить
текущий, число — перевести на него), менять ли пароль root:
```bash
bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
```
Те же ответы можно задать флагами — тогда вопросов не будет:
```bash
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols reality,hy2,awg,mtproto --domain vpn.example.com
```
3. Скрипт делает всё сам: проверяет сервер → скачивает наш релиз и **проверяет подпись `SHA256SUMS`
и SHA256 установщика** → запускает установщик автора (панель 3X-UI, **только выбранные протоколы**,
сайт-заглушка, маскировка, сертификат, подписка, ufw) → наши вопросы и правки (ping,
автообновления, второй порт SSH, пароль root) → `kit fix` → в конце показывает `kit check` и сводку
`kit local hardening`.
4. Забрать ссылки и подписку: `kit user link admin` (или адрес подписки из вывода установки).
Сомневаетесь в канале — сначала ничего не меняющие режимы:
```bash
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --verify-only # только проверка подписи релиза
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --dry-run --domain vpn.example.com # план установки
```
### Флаги установки
| Флаг | Что делает |
|---|---|
| `--protocols список` | какие протоколы ставить: `all`, `minimal` или через запятую — `reality,reality2,hy2,xhttp,ws,trojan,vmess,ss,tuic,wg,awg,awg3,mtproto`. Невыбранные не создаются вообще. Без флага — вопрос, по умолчанию предлагаются **REALITY + AmneziaWG-3.1** (ответ «все» — набор автора) |
| `--site N\|random\|off` | сайт-заглушка: конкретный из нашего набора (10 ИТ-сайтов), случайный (по умолчанию) или не трогать авторскую страницу (то же — `--no-site`) |
| `--yes` | не задавать вопросы (ping закрывается, автообновления включаются, порт SSH и пароль не трогаются) |
| `--harden` | закрыть ответ на ping и включить автообновления без вопросов |
| `--domain домен` | свой домен для маскировки: сайт и REALITY маскируются под него (A-запись на сервер, свободный 80) |
| `--sni сайт.com` | маскировка под чужой сайт вместо домена |
| `--panel-on domain\|ip` | где открывать панель и подписку |
| `--ssh-port N` | перевести SSH на порт N (число 1024–65535). Прежний порт сам не закрывается: сначала проверьте вход во втором окне, потом `kit local ssh --close-old N` |
| `--no-harden` | не закрывать ответ на ping и не включать автообновления безопасности |
| `--no-time` / `--no-check` | не запускать `kit fix` / `kit check` |
| `--verify-only` / `--dry-run` | проверить релиз / показать план и ничего не менять |
| остальные | передаются установщику автора как есть: `--host`, `--user`, `--port`, `--protocols`, `--restore`, `--multi-port`, `--cert`/`--key`, `--panel-ssl` |
Чего скрипт **не** делает (сознательно): `apt full-upgrade` (тянет систему вперёд и может сломать
закреплённые панель 3X-UI 3.9.0 и ядро Xray 26.6.27 — вместо него обновления безопасности),
автоматическую перезагрузку и смену пароля root (в однострочник пароль не подставляем, он попал бы
в список процессов — для этого есть `kit local passwd`).
### Как проверить, что установка удалась
```bash
kit check # без ❌: службы, порт 443 (nginx), подписка, маскировка, сертификат
kit local hardening # сводка: порты SSH, ответ на ping: deny, ufw активен, автообновления: включены
kit local channel # источник — этот репозиторий, ключей в KIT_SIGNERS: 1 (наш)
kit local version # версия сборки: 1.2.5 или новее
ping <IP сервера> # с домашней машины — таймаут: ответ на ping закрыт, это ожидаемо
kit user link admin # ссылка подписки; откройте её в браузере
```
Если ставили с доменом, в `kit check` должны быть строки «свой домен … указывает на этот сервер» и
«сайт маскировки … отвечает по TLS 1.3 и HTTP/2»; предупреждения про известный сайт на чужом IP
быть не должно (это касалось бы вспомогательного XHTTP, у REALITY должен быть ваш домен).
## Команды форка (`kit local`)
```bash
kit local help # справка по всем командам форка
kit local version # версия, источник обновлений, дата сборки нашего файла
kit local channel # откуда берутся обновления и чем проверяется подпись
kit local seams # какие швы форка сейчас в установленном /usr/local/bin/kit
kit local masking # состояние маскировки (у REALITY должен быть свой домен)
kit local hardening # сводка по защите сервера
```
Защита сервера (поведение взято из проекта `StressOzz/vps-setup`, реализация своя и безопаснее):
| Команда | Что делает |
|---|---|
| `kit local site` | поставить случайный сайт-заглушку из нашего набора (10 ИТ-сайтов) и синхронизировать ответ Hysteria2 |
| `kit local site --list` / `kit local site 7` / `--show` | список сайтов с номерами / поставить конкретный / какой стоит сейчас |
| `kit local ssh 2222` | добавить порт SSH (прежние продолжают работать; порт сначала открывается в ufw, конфиг проверяется через `sshd -t`) |
| `kit local ssh --status` | какие порты SSH слушаются сейчас |
| `kit local ssh --close-old 2222` | закрыть прежние порты после того, как новый проверен из второго окна |
| `kit local ping off\|on\|status` | ответ на ICMP echo: закрыть/вернуть/показать (правилом ufw, видно в `ufw status`) |
| `kit local passwd [юзер]` | сменить пароль (спросит дважды, в историю команд не попадёт) |
| `kit local passwd --generate` | сгенерировать пароль и показать один раз |
| `kit local upgrades on\|off\|status` | автообновления безопасности (только security, без перезагрузки) |
К любой правке можно добавить `--dry-run` — покажет план и ничего не изменит. Копии изменённых
файлов складываются в `/root/kit-local-backup-<дата>/` (не в `/tmp`).
Что `ping off` затрагивает: только ICMP echo. Подписка (HTTPS), панель, REALITY/XHTTP (TCP 443),
Hysteria2 (UDP 443) работают как работали; исходящий ping с самого сервера тоже остаётся. Если
сервер есть в вашем мониторинге — пробу до него делайте по HTTPS/TCP, а не по ICMP.
## Что где лежит
| Что | Где | Правит |
|---|---|---|
| Наши доработки (команды, хуки) | `scripts/kit-local.sh` | только мы |
| Установка одной командой (обёртка) | `scripts/3x-ui-local.sh` | только мы |
| Описание «швов» в коде автора | `tools/fork_apply.py` | только мы |
| Синхронизация с автором и проверки | `tools/fork-sync.sh` | только мы |
| Записи о наших версиях | `FORK-CHANGELOG.md` | только мы |
| `README.md` | целиком, включая картинки в `manuals/assets/` | только мы |
| Файлы автора (`scripts/kit.sh`, `scripts/3x-ui.sh`, …) | как есть | только автор |
Ключевая идея: в файлах автора мы правим только «швы» — несколько констант, одну строку подключения
и нормализацию сумм в релизном скрипте. Всё, что мы дописываем по существу, лежит в наших файлах.
Швы описаны в одном месте (`tools/fork_apply.py`) и накатываются только на чистое дерево автора;
если автор изменил нужную строку — инструмент останавливается и говорит об этом, а не делает
полу-патч.
## Ветки
`tools/release.sh` — **наш файл** (как README): авторский скрипт подписи мы переписали под себя
(проверка LF, гейт справки, шапка `SHA256SUMS` в двух строках, свой список файлов). Швов к нему нет;
при обновлении из авторского проекта файл конфликтует и решается в нашу пользу (как README).
Важно при правках швов: авторский скрипт работает с `set -u`, поэтому если оборачивать его присваивание
в `if`, переменную нужно объявить пустой (`local extra=""`, а не `local extra`) — иначе при невыполненном
условии любое чтение `$extra` валит установку на середине с «unbound variable».
Проверка корректности швов (делать после каждой правки и после синхронизации с автором):
накатать `tools/fork_apply.py` на чистую копию авторского кода и сверить с нашим деревом — расхождений
быть не должно, кроме наших собственных файлов. Это единственный способ увидеть правки, которых нет в швах.
**`main` двигается только вместе с подписанным релизом.** Серверы читают версию из `main` (`KIT_RAW`),
поэтому в `main` не должно попадать то, что ещё не подписано: иначе `kit update` и однострочник
установки упираются в отсутствующий тег или в неподписанные суммы. Порядок такой:
1. поднять версию и собрать изменения в `dev`;
2. пользователь подписывает (`tools/release.sh`);
3. коммит с `SHA256SUMS` и `SHA256SUMS.sig` → тег `v<версия>` → и только теперь `dev` вливается в `main`.
Проверка перед публикацией в `main`: `curl -fsSL .../raw/tag/v<версия>/VERSION` должен отдавать ту же
версию, а `bash <(curl ... raw/branch/main/scripts/3x-ui-local.sh) --verify-only` — писать, что подпись сошлась.
и релизы
- `upstream` — точная копия ветки автора (`main`), только вперёд.
- `dev` — наша сборка: `upstream` + швы + наши файлы; из неё делаются релизы.
- `main` — то же состояние плюс документация после релиза (по нему считается «последняя версия»).
- релизы — теги `v<номера>`; номер = `<версия автора>.<наш уровень>` (автор 1.2 → наш 1.2.1; после
релиза автора 1.3 → 1.3.1). Номер обязан строго расти: `kit update` не принимает откат назад.
## Обновление сервера и канал
```bash
kit update --force && kit check # тянет наш релиз из этого репозитория, проверяет нашу подпись
```
Источник файлов — `KIT_RAW` / `kit_ref_raw()` в `scripts/kit.sh` (наш Gitea и наш тег), подпись
проверяется ключом из `KIT_SIGNERS` (там только ключ форка). Ночной `kit-update.timer` (03:00) делает
то же автоматически — поэтому сервер и должен смотреть в этот репозиторий: иначе наши доработки
исчезнут в первую же ночь.
Вернуться на канал автора: скачать `scripts/kit.sh` из релиза автора с GitHub в `/usr/local/bin/kit`,
затем `kit update --force`.
## Обновление от автора (с рабочей станции)
### Автоматически: робот на раннере (CT 114)
Раз в сутки (03:30) Gitea сама запускает `.gitea/workflows/fork-sync.yml` на раннере в CT 114:
робот `tools/fork-sync-bot.sh` забирает ветку автора, переносит на неё наши правки, прогоняет те же
проверки, что и `tools/fork-sync.sh`, дописывает раздел в `FORK-CHANGELOG.md` и публикует `dev`
(`--force-with-lease`). Не сошлось (конфликт в местах наших правок) — **ничего не публикуется**,
причина видна в Gitea → Actions и в `/opt/kit-fork-sync/last-run.log` на контейнере.
Робот **не** выпускает и не подписывает релиз: закрытый ключ подписи остаётся на рабочей станции.
После автоматической синхронизации — как обычно: `git fetch origin && git checkout dev && git reset --hard origin/dev`
(робот переписывает `dev`), описать изменения в `FORK-CHANGELOG.md`, затем `tools/release.sh` и тег.
```bash
cd H:/My_Proj/Fork_3X-UI_KIT
bash tools/fork-sync.sh # подтянуть автора, перенести наши правки, прогнать проверки
# при конфликте — разобрать вручную: инструмент сам ничего не публикует
bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key # SHA256SUMS + подпись нашим ключом
**Нумерация версий.** Основа — версия автора, наша надстройка добавляется через точку с единицы:
у автора 1.2.1 → наша сборка 1.2.1.1; вышла его 1.2.2 → наша 1.2.2.1. Так на сервере всегда видно,
на чьей базе собрана сборка.
**Обновление базы автора.** Когда автор выпускает новую версию: перенести его файлы
(`git checkout upstream -- scripts/kit.sh scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit-sub.py`),
прогнать `python tools/fork_apply.py` и посмотреть, какие якоря не нашли себя. Те, чью работу автор уже
делает сам, — вычеркнуть; остальные перенастроить на его новый код и пересобрать таблицу швов
`git branch -f upstream <коммит автора>`.
Скрипт сам приводит переводы строк к LF, проверяет справку и то, что установщик не ругается на пустые
строки, сверяет панель и ядро Xray с официальными хешами GitHub (если GitHub недоступен — предупреждает и
пропускает эти проверки за секунды, а не висит; расхождение хешей при живом GitHub по-прежнему останавливает
выпуск). Далее скрипт ждёт **парольную фразу ключа** — её вводит человек, поэтому запускать надо в окне, где
можно набрать пароль (в PowerShell — да, в фоне — нет).
git add -A && git commit -m "Релиз v$(cat VERSION): …" && git tag v$(cat VERSION)
git push origin dev v$(cat VERSION) upstream
```
## Подпись релизов
- Открытая часть ключа — `tools/fork-signer.pub` и в `KIT_SIGNERS` (в `scripts/kit.sh`); она же
вшита в `scripts/3x-ui-local.sh`, чтобы проверять подпись до запуска установщика.
- Закрытая часть — только на рабочей станции владельца (`~/.ssh/3x-ui-kit-fork/release_key`).
В репозиторий, CI и на серверы не попадает: кто получит ключ — получит root на всех серверах,
которые обновляются из этого репозитория. Пароль на ключ пока не установлен
(`ssh-keygen -p -f ~/.ssh/3x-ui-kit-fork/release_key`).
- Смена ключа: новый релиз подписывается **старым** ключом, иначе серверы его не примут.
## Грабли, о которых стоит помнить
1. **Ночное автообновление** (`kit-update.timer`, 03:00) перезаписывает `/usr/local/bin/kit` тем, что
лежит в настроенном репозитории. Канал и наши правки — один шаг, а не два.
2. **Переводы строк.** Релиз подписывается по суммам рабочей копии, а сервер сверяет их с файлами из
Gitea: при `core.autocrlf=true` суммы не сойдутся (CRLF против LF). В клоне должно быть
`git config core.autocrlf false` (и `.gitattributes`), проверка есть в `tools/fork-sync.sh`.
3. **Наш файл обязан быть в двух списках** — `FILES=(…)` в `tools/release.sh` и список загрузки
`fetch_release()` в `scripts/kit.sh`, иначе после обновления `kit-local.sh` останется старым.
4. **Панель 3X-UI ставится с GitHub** по закреплённой версии и SHA256 — это шаг автора; для установки
с нуля серверу нужен доступ к GitHub.
5. **Формат контрольных сумм.** `kit` сверяет имя файла в `SHA256SUMS` строго (`$2 == "путь"`).
На Windows `sha256sum` пишет `hash *путь`, и такие релизы `kit update` отклоняет («в подписанном
списке нет такого файла»), хотя `sha256sum -c` их принимает. Поэтому `tools/release.sh` приводит
список к POSIX-виду **до** подписи, а релизы **v1.2.1–v1.2.4 ставить нельзя** — только v1.2.5+.
Проверять релиз надо логикой самого `kit`, а не одной `sha256sum -c`.
6. **Сборка релиза обращается к GitHub** (сверка закреплённых версий панели и ядра). Из домашней сети
TLS до GitHub иногда рвётся (`schannel: failed to receive handshake`) — просто повторить запуск.
7. **`README.md` — наш файл целиком.** Авторское описание убрано: иначе человек, открывший
репозиторий, читает команду установки **авторской** сборки, ставит по ней и оказывается на
авторском канале обновлений — ночное автообновление вернёт его сборку. При обновлении из
авторского проекта README конфликтует: берём нашу версию (`git checkout --ours README.md`),
авторские правки в нём нас не интересуют.
## Что проверено на сегодня
- Релиз `v1.2.5`: подпись сходится с ключом из опубликованного `kit.sh`, строгая сверка сумм
`$2 == путь` для `kit.sh`, `kit-sub.py`, `kit-local.sh` — как это делает `kit` на сервере.
- Публичный однострочник в режиме `--verify-only` отвечает «подпись сошлась, SHA256 установщика
совпал» — проверено снаружи, из домашней сети.
- Установка «в бою» на живом сервере пока не прогонялась: сначала её стоит выполнить на новом VPS,
посмотрев `--verify-only` и `--dry-run`.
+189 -86
View File
@@ -1,114 +1,217 @@
<div align="center"> <div align="center">
<img alt="3X-UI KIT" src="manuals/assets/banner.svg" width="820"> <img alt="3X-UI autoVPS" src="manuals/assets/fork-banner.svg" width="860">
**Свой VPN-сервер одной командой: 11 протоколов, один порт 443, одна подписка на всё** **Сервер одной командой: панель 3X-UI, VPN-протоколы за одним портом 443, одна подписка на все устройства**
[![Протоколов](https://img.shields.io/badge/протоколов-11-93E06F?labelColor=221B17)](manuals/3x-ui.md#протоколы) Сборка со **своим каналом обновлений**: сервер ставится и обновляется из этого репозитория, релизы
[![3X-UI](https://img.shields.io/badge/3X--UI-v3.8.5-93E06F?labelColor=221B17)](https://github.com/MHSanaei/3x-ui) подписаны, ночное автообновление не вернёт чужую версию. В основе — открытый проект 3X-UI_autoVPS.
[![Обновлено](https://img.shields.io/github/last-commit/itsnotkubrick/3X-UI_KIT?label=обновлено&color=93E06F&labelColor=221B17)](https://github.com/itsnotkubrick/3X-UI_KIT/commits)
[Возможности](#возможности) · [Установка](#установка) · [Генератор](#генератор-конфигов-для-роутера) · [Полезное](#полезное) · [Поддержать](#поддержать-проект)
</div> </div>
--- ## Что ставится
> [!TIP] | Что | Зачем |
> **Вышла версия 1.1:** исправления по аудиту безопасности, автообновление только подписанных |---|---|
> релизов, выбор маскировки (свой домен). [Что нового](https://github.com/itsnotkubrick/3X-UI_KIT/releases/tag/v1.1) · | Панель **3X-UI** + ядро **Xray** | управление подключениями и пользователями |
> сервер на 1.0 обновляется одной командой: | **nginx** на порту 443 | разводит подключения по именам и путям, отдаёт сайт-заглушку |
> ```bash | VPN-подключения | REALITY, Hysteria2, AmneziaWG, XHTTP, VLESS-WS, Trojan-gRPC, VMess-WS, Shadowsocks, TUIC, MTProto, WireGuard — какие выберете |
> curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update | **Подписка** (`kit-sub`) | одна ссылка на все устройства: Happ, Hiddify, v2rayN, Clash/Mihomo |
> ``` | **Сайт-заглушка** | на 443 и в ответе Hysteria2 — правдоподобный ИТ-сайт из нашего набора |
| **ufw**, обновления безопасности, синхронизация времени | защита сервера и корректная работа REALITY |
**3X-UI KIT** превращает чистый VPS в готовый VPN-сервер за несколько минут. Скрипт ставит
официальную панель [3X-UI](https://github.com/MHSanaei/3x-ui), настраивает все популярные
протоколы, сертификат и файрвол и выдаёт одну ссылку-подписку. Её можно вставить в любое
приложение – оно само получит подходящие ему протоколы. Домен не нужен.
<div align="center">
<img alt="Конец установки 3X-UI KIT" src="manuals/assets/script-3x-ui.svg" width="760">
</div>
## Возможности
- 🧩 **11 протоколов сразу** – VLESS REALITY, XHTTP, WebSocket, Trojan gRPC, VMess,
Shadowsocks 2022, Hysteria2, TUIC, AmneziaWG (классика и 3.1) и MTProto для Telegram.
- 🚪 **Всё TCP – через порт 443.** Панель, подписка и протоколы спрятаны за одним портом,
а на случайный заход сервер показывает обычный сайт.
- 🔗 **Одна подписка на все приложения.** Hiddify, Happ, v2rayN, Karing, Clash Verge и FlClash
получают свой формат и только те протоколы, которые умеют.
- 👥 **Дополнительные пользователи одной командой** – `kit user add` добавляет пользователя
сразу во все протоколы с общим лимитом трафика, сроком и числом устройств.
- 🔄 **Обновляется сам, но только подписанными релизами**, а `kit backup` сохраняет
копию сервера с пользователями и ключами.
- 🔒 **Сертификат Let's Encrypt на IP** выпускается и продлевается сам, панель скрыта на
случайном пути со случайными логином и паролем.
- ✅ **Проверено настоящими клиентами** – каждый протокол на ядрах Xray, Mihomo и sing-box,
в том числе с сервером в России: [tests/matrix](tests/matrix/).
## Что понадобится
- VPS с **Ubuntu 22.04/24.04** или **Debian 12/13** и доступом root по SSH
- Свободные порты **443** и **80** – на свежем сервере они свободны
## Установка ## Установка
Подключитесь к серверу по SSH и выполните: От root, на чистом сервере (Debian 11+ / Ubuntu 22.04+, 64-бит):
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh) bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
``` ```
Через пару минут скрипт покажет адрес панели, логин, пароль и подписку с QR-кодом. Без флагов скрипт **спрашивает всё заранее, до установки**: какие протоколы ставить (Enter — REALITY +
AmneziaWG-3.1), менять ли порт SSH (Enter — оставить 22), менять ли пароль root, закрывать ли ответ на
ping, включать ли автообновления безопасности. Затем сканирует подсеть и **предлагает выбрать сайт
маскировки** — отдельно для REALITY и, если выбраны XHTTP/MTProto, отдельно для них: номер из найденных
соседей, `0` — ввести адрес вручную, для XHTTP/MTProto ещё и известный сайт (Google, Microsoft, AMD…).
Подробно – подключение приложений, дополнительные пользователи и параметры – После установщика скрипт **ничего не спрашивает** — только выполняет выбранное, проверяет сервер и в
в **[инструкции](manuals/3x-ui.md)**. Нужен только Hysteria2 – есть самом конце показывает то, что нужно унести: **доступ к панели 3X-UI** (адрес, логин, пароль) и
[отдельный скрипт](manuals/hysteria2.md). **подписку с QR-кодом** и ссылками по протоколам.
> [!WARNING] <img alt="Установка одной командой" src="manuals/assets/script-3x-ui-local.svg" width="860">
> Проект создан в образовательных целях. Убедитесь, что ваши действия
> соответствуют законодательству вашей страны.
## Генератор конфигов для роутера Если вопросы не нужны — те же ответы задаются флагами:
Вставьте ссылку на сервер или подписку, выберите роутер и ядро, отметьте нужные сервисы – ```bash
и получите готовый конфиг и одну команду, которая сама положит его на роутер. # минимум: свой домен и два протокола, без вопросов
Всё считается в браузере, ссылки никуда не отправляются. bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols reality,awg3 --domain vpn.example.com
Как поставить XKeen на роутер – в [инструкции для Keenetic](manuals/xkeen-keenetic.md).
| Роутер | Ядро | Что получится | Генератор | # всё, кроме WireGuard, и второй порт SSH
|---|---|---|---| bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols все --ssh-port 2222
| Keenetic (XKeen) | Xray | `04_outbounds.json` и `05_routing.json`: серверы, выбор сервисов, реклама, свои сайты | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?core=xray) |
| Keenetic (XKeen) | Mihomo | `config.yaml` с автовыбором сервера, подпиской, Hysteria2, AmneziaWG и веб-панелью | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?core=mihomo) |
| OpenWrt (Nikki), бета | Mihomo | профиль для Nikki и команда, которая его включит | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?router=openwrt) |
## Полезное # ничего не спрашивать: ping закрыть, автообновления включить, порт и пароль не трогать
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --yes
```
- [XKeen](https://github.com/jameszeroX/XKeen) и его [вики](https://github.com/jameszeroX/XKeen/wiki) – документация по маршрутизации на Keenetic Проверить канал, ничего не меняя:
- [XKeen UI](https://github.com/zxc-rv/XKeen-UI) – веб-интерфейс для XKeen
- [IP-адреса для AmneziaWG](https://github.com/RockBlack-VPN/ip-address) – актуальные списки от RockBlack
## Поддержать проект ```bash
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --verify-only # подпись релиза и сумма установщика
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --dry-run # что именно будет сделано
```
Скрипты и инструкции бесплатные. Донат добровольный – он помогает оплачивать ### Флаги установки
тестовые серверы и держать скрипты в актуальном состоянии. Спасибо! 💜
| Способ | | | Флаг | Что делает |
|---|---| |---|---|
| Российской картой, СБП, Tinkoff Pay | [CloudTips](https://pay.cloudtips.ru/p/d4f9e3d1) | | `--protocols all\|minimal\|список` | какие протоколы ставить: `all`, `minimal` (только REALITY) или через запятую. Невыбранные не создаются вообще |
| Зарубежной картой, Apple Pay, Google Pay | [Buy Me a Coffee](https://buymeacoffee.com/relo.cate) | | `--domain ваш.домен` | свой домен для маскировки (A-запись на сервер, свободный порт 80 — сертификат Let's Encrypt выпустит установщик) |
| USDT (TRC-20) | `TS83ViXrdezUpp1eFadqj1rBhGLZaba1c1` | | `--sni сайт.ком` | маскировка под чужой сайт вместо своего домена |
| TON | `UQBchO4XFPwF9MMa_tjXpwqTo8IL2FhUDyllhYuFo8WM-Qbf` | | `--panel-on ip\|domain` | где открывать панель и подписку |
| Ethereum (ERC-20) | `0xC06F6B3A029d7Ea00705B7028490744e2BC16799` | | `--site N\|random\|off` | сайт-заглушка: конкретный из нашего набора, случайный (по умолчанию) или не трогать |
| `--ssh-port N` | перевести SSH на порт N (прежний закроется только после вашей проверки) |
| `--harden` / `--no-harden` | закрыть ping и включить автообновления безопасности / не трогать это |
| `--yes` | не задавать вопросы (для автоматизации) |
| `--host IP`, `--user имя`, `--port N`, `--restore файл` | адрес в ссылках, имя первого клиента, порт REALITY, восстановление из копии `kit backup` |
| `--clean` | убрать прошлую (оборвавшуюся) установку и поставить заново — SSH не трогает |
| `--auto-sni` | не показывать меню выбора сайта маскировки, подобрать самим (автоподбор автора) |
| `--no-sub` | не показывать в конце доступ к панели, подписку и QR-код |
| `--no-time`, `--no-check`, `--verify-only`, `--dry-run`, `-h` | служебные |
## Благодарности ## Что происходит по шагам
3X-UI KIT построен на работе авторов этих проектов: 1. **Проверка сервера**: root, Debian/Ubuntu, память, место на диске, занятые порты 80/443.
[3X-UI](https://github.com/MHSanaei/3x-ui) · 2. **Проверка релиза**: файлы берутся из этого репозитория по тегу, проверяется **подпись `SHA256SUMS`**
[Xray-core](https://github.com/XTLS/Xray-core) · нашим ключом и SHA256 самого установщика. Не сошлось — ничего не запускается.
[Mihomo](https://github.com/MetaCubeX/mihomo) · 3. **Наши вопросы** (до установки): протоколы, порт SSH, пароль root, ping, автообновления.
[Hysteria](https://github.com/apernet/hysteria) · 4. **Установщик**: панель 3X-UI, только выбранные подключения, nginx на 443, сертификат, сайт-заглушка,
[AmneziaWG](https://github.com/amnezia-vpn) · служба подписки, правила ufw. Маскировку спрашивает он: свой домен, сосед по подсети или свой сайт.
[XKeen](https://github.com/jameszeroX/XKeen) Если выбран сосед — сначала идёт **скан подсети**, затем меню выбора сайта (см. выше).
5. **Наши действия** (без вопросов): правдоподобный ИТ-сайт-заглушка вместо авторской страницы,
`kit fix` (время и безопасные починки), закрыть ответ на ping, включить обновления безопасности,
перевести SSH на новый порт, сменить пароль root.
6. **Проверка**: `kit check` (порты, службы, подписка, сертификат, маскировка) и сводка
`kit local hardening`.
7. **В самом конце** — **доступ к панели 3X-UI** (адрес, логин, пароль из `/root/3x-ui.txt`) и
**подписка с QR-кодом** и ссылками по протоколам. После них скрипт уже ничего не делает и не спрашивает.
## Протоколы
Один порт 443 на всё: nginx разводит подключения по именам и путям, поэтому снаружи видно только
443/tcp (и Hysteria2 на 443/udp). Список и умолчание — как у проекта: невыбранные протоколы не
создаются вообще, ни подключения в панели, ни записей в nginx, ни ссылок в подписке.
| № | Протокол | Что это |
|---|---|---|
| 1 | **REALITY** | VLESS + Reality, основной вход. **По умолчанию** |
| 2 | REALITY-2 | второй REALITY на случайном высоком порту |
| 3 | Hysteria2 | быстрее на плохой сети (UDP 443) |
| 4 | XHTTP | VLESS + Reality под другим именем маскировки |
| 5 | VLESS-WS | VLESS через WebSocket |
| 6 | Trojan-gRPC | Trojan через gRPC |
| 7 | VMess-WS | VMess через WebSocket |
| 8 | Shadowsocks | Shadowsocks 2022 |
| 9 | TUIC | быстрый UDP-протокол |
| 10 | WireGuard | обычный WireGuard: легко распознаётся, поэтому по умолчанию не ставится |
| 11 | AmneziaWG | AmneziaWG: Mihomo, роутеры Keenetic |
| 12 | **AmneziaWG-3.1** | AmneziaWG с защитой заголовков: приложения AmneziaVPN. **По умолчанию** |
| 13 | MTProto | прокси для Telegram |
Наш набор по умолчанию — **REALITY + AmneziaWG-3.1**: работает почти везде и не требует
дополнительных объяснений на клиенте. Маскировку и сайт можно менять потом: `kit net site`,
`kit local site`.
**Сайт маскировки выбираете вы.** После скана подсети установщик спрашивает отдельно для REALITY
(найденный сосед или свой адрес) и отдельно для XHTTP/MTProto (найденный, свой или известный сайт —
список известных расширен Google, Amazon, Samsung, Yahoo, Microsoft, AMD, Intel, NVIDIA). Выбранный
адрес проверяется на TLS 1.3 + HTTP/2. Если XHTTP и MTProto не выбраны, второго вопроса нет.
Для протоколов, которые **не** выбраны, не делается ничего: ни подключений, ни записей в nginx, ни
ссылок в подписке, ни лишних обращений к сети (например, обход соседей запускается только когда он
действительно нужен).
## Сайт-заглушка
На 443 при неизвестном имени отдаётся страница нашего набора: небольшие правдоподобные ИТ-сайты
вместо стандартных «страница в разработке».
| № | Сайт | Тема |
|---|---|---|
| 1 | Radian Analytics | дата-аналитика: хранилище, отчёты, дашборды |
| 2 | Circuit Nine | DevOps-консалтинг: CI/CD, инфраструктура, наблюдаемость |
| 3 | Cortex Monitor | мониторинг серверов: проверки, алерты, сертификаты |
| 4 | Bluefin Software | разработка ПО на заказ |
| 5 | Vantage Security | аудит и пентест |
| 6 | Keystone IT | ИТ-поддержка офисов |
| 7 | Meridian Networks | колокейшн и транзит |
| 8 | Lumen Notes | заметки про Linux, nginx и сети |
| 9 | Evercore Backup | бэкапы и восстановление |
| 10 | Halyard Docs | документация проекта |
```bash
kit local site # случайный сайт из набора
kit local site --list # список с номерами
kit local site 7 # поставить конкретный
kit local site --show # какой стоит сейчас
```
Менять страницу нужно **этой командой**: `/var/www/kit/index.html` отдаёт nginx, а Hysteria2 держит
свою копию страницы внутри подключения. Команда обновляет обе — иначе 443/tcp и UDP начнут отвечать
разными страницами, и это заметно снаружи.
## Команды
На сервере после установки доступна команда `kit` (без аргументов — меню).
| Команда | Что делает |
|---|---|
| `kit user …` | `add`, `list`, `link`, `limit`, `on`, `off`, `del` — пользователи, ссылки, лимиты трафика и сроков |
| `kit net` | состояние подключений; `on`/`off` включить-выключить, `site` — сайт маскировки, `masq` — ответ Hysteria2 страницей, `dns` — DoH в подписках |
| `kit port` / `kit port set <имя> <порт>` | какие порты слушают подключения / сменить порт подключения |
| `kit reality add [порт]` | добавить второй REALITY |
| `kit check [--deep\|--fix]`, `kit fix` | проверка сервера (в том числе с самого сервера, как клиент) и безопасные починки |
| `kit update [--panel]`, `--auto`, `--manual` | обновление сборки и панели, режим автообновления |
| `kit backup` | копия сервера (панель, сертификаты, настройки) |
| `kit version` | версии сборки, панели и ядра |
Наш слой — `kit local` (справка: `kit local help`):
| Команда | Что делает |
|---|---|
| `kit local site` | сайт-заглушка: случайный, `--list`, номер, `--show` |
| `kit local ssh 2222` / `--status` / `--close-old 2222` | добавить порт SSH / какие слушают / закрыть прежние после проверки |
| `kit local ping off\|on\|status` | ответ на ICMP echo правилом ufw (видно в `ufw status`, отменяется одной командой) |
| `kit local passwd [юзер]`, `--generate` | смена пароля (спрашивает дважды, в логи не попадает) |
| `kit local upgrades on\|off\|status` | обновления безопасности |
| `kit local uninstall` | **снести установку целиком**: панель, службы и файлы kit, nginx и сайт, сертификаты, наши правки системы и правила ufw для 80/443. Спрашивает слово «удалить», есть `--dry-run`. **SSH не трогает** |
| `kit local hardening`, `masking`, `channel`, `seams`, `version` | сводка защиты, состояние маскировки, канал обновлений, какие места форк правит у автора |
У любой правки есть `--dry-run` — показывает план и ничего не меняет. Копии изменённых файлов
складываются в `/root/kit-local-backup-<дата>/`.
## Обновление и канал
- Сервер обновляется из **этого репозитория**: `kit update` и ночной таймер берут релиз с подписью
нашего ключа. Чужой релиз сервер не примет.
- Посмотреть, откуда берутся обновления и чем проверяется подпись: `kit local channel`.
- Управление автообновлением: `kit update --auto` (включить) и `kit update --manual` (выключить),
`kit update` — обновиться сейчас, `kit update --panel` — обновить саму панель 3X-UI.
- Проверить канал, ничего не устанавливая: установщик с `--verify-only` или `kit local channel`.
## Оговорки
- **Пароль root** никогда не передаётся флагом: значение в командной строке видно другим процессам.
Спрашивается в ходе установки или меняется `kit local passwd`.
- **Смена порта SSH** делается в два шага: новый порт добавляется, прежний продолжает работать, пока
вы не проверите вход во втором окне. Закрыть прежние: `kit local ssh --close-old 2222`.
- **`apt full-upgrade` не выполняется** — только обновления безопасности: панель и ядро Xray
закреплены на проверенных версиях.
- Версии панели и ядра в сборке сверяются с официальными хешами при выпуске релиза.
## Документация форка
| Файл | О чём |
|---|---|
| [FORK.md](FORK.md) | как устроен форк: где наши правки (швы), регламент обновления из авторского проекта, канал, подпись, грабли |
| [FORK-CHANGELOG.md](FORK-CHANGELOG.md) | что менялось в наших версиях |
| `kit local help` | команды форка прямо на сервере |
+9 -6
View File
@@ -1,6 +1,9 @@
# 3X-UI KIT 1.1 # 3X-UI KIT 1.2.1.6
313d8d47f425b51dc5888af7fd2015dd99f44c1fac0022f140ef8de91b35d99f scripts/3x-ui.sh # 3X-UI_autoVPS 1.2.1.6
3497f4b55aedc91940dd03a3bf0d3c8153f5e86c0c9a4a4895f38da540a645e6 scripts/hysteria2.sh f30fc23d1c667d6fc90742fdf12377ca84d9e119fc394d86822bf1daaf462795 scripts/3x-ui.sh
f585d789d82a326ae1775f74494e7230178969b8a9e8c1fd382c5092877c8d73 scripts/kit.sh 4ef4c3f830196704648c15dd546e20195ce15e2d4ee6ca66e363aac47811c4ab scripts/hysteria2.sh
aae32dea127a50cf6e5b30915a21168d1a88f5c1010d641c19820a52d50afef3 scripts/kit-sub.py b85e6ca11eef15eed8d45fba9e7b4a7eddf6141de4aeceaac1fe7f51d4c9fa78 scripts/kit.sh
1523e9e982ff804c6d979e1bb2c6f3d7bae35307e8ce458bffc0d12a442b98da VERSION f214671464e1c2e174e5af9df42c95567a9b4a9141f31a39f7df2e971e89d240 scripts/kit-sub.py
75cc7bc4e048c43989f66ca3b9e9addff8d7a434e651daba6db894e18109b45b scripts/kit-local.sh
ba87dfba9abc3417092a2c2ec8da1b0332397e9952ead213e9367692f2849459 scripts/3x-ui-local.sh
3acd832d329f916790a07153fcc6b7cbdbeb6958131e60751094c059e4fb0f76 VERSION
+4 -4
View File
@@ -1,6 +1,6 @@
-----BEGIN SSH SIGNATURE----- -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgatMYIOGGebxc7V6Sk8m5JZFUIm U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV
IWK6kSyl9DFxsQGbMAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
AAALc3NoLWVkMjU1MTkAAABAsmJHzAWZ9uDnSG5hoMfvH/vgx2rI3qG33quMNBAVsAE5c1 AAALc3NoLWVkMjU1MTkAAABADBMIIRfAe53BhNokGqyMTekQwEmlBZ8HdhkKUXjRPv0ofv
zi16FA4+hxyGYqRsxN/l30ZfyfURrpRKAAoMQHBw== u4/0GW5p5d2QnwShb7+zS/Dr+LekQ2SMQEBvVbAA==
-----END SSH SIGNATURE----- -----END SSH SIGNATURE-----
+1 -1
View File
@@ -1 +1 @@
1.1 1.2.1.6
+110 -18
View File
@@ -30,7 +30,7 @@
| VLESS WebSocket + TLS | 443/tcp | всех приложениях | | VLESS WebSocket + TLS | 443/tcp | всех приложениях |
| Trojan gRPC + TLS | 443/tcp | всех приложениях | | Trojan gRPC + TLS | 443/tcp | всех приложениях |
| VMess WebSocket + TLS | 443/tcp | всех приложениях | | VMess WebSocket + TLS | 443/tcp | всех приложениях |
| Shadowsocks 2022 | 8388/tcp+udp | всех приложениях (мобильный МТС его блокирует, по Wi-Fi работает) | | Shadowsocks 2022 | 8388/tcp+udp | всех приложениях (если не подключается, попробуйте другой протокол) |
| TUIC v5 | 8444/udp | Hiddify, Clash Verge, FlClash, NekoBox | | TUIC v5 | 8444/udp | Hiddify, Clash Verge, FlClash, NekoBox |
| AmneziaWG | 51821/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash | | AmneziaWG | 51821/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash |
| AmneziaWG 3.1 | 51822/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash | | AmneziaWG 3.1 | 51822/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash |
@@ -38,17 +38,16 @@
**Все TCP-протоколы, панель и подписка работают через один порт 443.** Его слушает nginx: **Все TCP-протоколы, панель и подписка работают через один порт 443.** Его слушает nginx:
по имени сайта в запросе (SNI) он узнаёт REALITY, XHTTP и MTProto, остальное расшифровывает по имени сайта в запросе (SNI) он узнаёт REALITY, XHTTP и MTProto, остальное расшифровывает
и разводит по секретным путям, а на всё прочее показывает сайт-заглушку. Снаружи у сервера и разводит по секретным путям, а на всё прочее показывает сайт-заглушку. Заглушка своя у каждого сервера:
при установке она собирается случайно (тема, название, тексты, вёрстка, цвета) и лежит в `/var/www/kit/index.html`,
её же показывает Hysteria2. Можно положить туда свою страницу. Снаружи у сервера
открыты только 443, 80 (для продления сертификата), SSH, порт Shadowsocks 8388 и UDP-порты. Настоящие IP клиентов открыты только 443, 80 (для продления сертификата), SSH, порт Shadowsocks 8388 и UDP-порты. Настоящие IP клиентов
доходят до панели, поэтому лимит устройств работает. доходят до панели, поэтому лимит устройств работает.
Каждый протокол проверен настоящим подключением на ядрах Xray, Mihomo и sing-box,
в том числе через интернет к серверу в России – стенд лежит в [tests/matrix](../tests/matrix/).
> [!NOTE] > [!NOTE]
> Обычный WireGuard по умолчанию не ставится: в России его рукопожатие режет DPI, > Обычный WireGuard по умолчанию не ставится: его рукопожатие легко распознаётся сетевым
> а попытки подключения могут привести к блокировке IP сервера. AmneziaWG – тот же WireGuard > оборудованием, из-за чего соединение нестабильно. AmneziaWG – тот же WireGuard с маскировкой –
> с маскировкой – проходит. Если сервер и пользователи за границей, добавьте `wg` в `--protocols`. > работает лучше. Если сервер и пользователи за границей, добавьте `wg` в `--protocols`.
## Что понадобится ## Что понадобится
@@ -92,6 +91,12 @@ Enter) – стандартный чужой сайт, работает сраз
а Happ, v2rayN, Karing и Hiddify – нет, потому что не умеют его. Для AmneziaVPN импортируйте а Happ, v2rayN, Karing и Hiddify – нет, потому что не умеют его. Для AmneziaVPN импортируйте
ссылки `vpn://` из `/root/3x-ui.txt`, для Telegram – ссылку `tg://` оттуда же. ссылки `vpn://` из `/root/3x-ui.txt`, для Telegram – ссылку `tg://` оттуда же.
## Меню `kit`
Команда `kit` без аргументов открывает меню, как у `x-ui`: пользователи, протоколы и порты, проверка, обновление,
копия сервера. Выберите цифру, остальное `kit` спросит сам. Те же действия есть командами: `kit user …`, `kit net …`,
`kit check`, `kit update`, `kit backup`, `kit version` (справка: `kit help`).
## Дополнительные пользователи ## Дополнительные пользователи
Одна команда на сервере добавляет пользователя сразу во все протоколы и выдаёт его подписку с QR-кодом: Одна команда на сервере добавляет пользователя сразу во все протоколы и выдаёт его подписку с QR-кодом:
@@ -104,7 +109,7 @@ kit user add sasha --gb 50 --days 30
|---|---| |---|---|
| `kit user add имя [--gb N] [--days N] [--devices N]` | добавить; лимит трафика, срок и число устройств – по желанию | | `kit user add имя [--gb N] [--days N] [--devices N]` | добавить; лимит трафика, срок и число устройств – по желанию |
| `kit user list` | кто сколько израсходовал, до какого числа и когда был в сети | | `kit user list` | кто сколько израсходовал, до какого числа и когда был в сети |
| `kit user link имя [--all]` | подписка и QR-код ещё раз; `--all` – ещё ссылки `vpn://` и `tg://` | | `kit user link имя [--all \| --amnezia \| --telegram]` (рядом с подпиской – ссылки «открыть в приложении») | подписка и ссылки ещё раз: основные и запасные по группам; AmneziaVPN и Telegram – отдельными ключами |
| `kit user limit имя --gb N --days N` | изменить лимиты (0 – без ограничений) | | `kit user limit имя --gb N --days N` | изменить лимиты (0 – без ограничений) |
| `kit user off имя` / `kit user on имя` | временно выключить и включить | | `kit user off имя` / `kit user on имя` | временно выключить и включить |
| `kit user del имя` | удалить | | `kit user del имя` | удалить |
@@ -113,7 +118,7 @@ kit user add sasha --gb 50 --days 30
## Обновление ## Обновление
Обновления kit и подписки kit-sub приходят сами: раз в сутки ночью сервер проверяет, не вышла ли Обновления KIT и подписки kit-sub приходят сами: раз в сутки ночью сервер проверяет, не вышла ли
новая версия 3X-UI KIT, и ставит её. Пользователи, ссылки и подписки не меняются, раздавать новая версия 3X-UI KIT, и ставит её. Пользователи, ссылки и подписки не меняются, раздавать
ничего заново не нужно. ничего заново не нужно.
@@ -135,30 +140,117 @@ kit version # версии kit, панели и ядра, включе
Сервер ставили версией 1.0? В ней команды `kit update` ещё нет, поэтому один раз: Сервер ставили версией 1.0? В ней команды `kit update` ещё нет, поэтому один раз:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1.2/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update
``` ```
Дальше обновления будут приходить сами. Дальше обновления будут приходить сами.
Панель 3X-UI обновляется отдельно, через `x-ui`. Ядро Xray в панели не обновляйте до последней Панель 3X-UI обновляет `kit update --panel` (проверенная версия, ядро и настройки сохраняются). Ядро Xray в панели не обновляйте до последней
версии: с Xray 26.7 и новее Hiddify, FlClash и Clash Verge не подключаются к REALITY. версии: с Xray 26.7 и новее Hiddify, FlClash и Clash Verge не подключаются к REALITY.
## Проверка и починка
```bash
kit check # проверить сервер, ничего не меняет
kit check --deep # то же и подключиться клиентом (REALITY, XHTTP, Hysteria2) с самого сервера
kit check --fix # исправить безопасное
```
`kit check` проверяет службы, что ядро Xray запущено и каждое включённое подключение слушает свой порт (TCP и UDP
отдельно), версии, срок сертификата, что панель не торчит наружу, права на файлы с
паролями и ключами, ответ подписки для Happ, Hiddify, v2rayN и Clash, а также что сайт маскировки отвечает по
TLS 1.3 и HTTP/2, и подсказывает, что улучшить (сайт маскировки, лишние порты). Вывод можно просто прислать в чат, если нужна помощь.
`kit check --fix` лечит только безопасное: перезапускает упавшие службы и ядро Xray, возвращает права `600`, включает
автообновление, перечитывает сертификат и снимает TLS у встроенной подписки 3X-UI (его включает команда
`x-ui cert`, и подписка перестаёт отвечать). Ничего необратимого он не делает.
> [!NOTE]
> Проверка идёт с самого сервера: доступность из вашей сети она не покажет.
## Протоколы, порты и сайт маскировки (`kit net`)
```bash
kit net # протоколы, порты, статус, сайт маскировки и отпечаток одним экраном
kit net site # подобрать сайт маскировки среди соседей по подсети (около минуты)
kit net site сайт.com # или задать самому (проверю TLS 1.3 и HTTP/2)
kit net port имя порт # сменить порт подключения: ufw и ссылки обновятся сами
kit net off vmess-ws # выключить протокол и закрыть его порт (вернуть: kit net on vmess-ws)
kit net on reality2 # создать второй REALITY на высоком порту
kit net fp firefox # сменить отпечаток клиента у всех подключений
kit net masq on # Hysteria2 отвечает на чужой запрос страницей сайта
kit net panel domain # панель и подписка открываются и по домену (только со своим доменом); kit net panel ip – как раньше
kit net split on # через VPN только список (по умолчанию популярные иностранные сервисы), остальное напрямую; kit net split check | off
```
Панель меняет подключения на ходу, без перезапуска. Если ядро Xray не приняло правку и порт закрылся (так бывает с
маскировкой Hysteria2), `kit net` сам перезапускает ядро и говорит об этом: подключения прерываются на пару секунд.
Со своим доменом панель и подписка по умолчанию открываются по IP (`https://IP/путь/`). Если нужен адрес с доменом (например, для Telegram-бота панели
или ссылки, которая не зависит от IP), выберите «по домену» при установке (`--panel-on domain`) или позже: `kit net panel domain`. Путь у них секретный и по домену, как и по IP.
### Раздельная маршрутизация (`kit net split`)
Если нужно, чтобы через VPN шли только выбранные сервисы, а всё остальное напрямую, включите: `kit net split on`. На сервере появится
файл `/etc/kit-sub/rules.yaml` со списком иностранных сервисов (по умолчанию популярные: нейросети, видео и музыка, соцсети и мессенджеры, GitHub, Google и другие, всего около 50 категорий; тяжёлые и региональные – в комментариях, раскомментируйте нужные). Российские сайты в список не добавляйте: всё, чего в нём нет, и так идёт напрямую.
Правила приезжают вместе с подпиской, на телефонах и компьютерах ничего настраивать не нужно: достаточно обновить подписку в приложении.
| Приложение | Как получает правила |
|---|---|
| Clash Verge, FlClash, Mihomo Party (ядро Mihomo) | из подписки, сразу (режим «Rule», не «Global») |
| Happ | заголовком подписки (профиль «3X-UI KIT» включается сам) |
| Приложения, которые берут JSON-подписку Xray (адрес «JSON» со страницы подписки) | из конфига подписки |
| v2rayN, v2rayNG и другие с обычным списком ссылок, Karing, Hiddify, sing-box | не получают: подписка прежняя, весь трафик через VPN; раздельную маршрутизацию там настраивают в самом приложении |
```
kit net split on # создать список и включить
nano /etc/kit-sub/rules.yaml
kit net split check # проверить файл: сколько правил применится и что отброшено
kit net split apply # после правки списка: обновить профили Happ и Xray в панели (Mihomo берёт файл сам)
kit net split off # выключить (список сохранится в rules.yaml.off)
```
Формат: `GEOSITE,youtube` (готовая категория), `DOMAIN-SUFFIX,example.org`, `DOMAIN,api.example.org`, `DOMAIN-KEYWORD,слово`, `IP-CIDR,203.0.113.0/24`.
Лишнее и непонятное KIT отбрасывает; если список пуст или повреждён, подписка отдаётся как обычно. Список входит в `kit backup`.
DNS при этом не течёт: имена из списка у клиента не разрешаются, а запросы про них идут по DoH через VPN; для остального сайта используется
системный DNS приложения (или адреса `direct_dns` из файла). Категории GEOSITE приложение один раз скачивает с GitHub.
Лучший сайт маскировки – ваш собственный домен (`--domain` при установке): сервер изображает ваш сайт, а сертификат
и имя совпадают. Без домена установщик подбирает сайт среди соседей по подсети сервера: около 250 коротких обращений к порту 443
соседних адресов (не нужно – укажите `--sni сайт` или `--domain`). Любой протокол можно выключить командой `kit net off имя` и включить обратно `kit net on имя`.
## Резервная копия ## Резервная копия
```bash ```bash
kit backup kit backup
``` ```
Сохранит в `/root/kit-backup-ДАТА.tar.gz` всё, что нужно, чтобы поднять тот же сервер в другом Сохранит в `/root/kit-backup-ДАТА.tar.gz` копию сервера: подключения, ключи, пользователей с их
месте: подключения, ключи, пользователей с их лимитами, настройки подписки и nginx. Файл виден лимитами, настройки подписки и nginx. Файл виден только root, и в нём ключи от сервера, поэтому
только root, и в нём ключи от сервера, поэтому храните его как пароль. Скачать к себе: храните его как пароль. Скачать к себе:
```bash ```bash
scp root@IP:/root/kit-backup-ДАТА.tar.gz . scp root@IP:/root/kit-backup-ДАТА.tar.gz .
``` ```
Восстановление из копии на новом VPS пока в разработке. Сейчас копия нужна как страховка: Храните копию отдельно от сервера.
храните её отдельно от сервера.
Новый чистый сервер поднимается из копии одной командой (нужны `3x-ui.sh` и файл копии):
```bash
bash 3x-ui.sh --restore /root/kit-backup-ДАТА.tar.gz -y
```
Подключения, ключи, пользователи и их лимиты вернутся прежними. Если адрес сервера сменился, подписку клиентам нужно
добавить заново, а на домене ничего менять не придётся: достаточно переключить DNS.
Сертификат Let's Encrypt на новый IP выпускается заново. Если он не выдался (лимит – 5 сертификатов в неделю на один IP,
или закрыт входящий порт 80), скрипт скажет об этом и поставит самоподписанный сертификат с отпечатком в ссылках:
ссылки на подключения работают, а подписка в приложениях может не открыться. Если ядро Xray после восстановления
не запустилось, скрипт не скажет «Готово», а покажет, где искать причину.
Восстанавливайте только из своей копии. Архив с чужими путями, ссылками, лишними настройками или значениями
не в своём формате (порт – не число, путь со странными символами, непечатные байты в файлах настроек) скрипт не примет («Это не резервная копия 3X-UI KIT…») и ничего не изменит.
## Параметры ## Параметры
@@ -196,4 +288,4 @@ x-ui
--- ---
[← На главную](../README.md) · [Дальше: 🚀 Hysteria2 →](hysteria2.md) [← На главную](../README.md)
+20 -22
View File
@@ -1,26 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="300" viewBox="0 0 1200 300" role="img" aria-label="3X-UI KIT: свой VPN-сервер, все протоколы, один порт, одна подписка"><title>3X-UI KIT: свой VPN-сервер, все протоколы, один порт, одна подписка</title> <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="300" viewBox="0 0 1200 300" role="img" aria-label="3X-UI KIT: установка сервера одной командой"><title>3X-UI KIT: установка сервера одной командой</title>
<defs><clipPath id="r"><rect width="1200" height="300" rx="20"/></clipPath></defs> <defs><clipPath id="r"><rect width="1200" height="300" rx="20"/></clipPath></defs>
<g clip-path="url(#r)"><rect width="1200" height="300" fill="#1a1411"/><path d="M60 0V300" stroke="#1d1713" stroke-width="2"/><path d="M156 0V300" stroke="#1d1713" stroke-width="2"/><path d="M252 0V300" stroke="#1d1713" stroke-width="2"/><path d="M348 0V300" stroke="#1d1713" stroke-width="2"/><path d="M444 0V300" stroke="#1d1713" stroke-width="2"/><path d="M540 0V300" stroke="#1d1713" stroke-width="2"/><path d="M636 0V300" stroke="#1d1713" stroke-width="2"/><path d="M732 0V300" stroke="#1d1713" stroke-width="2"/><path d="M828 0V300" stroke="#1d1713" stroke-width="2"/><path d="M924 0V300" stroke="#1d1713" stroke-width="2"/><path d="M1020 0V300" stroke="#1d1713" stroke-width="2"/><path d="M1116 0V300" stroke="#1d1713" stroke-width="2"/> <g clip-path="url(#r)"><rect width="1200" height="300" fill="#1a1411"/><path d="M60 0V300" stroke="#1d1713" stroke-width="2"/><path d="M156 0V300" stroke="#1d1713" stroke-width="2"/><path d="M252 0V300" stroke="#1d1713" stroke-width="2"/><path d="M348 0V300" stroke="#1d1713" stroke-width="2"/><path d="M444 0V300" stroke="#1d1713" stroke-width="2"/><path d="M540 0V300" stroke="#1d1713" stroke-width="2"/><path d="M636 0V300" stroke="#1d1713" stroke-width="2"/><path d="M732 0V300" stroke="#1d1713" stroke-width="2"/><path d="M828 0V300" stroke="#1d1713" stroke-width="2"/><path d="M924 0V300" stroke="#1d1713" stroke-width="2"/><path d="M1020 0V300" stroke="#1d1713" stroke-width="2"/><path d="M1116 0V300" stroke="#1d1713" stroke-width="2"/><rect y="268" width="1200" height="32" fill="#120e0c"/></g>
<rect y="262" width="1200" height="38" fill="#120e0c"/></g>
<rect x="1.5" y="1.5" width="1197" height="297" rx="19" fill="none" stroke="#15110F" stroke-width="3"/> <rect x="1.5" y="1.5" width="1197" height="297" rx="19" fill="none" stroke="#15110F" stroke-width="3"/>
<g font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="66" letter-spacing="1"> <g font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="66" letter-spacing="1">
<text x="80" y="108" fill="#15110F" stroke="#15110F" stroke-width="14" stroke-linejoin="round">3X-UI KIT</text> <text x="80" y="104" fill="#15110F" stroke="#15110F" stroke-width="14" stroke-linejoin="round">3X-UI KIT</text>
<text x="80" y="108" fill="#FBF6EC" style="paint-order:stroke">3X-UI KIT</text></g> <text x="80" y="104" fill="#FBF6EC" style="paint-order:stroke">3X-UI KIT</text></g>
<g font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="42" letter-spacing="1"> <g font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="35" letter-spacing="1">
<text x="82" y="164" fill="#8A66C4" stroke="#15110F" stroke-width="10" stroke-linejoin="round" transform="translate(3 4)">СВОЙ VPN ЗА МИНУТЫ</text> <text x="82" y="156" fill="#8A66C4" stroke="#15110F" stroke-width="10" stroke-linejoin="round" transform="translate(3 4)">УСТАНОВКА ОДНОЙ КОМАНДОЙ</text>
<text x="82" y="164" fill="#93E06F" stroke="#15110F" stroke-width="8" stroke-linejoin="round" style="paint-order:stroke">СВОЙ VPN ЗА МИНУТЫ</text></g> <text x="82" y="156" fill="#93E06F" stroke="#15110F" stroke-width="8" stroke-linejoin="round" style="paint-order:stroke">УСТАНОВКА ОДНОЙ КОМАНДОЙ</text></g>
<text x="82" y="204" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-size="19" font-weight="500" fill="#CBBFA9">11 протоколов, один порт 443, одна подписка на все приложения</text> <g><rect x="77" y="190" width="130" height="38" rx="10" fill="#15110F"/><rect x="74" y="186" width="130" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="91" cy="205" r="4.5" fill="#93E06F"/><text x="104" y="211" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">Порт 443</text></g><g><rect x="219" y="190" width="183" height="38" rx="10" fill="#15110F"/><rect x="216" y="186" width="183" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="233" cy="205" r="4.5" fill="#93E06F"/><text x="246" y="211" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">Одна подписка</text></g><g><rect x="414" y="190" width="205" height="38" rx="10" fill="#15110F"/><rect x="411" y="186" width="205" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="428" cy="205" r="4.5" fill="#93E06F"/><text x="441" y="211" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">С доменом и без</text></g><g><rect x="77" y="238" width="141" height="38" rx="10" fill="#15110F"/><rect x="74" y="234" width="141" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="91" cy="253" r="4.5" fill="#93E06F"/><text x="104" y="259" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">kit check</text></g><g><rect x="230" y="238" width="194" height="38" rx="10" fill="#15110F"/><rect x="227" y="234" width="194" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="244" cy="253" r="4.5" fill="#93E06F"/><text x="257" y="259" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">Автообновление</text></g><g><rect x="436" y="238" width="236" height="38" rx="10" fill="#15110F"/><rect x="433" y="234" width="236" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="450" cy="253" r="4.5" fill="#93E06F"/><text x="463" y="259" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">Keenetic и OpenWrt</text></g>
<g><rect x="77" y="228" width="115" height="38" rx="10" fill="#15110F"/><rect x="74" y="225" width="115" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="90" cy="244" r="4.5" fill="#93E06F"/><text x="102" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">REALITY</text></g><g><rect x="204" y="228" width="135" height="38" rx="10" fill="#15110F"/><rect x="201" y="225" width="135" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="217" cy="244" r="4.5" fill="#93E06F"/><text x="229" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">Hysteria2</text></g><g><rect x="351" y="228" width="135" height="38" rx="10" fill="#15110F"/><rect x="348" y="225" width="135" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="364" cy="244" r="4.5" fill="#93E06F"/><text x="376" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">AmneziaWG</text></g><g><rect x="498" y="228" width="95" height="38" rx="10" fill="#15110F"/><rect x="495" y="225" width="95" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="511" cy="244" r="4.5" fill="#93E06F"/><text x="523" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">XHTTP</text></g><g><rect x="605" y="228" width="84" height="38" rx="10" fill="#15110F"/><rect x="602" y="225" width="84" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="618" cy="244" r="4.5" fill="#93E06F"/><text x="630" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">TUIC</text></g><g><rect x="701" y="228" width="84" height="38" rx="10" fill="#15110F"/><rect x="698" y="225" width="84" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="714" cy="244" r="4.5" fill="#93E06F"/><text x="726" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">:443</text></g> <g transform="rotate(2 940 110)"><rect x="783" y="40" width="360" height="140" rx="14" fill="#15110F"/>
<g transform="rotate(2 940 130)"><rect x="763" y="54" width="360" height="156" rx="14" fill="#15110F"/> <rect x="778" y="34" width="360" height="140" rx="14" fill="#221B17" stroke="#15110F" stroke-width="4"/>
<rect x="758" y="48" width="360" height="156" rx="14" fill="#221B17" stroke="#15110F" stroke-width="4"/> <circle cx="800" cy="54" r="6" fill="#ff5f57"/><circle cx="820" cy="54" r="6" fill="#febc2e"/><circle cx="840" cy="54" r="6" fill="#28c840"/>
<circle cx="780" cy="68" r="6" fill="#ff5f57"/><circle cx="800" cy="68" r="6" fill="#febc2e"/><circle cx="820" cy="68" r="6" fill="#28c840"/> <text x="798" y="92" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="14" fill="#CBBFA9">$ bash 3x-ui.sh</text>
<text x="778" y="108" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="14" fill="#CBBFA9">$ bash 3x-ui.sh</text> <text x="798" y="116" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="13" fill="#CBBFA9">==&gt; Ставлю 3X-UI…</text>
<text x="778" y="136" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="800" font-size="14" fill="#93E06F">Готово! 3X-UI работает:</text> <text x="798" y="142" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="800" font-size="14" fill="#93E06F">Готово! 3X-UI работает.</text>
<text x="778" y="158" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="800" font-size="14" fill="#93E06F">11 протоколов.</text> <text x="798" y="164" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="13" fill="#CBBFA9">https://IP/sub/…</text></g>
<text x="778" y="186" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="13" fill="#CBBFA9">https://IP/sub/…</text></g> <g transform="rotate(-4 1000 222)"><rect x="854" y="206" width="292" height="52" rx="6" fill="#15110F" transform="translate(4 5)"/>
<g transform="rotate(-5 1000 236)"><rect x="880" y="222" width="236" height="46" rx="6" fill="#15110F" transform="translate(4 5)"/> <rect x="854" y="206" width="292" height="52" rx="6" fill="#F2E8D2" stroke="#15110F" stroke-width="3"/>
<rect x="880" y="222" width="236" height="46" rx="6" fill="#F2E8D2" stroke="#15110F" stroke-width="3"/> <circle cx="868" cy="219" r="3.2" fill="#15110F"/><circle cx="1132" cy="219" r="3.2" fill="#15110F"/>
<circle cx="893" cy="235" r="3.2" fill="#15110F"/><circle cx="1103" cy="235" r="3.2" fill="#15110F"/> <text x="1000" y="241" text-anchor="middle" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="23" fill="#15110F" letter-spacing=".5">11 ПРОТОКОЛОВ</text></g>
<text x="998" y="253" text-anchor="middle" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="20" fill="#15110F" letter-spacing=".5">БЕЗ ДОМЕНА</text></g> <text x="1176" y="290" text-anchor="end" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-size="13" font-weight="500" fill="#CBBFA9" font-style="italic">it's not Kubrick. it's just a VPN.</text>
<text x="1176" y="288" text-anchor="end" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-size="13" font-weight="500" fill="#CBBFA9" font-style="italic">it's not Kubrick. it's just a VPN.</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="190" viewBox="0 0 860 190" role="img" aria-label="3X-UI autoVPS — сборка форка">
<style>.t{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
.m{font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}</style>
<rect x="0.5" y="0.5" width="859" height="189" rx="16" fill="#15110f" stroke="#43372e"/>
<circle cx="748" cy="46" r="58" fill="#93e06f" opacity="0.07"/>
<circle cx="808" cy="140" r="34" fill="#93e06f" opacity="0.05"/>
<text x="44" y="86" class="t" font-size="46" font-weight="700" fill="#fbf6ec">3X-UI <tspan fill="#93e06f">autoVPS</tspan></text>
<text x="44" y="118" class="t" font-size="16" fill="#cbbfa9">сервер одной командой: панель 3X-UI, VPN-протоколы, один порт 443, одна подписка</text>
<text x="44" y="152" class="m" font-size="13" fill="#8a8073">свой канал обновлений · подписанные релизы · 13 протоколов · 10 сайтов-заглушек</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="305" viewBox="0 0 860 305" role="img" aria-label="root@vps: ~"><title>root@vps: ~</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="859" height="304" rx="14" fill="#15110f" stroke="#43372e" stroke-width="1" /><path d="M1 44 H859" stroke="#43372e"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="430.0" y="27" class="m" font-size="13" fill="#cbbfa9" font-weight="500" text-anchor="middle">root@vps: ~</text><text x="22" y="70" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="70" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 add friend</text><text x="22" y="91" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==&gt; </text><text x="54.52" y="91" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователь friend добавлен.</text><text x="22" y="112" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2://friend:4b7d…@hy.example.com:443/?sni=hy.example.com#friend</text><text x="22" y="133" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="154" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="154" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 list</text><text x="22" y="175" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователи:</text><text x="22" y="196" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"> • admin</text><text x="22" y="217" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"> • friend</text><text x="22" y="238" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="259" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="259" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 del friend</text><text x="22" y="280" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==&gt; </text><text x="54.52" y="280" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователь friend удалён, его ссылка больше не работает.</text></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="368" viewBox="0 0 860 368" role="img" aria-label="root@vps: ~"><title>root@vps: ~</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="859" height="367" rx="14" fill="#15110f" stroke="#43372e" stroke-width="1" /><path d="M1 44 H859" stroke="#43372e"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="430.0" y="27" class="m" font-size="13" fill="#cbbfa9" font-weight="500" text-anchor="middle">root@vps: ~</text><text x="22" y="70" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="70" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">bash &lt;(curl -fsSL https://raw.githubusercontent.com/…/scripts/hysteria2.sh)</text><text x="22" y="91" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="112" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Установка Hysteria2</text><rect x="10" y="118" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="128.5" r="11" fill="#93e06f"/><text x="832" y="133.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">1</text><text x="22" y="133" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Домен (Enter – без домена): </text><text x="249.64000000000001" y="133" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">hy.example.com</text><rect x="10" y="139" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="149.5" r="11" fill="#93e06f"/><text x="832" y="154.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">1</text><text x="22" y="154" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Почта для Let's Encrypt: </text><text x="225.25000000000003" y="154" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">you@example.com</text><text x="22" y="175" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==&gt; </text><text x="54.52" y="175" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Скачиваю Hysteria 2.12.3 (amd64) с GitHub</text><text x="22" y="196" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==&gt; </text><text x="54.52" y="196" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Контрольная сумма совпала: 8c7a68a906998b74…</text><text x="22" y="217" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==&gt; </text><text x="54.52" y="217" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Настраиваю ufw: SSH 22/tcp, Hysteria 443/udp, сайт 80/tcp и 443/tcp</text><text x="22" y="238" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==&gt; </text><text x="54.52" y="238" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Запускаю Hysteria и получаю сертификат для hy.example.com</text><text x="22" y="259" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="280" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">Готово! Hysteria2 работает.</text><text x="22" y="301" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><rect x="10" y="307" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="317.5" r="11" fill="#93e06f"/><text x="832" y="322.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">2</text><text x="22" y="322" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2://admin:9c1e…@hy.example.com:443/?sni=hy.example.com#admin</text><text x="22" y="343" class="m" font-size="13.5" fill="#cbbfa9" xml:space="preserve">[ здесь QR-код – отсканируйте его телефоном ]</text></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

-77
View File
@@ -1,77 +0,0 @@
# 🚀 Hysteria2
[← На главную](../README.md)
[Hysteria2](https://github.com/HyNetworks/hysteria) работает поверх UDP и
хорошо держит скорость на плохих каналах – отличная пара к REALITY. Одна
команда ставит сервер, сертификат и сайт-заглушку и выдаёт готовую ссылку.
**Что делает [скрипт](../scripts/hysteria2.sh):**
- ставит официальный Hysteria2 проверенной версии и сверяет контрольную сумму;
- с доменом – получает сертификат Let's Encrypt, без домена – делает свой
и закрепляет его отпечаток прямо в ссылке;
- поднимает сайт-заглушку: при заходе на ваш домен откроется страница
«RETRYING CONNECTION»;
- создаёт пользователя со случайным паролем и открывает нужные порты в ufw;
- выдаёт ссылку `hy2://` и QR-код;
- ставит команду `hy2`, чтобы добавлять и удалять дополнительных пользователей без правки конфигов.
## Что понадобится
- VPS с Ubuntu 22.04/24.04 или Debian 12/13, доступ root по SSH
- По желанию – домен, A-запись которого указывает на IP сервера
## Установка
Подключитесь к серверу по SSH и выполните:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/hysteria2.sh)
```
![Установка Hysteria2](assets/script-hysteria2.svg)
1. Скрипт спросит **домен и почту**. Нет домена – просто нажмите Enter.
2. В конце он покажет **ссылку** `hy2://` и QR-код.
> [!TIP]
> Без домена всё тоже работает. Домен даёт настоящий сертификат и
> сайт-заглушку, так что сервер снаружи выглядит обычным сайтом.
## Подключение
Ссылку `hy2://` поддерживают:
| Устройство | Приложение |
|---|---|
| iPhone, iPad | Streisand, Hiddify, Happ |
| Android | Hiddify, NekoBox |
| Windows, macOS, Linux | Hiddify, v2rayN |
## Дополнительные пользователи и управление
![Команды hy2](assets/script-hy2-add.svg)
| Команда | Что делает |
|---|---|
| `hy2 add имя` | добавить пользователя и показать его ссылку |
| `hy2 del имя` | удалить – его ссылка сразу перестанет работать |
| `hy2 list` | список пользователей |
| `hy2 link имя` | ссылка и QR-код ещё раз |
| `hy2 status` | версия, адрес и состояние |
| `hy2 restart` | перезапустить, например после исправления DNS |
| `hy2 update` | обновить до новой проверенной версии |
| `hy2 uninstall` | удалить всё |
## Если что-то пошло не так
- **«Не удалось получить сертификат»** – A-запись домена ещё не обновилась
или порт 80 закрыт у хостера. Исправьте и выполните `hy2 restart`.
- **Клиент не подключается** – проверьте, что у хостера открыт UDP-порт 443
(у некоторых хостеров есть свой файрвол в личном кабинете).
- **Порт 443 занят** – поставьте на другой: добавьте к команде установки `--port 8443`.
---
[← На главную](../README.md) · [Дальше: 📶 XKeen на Keenetic →](xkeen-keenetic.md)
+604
View File
@@ -0,0 +1,604 @@
#!/usr/bin/env bash
# 3X-UI_autoVPS (форк hacker07) — установка сервера одной командой из нашего канала.
#
# Что делает (по шагам):
# 1) проверяет сервер и наличие curl, ssh-keygen, openssl;
# 2) берёт из нашего Gitea релиз своей же версии, проверяет ПОДПИСЬ SHA256SUMS и SHA256 установщика;
# 3) запускает установщик автора (scripts/3x-ui.sh) — панель 3X-UI, протоколы, сайт-заглушка,
# маскировка, сертификат, подписка, ufw. Флаги установщика можно передавать как есть,
# а можно ничего не передавать — тогда он задаст вопросы сам (в том числе про домен);
# 4) спрашивает, какие протоколы ставить: невыбранные не создаются вообще — ни подключения в панели,
# ни записей в nginx, ни ссылок в подписке (за это отвечает флаг автора --protocols);
# 5) ставит сайт-заглушку: случайный правдоподобный ИТ-сайт из нашего набора (10 штук;
# --site N — конкретный, --no-site — не трогать авторскую страницу);
# 6) спрашивает и делает то, чего у автора нет: закрыть ответ на ping, включить обновления
# безопасности, перевести SSH на другой порт, сменить пароль root (поведение из StressOzz/vps-setup,
# но безопаснее);
# 7) в конце: kit fix (время и безопасные починки), kit check, kit local hardening;
# 8) последним — подписка, QR-код и ссылки по протоколам: то, что нужно сохранить.
#
# Запуск:
# # одна команда, дальше — вопросы:
# bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
# # или сразу с ответами, без вопросов:
# ... 3x-ui-local.sh --domain vpn.example.com
# ... 3x-ui-local.sh --sni dl.google.com --no-harden --ssh-port 2222
# # без вопросов, всё по умолчанию (для автоматизации):
# ... 3x-ui-local.sh --yes
#
# Флаги этого скрипта (остальные уходят установщику автора):
# --domain ДОМЕН, --sni САЙТ, --panel-on ip|domain, --host IP, --user ИМЯ, --port N, --restore ФАЙЛ
# --harden закрыть ping и включить автообновления, без вопросов
# --no-harden не трогать ping и автообновления
# --ssh-port N добавить порт SSH N (прежний порт продолжает работать)
# --yes ни о чём не спрашивать (ping закрою, автообновления включу, порт и пароль — нет)
# --no-time не запускать kit fix
# --no-check не запускать в конце kit check
# --clean убрать прошлую установку и поставить заново (SSH не трогает)
# --auto-sni не предлагать выбор сайта маскировки (иначе покажем меню по скану подсети)
# --verify-only только проверить релиз (подпись и SHA256) и выйти
# --dry-run показать план и ничего не делать
# -h | --help эта справка
#
# Наш файл: авторские релизы его не перезаписывают, а правки форка живут в scripts/kit-local.sh.
set -Eeuo pipefail
KIT_VERSION="1.2.1.6" # версия сборки форка; должна совпадать с VERSION релиза
GITEA="${KIT_FORK_BASE:-https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS}"
NS="3x-ui-kit-release"
SIG_ID="releases@3x-ui-kit"
# Публичный ключ форка. Меняется только вместе с ключом подписи (тогда обновляется и здесь, и в kit).
SIGNER_PUB='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)'
G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m'
[[ -t 1 ]] || { G=; Y=; R=; B=; D=; N=; }
say() { printf '%s\n' "${G}==>${N} $*"; }
warn() { printf '%s\n' "${Y}!${N} $*" >&2; }
die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
usage() {
cat <<'EOF'
3X-UI_autoVPS (форк hacker07) — установка сервера одной командой из нашего канала.
bash <(curl -fsSL <наш Gitea>/raw/branch/main/scripts/3x-ui-local.sh) [флаги…]
Без флагов скрипт спрашивает: какие протоколы ставить (невыбранные не создаются вообще),
маскировку и домен — это вопросы установщика автора; затем про ответ на ping, автообновления,
второй порт SSH и пароль root — это наши вопросы.
Флаги, которые скрипт передаёт установщику автора как есть:
--domain vpn.example.com свой домен для маскировки (A-запись на этот сервер, свободный 80)
--sni сайт.com маскировка под чужой сайт
--panel-on domain | ip где открывать панель и подписку
--host IP, --user имя, --port N, --protocols …, --restore файл
Флаги этого скрипта:
--harden закрыть ping и включить автообновления, без вопросов
--no-harden не трогать ping и автообновления
--ssh-port N перевести SSH на порт N (прежний закроется после проверки входа)
--yes ни о чём не спрашивать (для автоматизации)
--site N|random|off сайт-заглушка: конкретный из нашего набора, случайный (по умолчанию) или
не трогать авторскую страницу (то же: --no-site)
--no-sub не показывать в конце подписку, доступ к панели и QR-код
--clean снести прошлую установку и поставить заново (если прошлый запуск оборвался)
--auto-sni не предлагать выбор сайта маскировки (автоподбор автора)
--no-time не запускать kit fix (время и безопасные починки)
--no-check не запускать в конце kit check
--verify-only только проверить релиз (подпись и SHA256) и выйти
--dry-run показать план и ничего не делать
-h | --help эта справка
EOF
}
# ------------------------------------------------------------------ разбор флагов
INSTALLER_ARGS=(); harden=auto; ssh_port=""; fix_time=yes; do_check=yes; dry=no; verify_only=no; assume_yes=no
site=random # заглушка: случайный сайт из нашего набора (off — не трогать)
show_sub=yes # в самом конце показать подписку, QR-код и ссылки
clean=no # --clean: убрать прошлую установку перед новой
pick_sni=yes # показывать меню выбора сайта маскировки по результатам скана
while (($#)); do
case $1 in
--harden) harden=yes ;;
--no-harden) harden=no ;;
--ssh-port) ssh_port=${2:-}; shift || die "--ssh-port: нужен номер порта" ;;
--ssh-port=*) ssh_port=${1#*=} ;;
--yes | -y) assume_yes=yes ;;
--site) site=${2:-random}; shift || die "--site: нужен номер сайта (1..10), random или off" ;;
--site=*) site=${1#*=} ;;
--no-site) site=off ;;
--no-sub) show_sub=no ;;
--clean) clean=yes ;;
--auto-sni) pick_sni=no ;;
--no-time) fix_time=no ;;
--no-check) do_check=no ;;
--verify-only) verify_only=yes ;;
--dry-run) dry=yes ;;
-h | --help) usage; exit 0 ;;
*) INSTALLER_ARGS+=("$1") ;;
esac
shift
done
[[ -z $ssh_port ]] || { [[ $ssh_port =~ ^[0-9]{1,5}$ ]] && ((10#$ssh_port >= 1024 && 10#$ssh_port <= 65535)); } \
|| die "--ssh-port: нужно число 1024–65535, например 2222"
if [[ $site != off && $site != random ]]; then
# 10 — число сайтов в наборе (scripts/kit-local.sh, KL_SITE_TITLES); добавите сайт — поднимите границу
[[ $site =~ ^[0-9]{1,2}$ ]] && ((10#$site >= 1 && 10#$site <= 10)) \
|| die "--site: random, off или номер 1..10 (список: kit local site --list)"
fi
# Если --protocols задан флагом — не спрашиваем и не дублируем его.
proto_flag=""
for ((_i = 0; _i < ${#INSTALLER_ARGS[@]}; _i++)); do
[[ ${INSTALLER_ARGS[_i]} == --protocols ]] && proto_flag=${INSTALLER_ARGS[_i + 1]:-all}
done
pre() { printf ' %s\n' "$*"; }
ASK_SRC=${KIT_ASK_SRC:-/dev/tty} # откуда читать ответы (в тестах подменяется файлом)
interactive() { [[ $assume_yes == no && -r $ASK_SRC ]]; }
if [[ $dry == yes ]]; then
say "план установки (--dry-run: ничего не делаю)"
pre "версия сборки: $KIT_VERSION"
pre "откуда: $GITEA (тег v$KIT_VERSION), подпись проверяется ключом форка"
[[ $clean == yes ]] && pre "прошлая установка: уберу её перед установкой (--clean), SSH не трону"
if [[ $pick_sni == yes ]]; then pre "маскировка: просканирую подсеть и предложу выбрать сайт (--auto-sni — автоподбор)"
else pre "маскировка: подберу сайт сам, без вопросов (--auto-sni)"; fi
if ((${#INSTALLER_ARGS[@]})); then pre "установщик автора: bash scripts/3x-ui.sh ${INSTALLER_ARGS[*]}"; else pre "установщик автора: bash scripts/3x-ui.sh — спросит про маскировку и домен"; fi
if [[ $harden == auto ]]; then
if interactive; then pre "защита: спрошу про ответ на ping и автообновления"
else pre "защита: без терминала — закрыть ping и включить автообновления"; fi
elif [[ $harden == yes ]]; then pre "защита: закрыть ping и включить автообновления (--harden)"
else pre "защита: пропускаю (--no-harden)"; fi
if [[ -n $proto_flag ]]; then pre "протоколы: ${proto_flag} (задано флагом)"
elif interactive; then pre "протоколы: спрошу (Enter — REALITY + AmneziaWG-3.1, «все» — набор автора)"
else pre "протоколы: REALITY + AmneziaWG-3.1 (без терминала вопроса нет)"; fi
if [[ -n $ssh_port ]]; then pre "порт SSH: перевожу на $ssh_port (прежний — после проверки входа)"
elif interactive; then pre "порт SSH: спрошу (Enter — оставить текущий)"; fi
if interactive; then pre "пароль root: спрошу (по умолчанию — не менять)"; fi
case $site in
off) pre "заглушка: оставляю авторскую страницу (--no-site)" ;;
random) pre "заглушка: случайный ИТ-сайт из нашего набора (10 штук)" ;;
*) pre "заглушка: сайт №$site из нашего набора (список: kit local site --list)" ;;
esac
[[ $fix_time == yes ]] && pre "после: kit fix (время, безопасные починки)"
[[ $do_check == yes ]] && pre "проверка: kit check + kit local hardening"
[[ $show_sub == yes ]] && pre "в самом конце: доступ к панели 3X-UI, подписка, QR-код и ссылки (--no-sub — не надо)"
echo
say "запуск без --dry-run выполнит всё это на сервере"
exit 0
fi
# ------------------------------------------------------------------ релиз: скачать и проверить подпись
# Подпись проверяем ДО запуска чужого кода, поэтому здесь нужны только curl/ssh-keygen/openssl.
for c in curl ssh-keygen openssl; do command -v "$c" >/dev/null || die "нет $c — поставьте: apt-get update && apt-get install -y $c"; done
say "беру релиз v$KIT_VERSION и проверяю подпись"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
raw="$GITEA/raw/tag/v$KIT_VERSION"
for f in SHA256SUMS SHA256SUMS.sig; do
mkdir -p "$tmp/$(dirname "$f")"
curl -fsSL --retry 3 -m 60 -o "$tmp/$f" "$raw/$f" || die "не скачался $f из $raw (Gitea доступна с сервера?)"
done
printf '%s namespaces="%s" %s\n' "$SIG_ID" "$NS" "$SIGNER_PUB" >"$tmp/allowed_signers"
ssh-keygen -Y verify -f "$tmp/allowed_signers" -I "$SIG_ID" -n "$NS" -s "$tmp/SHA256SUMS.sig" <"$tmp/SHA256SUMS" >/dev/null 2>&1 \
|| die "подпись релиза v$KIT_VERSION не сошлась с ключом форка — это не наш релиз, ничего не запускаю"
sum=$(awk '{f = $2; sub(/^\*/, "", f)} f == "scripts/3x-ui.sh" {print $1}' "$tmp/SHA256SUMS")
[[ $(printf '%s' "$sum" | wc -c) -eq 64 ]] || die "в SHA256SUMS нет установщика — релиз собран неверно"
got=$(sha256sum "$tmp/scripts/3x-ui.sh" | awk '{print $1}')
[[ $got == "$sum" ]] || die "SHA256 установщика не совпал с подписанным — не запускаю"
# Качаем все остальные файлы релиза и сверяем каждый с подписанным списком. Раньше брали только
# установщик, поэтому kit-local.sh (в нём наша страница-заглушка) отсутствовал, и подготовка
# заглушки до установщика падала с «kl_site_html: command not found».
while read -r sum2 name2; do
case $sum2 in \#* | "") continue ;; esac
[[ $name2 == scripts/3x-ui.sh ]] && continue
mkdir -p "$tmp/$(dirname "$name2")"
curl -fsSL --retry 3 -m 60 -o "$tmp/$name2" "$raw/$name2" || die "не скачался $name2 из $raw (Gitea доступна с сервера?)"
got2=$(sha256sum "$tmp/$name2" | awk '{print $1}')
[[ $got2 == "$sum2" ]] || die "SHA256 не совпал у $name2 — не запускаю"
done <"$tmp/SHA256SUMS"
bash -n "$tmp/scripts/3x-ui.sh" || die "установщик не проходит проверку синтаксиса"
[[ -r $tmp/scripts/kit-local.sh ]] || die "в релизе нет scripts/kit-local.sh — сборка неполная"
printf ' ✓ подпись сошлась, SHA256 установщика совпал\n'
if [[ $verify_only == yes ]]; then
say "релиз v$KIT_VERSION проверен: подпись и SHA256 сошлись, установщик не запускался"
exit 0
fi
# ------------------------------------------------------------------ предполётные проверки
say "проверяю сервер"
[[ ${EUID:-0} -eq 0 ]] || die "нужен root: запустите от root (на чистом VPS обычно так и есть)"
[[ -r /etc/os-release ]] || die "не вижу /etc/os-release — это не Debian/Ubuntu?"
# shellcheck disable=SC1091
. /etc/os-release
case ${ID:-} in debian | ubuntu | raspbian) ;; *) warn "система ${PRETTY_NAME:-?}: поддерживаются Debian и Ubuntu" ;; esac
printf ' %s\n' "${PRETTY_NAME:-Linux}, ядро $(uname -r), памяти $(awk '/MemTotal/ {printf "%.1f ГБ", $2 / 1048576}' /proc/meminfo 2>/dev/null || echo '?')"
df -P / | awk 'NR==2 {gsub("%","",$5); if ($5 >= 95) exit 1}' || warn "диск занят почти полностью — установка может не пройти"
for p in 443 80; do ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$p\$" && warn "порт $p уже кем-то занят"; done
# ------------------------------------------------------------------ какие протоколы ставить
# Список, порядок и набор по умолчанию — как у установщика автора (scripts/3x-ui.sh:
# ALL_PROTOS / DEFAULT_PROTOS). Невыбранные протоколы не создаются вообще: под них не будет
# ни подключения в панели, ни записей в nginx, ни ссылок в подписке.
ALL_PROTOS=(reality reality2 hy2 xhttp ws trojan vmess ss tuic wg awg awg3 mtproto)
PROTO_HINT=(
"REALITY основной: VLESS + Reality, порт 443"
"REALITY-2 второй REALITY на случайном высоком порту"
"Hysteria2 быстрее на плохой сети (UDP 443)"
"XHTTP VLESS + Reality под другим именем маскировки"
"VLESS-WS VLESS через WebSocket"
"Trojan-gRPC Trojan через gRPC"
"VMess-WS VMess через WebSocket"
"Shadowsocks Shadowsocks 2022"
"TUIC быстрый UDP-протокол"
"WireGuard обычный WireGuard: легко распознаётся, обычно не ставят"
"AmneziaWG AmneziaWG: Mihomo, роутеры Keenetic"
"AmneziaWG-3.1 AmneziaWG с защитой заголовков: AmneziaVPN"
"MTProto прокси для Telegram"
)
# Наш набор по умолчанию: VLESS REALITY и AmneziaWG-3.1 — самый совместимый минимум.
# Набор автора (флаг --protocols all) доступен ответом «все».
AUTHOR_PROTOS=(reality hy2 xhttp ws trojan vmess ss tuic awg awg3 mtproto)
DEFAULT_PROTOS=(reality awg3)
proto_menu() {
local i n mark
say "какие протоколы поставить?" >&2
for i in "${!ALL_PROTOS[@]}"; do
n=$((i + 1)) mark=" "
[[ " ${DEFAULT_PROTOS[*]} " == *" ${ALL_PROTOS[i]} "* ]] && mark="*"
printf ' %s %2d. %s\n' "$mark" "$n" "${PROTO_HINT[i]}" >&2
done
printf ' %s\n' "${D}[* — по умолчанию: REALITY + AmneziaWG-3.1. «все» — набор автора (всё, кроме WireGuard).${N}" >&2
printf ' %s\n' "${D}[Невыбранные протоколы не создаются вообще: ни подключения, ни записей в nginx, ни ссылок в подписке.]${N}" >&2
}
proto_parse() { # ответ → выбранные имена через запятую; код 1, если не разобрал
local ans=${1,,} tok i all=" ${ALL_PROTOS[*]} " picked=""
ans=${ans//[[:space:]]/,} # «1 3 11» и «1,3,11» — одно и то же
[[ -n $ans ]] || { printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"; return 0; }
case $ans in
all | все | всё) printf '%s' "$(IFS=,; echo "${AUTHOR_PROTOS[*]}")"; return 0 ;;
minimal | минимум) printf '%s' "reality"; return 0 ;;
esac
local IFS=','
for tok in $ans; do
[[ -n $tok ]] || continue # двойные запятые и пробелы — не ошибка
if [[ $tok =~ ^[0-9]{1,2}$ ]]; then
i=$((10#$tok - 1))
((i >= 0 && i < ${#ALL_PROTOS[@]})) || return 1
tok=${ALL_PROTOS[i]}
fi
[[ $all == *" $tok "* ]] || return 1 # только имена из списка автора
[[ ,$picked, == *",$tok,"* ]] || picked+="${picked:+,}$tok"
done
[[ -n $picked ]] || return 1
printf '%s' "$picked"
}
ask_protocols() { # печатает итоговый список через запятую
local i ans picked
interactive || { printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"; return 0; }
proto_menu
for i in 1 2 3; do
printf '\n%sНомера через пробел (например 1 12) или «все»; Enter — REALITY + AmneziaWG-3.1: %s' "$B" "$N" >&2
ans=""
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
if picked=$(proto_parse "$ans"); then printf '%s' "$picked"; return 0; fi
warn "не понял «$ans» — нужны номера из списка через пробел или «все»"
done
warn "оставляю набор по умолчанию"
printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"
}
# ------------------------------------------------------------------ снос прошлой установки (--clean)
# Нужен, когда прошлая установка оборвалась: авторский установщик в таком случае отказывается работать
# («3X-UI уже установлена этим скриптом»). Убираем то, что создаёт установка, и ставим заново.
# SSH не трогаем: ни порт, ни конфиги — иначе можно потерять доступ к серверу.
clean_previous() {
say "убираю прошлую установку (--clean)"
local s p
for s in x-ui kit-sub kit-update.timer kit-update.service kit-limit.timer kit-limit.service \
$(systemctl list-unit-files --no-legend 2>/dev/null | awk '{print $1}' | grep -E '^(x-ui|kit-|kit\.)' | grep -E '\.(service|timer)$'); do
systemctl list-unit-files 2>/dev/null | grep -q "^$s" || continue
systemctl disable --now "$s" >/dev/null 2>&1 || true
printf ' остановлено: %s\n' "$s"
done
for p in /usr/local/x-ui /etc/x-ui /usr/bin/x-ui /usr/bin/x-ui.new /etc/systemd/system/x-ui.service \
/usr/local/bin/kit /usr/local/lib/kit /usr/local/lib/kit-sub /etc/kit /etc/kit-sub \
/etc/systemd/system/kit-sub.service /etc/systemd/system/kit-update.service \
/etc/systemd/system/kit-update.timer /etc/systemd/system/kit-limit.service \
/etc/systemd/system/kit-limit.timer \
/etc/cron.d/kit-nginx-reload /etc/cron.d/kit-xui-menu /etc/cron.d/kit-sub-cert \
/etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf /var/www/kit \
/root/3x-ui.txt /root/cert /var/log/kit-install-check.log \
/root/kit-local-backup-* /root/kit-backup-* /root/x-ui-before-sni-* /root/x-ui-before-update-*; do
[[ -e $p ]] || continue
rm -rf $p && printf ' удалено: %s\n' "$p"
done
if [[ -f /etc/nginx/nginx.conf ]] && grep -q 'kit-stream\.conf' /etc/nginx/nginx.conf; then
sed -i '/kit-stream\.conf/d' /etc/nginx/nginx.conf
printf ' из nginx.conf убрана наша строка включения\n'
fi
if id -u kit-sub >/dev/null 2>&1; then
pkill -u kit-sub >/dev/null 2>&1 || true
userdel -r kit-sub >/dev/null 2>&1 || true
printf ' удалён пользователь kit-sub\n'
fi
systemctl daemon-reload
if [[ -f /root/3x-ui.txt && -x /usr/local/x-ui/x-ui ]]; then
warn "часть файлов прошлой установки осталась — проверьте: ls -la /usr/local/x-ui /root/3x-ui.txt"
else
printf ' готово: место под установку свободно\n'
fi
}
installer_failed() {
if grep -q "likely firewall problem" /var/log/kit-domain-cert.log 2>/dev/null \
|| grep -q "Timeout during connect" /var/log/3x-ui-install.log 2>/dev/null; then
printf '\n%s!%s %s\n' "$Y" "$N" \
"Let's Encrypt не смог достучаться до порта 80 (в логе acme «Timeout during connect»)." >&2
printf ' Проверьте по порядку: открыт ли 80 в ufw (скрипт делает это сам) и не режет ли его хостер.\n' >&2
printf ' Либо ставьте без своего домена: в меню маскировки выберите «общедоступный сайт» —\n' >&2
printf ' сертификат для домена тогда не нужен, панель останется на самоподписанном.\n' >&2
fi
if [[ -f /root/3x-ui.txt && -x /usr/local/x-ui/x-ui ]]; then
printf '\n%s!%s %s\n' "$Y" "$N" \
"На сервере осталась прошлая установка (установщик так и пишет). Запустите ещё раз с флагом --clean — он уберёт старое и поставит заново." >&2
fi
die "установщик остановился с ошибкой — смотрите его вывод выше"
}
# ------------------------------------------------------------------ установка
if [[ -z $proto_flag ]]; then
protos=$(ask_protocols)
INSTALLER_ARGS+=(--protocols "$protos")
else
protos=$proto_flag
fi
say "протоколы: $protos — под остальные не создаётся ничего"
# ------------------------------------------------------------------ вопросы (всё спросим ДО установки)
# После установщика на экране появляются доступы к панели: там вопросы уже неуместны. Поэтому
# спрашиваем всё заранее, а действия выполняем после установки (см. «наши дополнения» ниже).
ask_yes() { # "вопрос" y|n — по умолчанию
local q=$1 def=$2 ans=""
interactive || { [[ $def == y ]]; return; }
printf '\n%s [%s]: ' "$q" "$([[ $def == y ]] && echo 'Y/n' || echo 'y/N'])"
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
ans=${ans,,}; ans=${ans//[[:space:]]/}
[[ -z $ans && $def == y ]] && return 0
[[ $ans =~ ^(y|yes|д|да)$ ]]
}
# порт SSH: по флагу или по вопросу. Смена делается в два шага: сначала ДОБАВИТЬ новый порт
# (прежний продолжает работать), потом закрыть прежние — иначе одна опечатка оставляет сервер без доступа.
ssh_ports_now() {
local p
p=$(kit local ssh --status 2>/dev/null | sed -n 's/^порты SSH сейчас: //p')
[[ -n $p ]] || p=$(sshd -T 2>/dev/null | awk '$1 == "port" {print $2}' | paste -sd, -)
[[ -n $p ]] || p=$(grep -rhiE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config /etc/ssh/sshd_config.d 2>/dev/null | awk '{print $2}' | paste -sd, -)
printf '%s' "${p:-22}"
}
ask_ssh_port() { # пусто — оставить как есть; иначе номер нового порта
local cur ans try
cur=$(ssh_ports_now)
interactive || return 0
for try in 1 2 3; do
printf '\n%sСменить порт SSH? Сейчас %s. Введите новый номер, Enter — оставить как есть: %s' "$B" "$cur" "$N" >&2
ans=""
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
ans=${ans//[[:space:]]/}
[[ -n $ans ]] || { say "порт SSH оставляю как есть ($cur)" >&2; return 0; }
if ! [[ $ans =~ ^[0-9]{1,5}$ ]] || ((10#$ans < 1024 || 10#$ans > 65535)); then
warn "«$ans» не похоже на порт (нужно число 1024–65535)"
continue
fi
if [[ ,$cur, == *",$ans,"* ]]; then
say "порт $ans уже слушает SSH — менять нечего" >&2; return 0
fi
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$ans\$"; then
warn "порт $ans/tcp занят другой программой"
continue
fi
printf '%s' "$ans"; return 0
done
warn "оставляю прежний порт ($cur)"
}
want_ping=$harden; want_update=$harden
[[ $want_ping == auto ]] && want_ping=yes
[[ $want_update == auto ]] && want_update=yes
if [[ $harden == auto ]] && interactive; then
if ask_yes "Закрыть ответ сервера на ping? Снаружи пинг перестанет отвечать (подписка и VPN не затрагиваются)." y; then want_ping=yes; else want_ping=no; fi
if ask_yes "Включить автообновления безопасности? Только security-обновления, без перезагрузки." y; then want_update=yes; else want_update=no; fi
fi
[[ -n $ssh_port ]] || ssh_port=$(ask_ssh_port)
ssh_ports_before=$(ssh_ports_now) # для подсказки в конце: какие порты закрывать
want_passwd=no
if interactive; then
if ask_yes "Сменить пароль root прямо сейчас? Новый пароль введёте в следующем окне." n; then want_passwd=yes; fi
fi
[[ $clean == yes ]] && clean_previous
# Порты для сертификата и маскировки: открываем правила ufw ДО установщика и говорим, где смотреть,
# если сертификат не выпустится (частая причина — свой файрвол у хостера, а не ufw).
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "^Status: active"; then
for _p in 80/tcp 443/tcp; do ufw allow "$_p" >/dev/null 2>&1 || true; done
say "порты 80 и 443 открыты в ufw (нужны для сертификата и работы маскировки)"
fi
printf ' %sесли у хостера свой файрвол — 80 и 443 должны быть разрешены и там.%s\n' "$D" "$N"
printf ' %sпризнак закрытого 80: сертификат не выпускается, в логе acme «Timeout during connect (likely firewall problem)»%s\n' "$D" "$N"
# Меню выбора сайта маскировки: сканируем подсеть один раз и предлагаем выбрать сайт отдельно для
# REALITY и (если выбраны) для XHTTP/MTProto. Отключается флагом --auto-sni (тогда автоподбор автора).
if [[ $pick_sni == yes ]]; then export KL_PICK_SNI=1; else export KL_PICK_SNI=0; fi
say "запускаю установщик автора (панель 3X-UI, протоколы, сайт-заглушка, подписка)"
if ((${#INSTALLER_ARGS[@]})); then printf ' флаги: %s\n' "${INSTALLER_ARGS[*]}"; else printf ' флагов нет: установщик задаст вопросы (маскировка, домен)\n'; fi
# stdin: если есть терминал — отдаём его установщику (там есть вопросы), иначе /dev/null,
# иначе установщик начнёт читать наш собственный скрипт вместо ответов.
if [[ -r /dev/tty ]]; then
bash "$tmp/scripts/3x-ui.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"} </dev/tty || installer_failed
else
bash "$tmp/scripts/3x-ui.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"} </dev/null || installer_failed
fi
command -v kit >/dev/null || die "после установки нет команды kit — что-то пошло не так, смотрите вывод выше"
# Ставим нашу заглушку СРАЗУ (а не поверх авторской): страницу кладём до установщика, а его собственную
# страницу из релиза убираем — иначе она перезапишет нашу на шаге сборки сайта.
stub_staged=no
if [[ $site != off ]]; then
stub_n=""
case $site in
random) stub_n=$(( RANDOM % 10 )) ;;
*) [[ $site =~ ^[0-9]+$ ]] && stub_n=$((10#$site - 1)) ;;
esac
if [[ -n $stub_n ]]; then
install -d -m 755 /var/www/kit
if ( source "$tmp/scripts/kit-local.sh" 2>/dev/null; kl_site_html "$stub_n" ) >/var/www/kit/index.html 2>"$tmp/stub.err" \
&& [[ -s /var/www/kit/index.html ]]; then
stub_staged=yes
rm -f "$tmp/var/www/kit/index.html"
say "заглушка: наш сайт №$((stub_n + 1)) — ставится сразу, авторская страница не используется"
else
warn "не смог подготовить нашу заглушку — поставлю после установки (kit local site). Причина: $(head -c 200 "$tmp/stub.err" 2>/dev/null | tr '\n' ' ')"
fi
fi
fi
# Запоминаем, какие протоколы выбраны: наши команды (kit local site и др.) смотрят сюда и не
# делают ничего для протоколов, которых в этой установке нет.
printf '%s\n' "${protos//,/ }" | tr ' ' '\n' | sed '/^$/d' > /etc/kit/kl-protos 2>/dev/null || true
# ------------------------------------------------------------------ наши дополнения (только действия)
# Ничего не спрашиваем: все ответы собраны до установщика. Здесь только выполняем.
# 443 нужен для маскировки и VPN, 80 — для выпуска сертификата: проверяем правила ufw ещё раз.
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "^Status: active"; then
for _p in 80/tcp 443/tcp; do ufw allow "$_p" >/dev/null 2>&1 || true; done
fi
# Сайт-заглушка: у автора это случайные «кофейни и пекарни», у нас — правдоподобный ИТ-сайт.
# Меняем не файл вручную, а командой форка: она же обновляет копию страницы в ответе Hysteria2,
# иначе 443/tcp и UDP-порт начнут отвечать разными страницами (это видно снаружи).
case ${stub_staged:-no} in
yes) say "заглушка уже стоит (поставлена при установке, Hysteria2 синхронизирован тем же файлом)" ;;
*) case $site in
off) say "заглушку не трогаю (--no-site): остаётся страница автора" ;;
random)
say "ставлю сайт-заглушку: случайный ИТ-сайт из нашего набора"
kit local site || warn "не удалось поставить заглушку — позже: kit local site"
;;
*)
say "ставлю сайт-заглушку №$site"
kit local site "$site" || warn "не удалось поставить заглушку — позже: kit local site $site"
;;
esac ;;
esac
if [[ $fix_time == yes ]]; then
say "kit fix: синхронизация времени и безопасные починки (авторская команда, ничего необратимого)"
kit fix || warn "kit fix завершился с замечаниями — посмотрите вывод"
fi
# Действия по ответам, которые были заданы ДО установщика (см. блок вопросов выше).
if [[ $want_ping == yes ]]; then
say "закрываю ответ на ping (правилом ufw)"
kit local ping off || warn "не удалось — повторите: kit local ping off"
fi
if [[ $want_update == yes ]]; then
say "включаю автообновления безопасности"
kit local upgrades on || warn "не удалось — повторите: kit local upgrades on"
fi
if [[ $want_passwd == yes ]]; then
say "меняю пароль root"
kit local passwd || warn "не получилось — повторите: kit local passwd"
fi
# порт SSH: новый порт добавляем, прежний остаётся рабочим до вашей проверки входа.
if [[ -n $ssh_port ]]; then
say "перевожу SSH на порт $ssh_port: новый добавляю, прежний пока остаётся рабочим"
kit local ssh "$ssh_port" || warn "порт не добавился — сделайте отдельно: kit local ssh $ssh_port"
fi
# ------------------------------------------------------------------ итог: коротко и по делу
if [[ $do_check == yes || $show_sub == yes ]]; then
# kit check печатает много строк: человеку нужны только замечания и одна строка «всё ли в порядке».
chk=$(kit check 2>&1 || true)
problems=$(printf '%s\n' "$chk" | grep -cE '^❌|^⚠|^!' || true)
[[ $problems == 0 ]] || printf '%s\n' "$chk" | grep -E '^❌|^⚠|^!' | sed 's/^/ /'
ports_open=""
command -v ufw >/dev/null 2>&1 && ports_open=$(ufw status 2>/dev/null | awk '$2 == "ALLOW" { printf "%s ", $1 }')
ip_show=$(hostname -I 2>/dev/null | awk '{ print $1 }')
os_show=$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}" )
cert_show="без сертификата: панель открывается через SSH-туннель"
if [[ -s /root/cert/ip/fullchain.pem ]]; then
if openssl x509 -in /root/cert/ip/fullchain.pem -noout -issuer 2>/dev/null | grep -q "Let's Encrypt"; then
cert_show="${G}Let's Encrypt на IP — доверенный, браузер не ругается${N}"
else
cert_show="${Y}самоподписанный — браузер предупредит${N}"
fi
fi
protos_show=$(tr '\n' ' ' </etc/kit/kl-protos 2>/dev/null || true)
cat <<EOF
${B}────────────── Установка завершена ──────────────${N}
сборка: 3X-UI_autoVPS $KIT_VERSION (канал: наш Gitea)
сервер: ${ip_show:-?}${os_show:+ · $os_show}
протоколы: ${protos_show:-—}
сертификат: $cert_show
проверка: $([[ $problems == 0 ]] && echo 'замечаний нет' || echo "замечаний: $problems (подробности: kit check)")
${B}───────────────────────────────────────────────────${N}
${B}Доступ к панели${N} (файл /root/3x-ui.txt, виден только root)
EOF
# Автор пишет эти строки без ведущих пробелов (RESULT=/root/3x-ui.txt), поэтому ищем их как есть.
if grep -aqE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt 2>/dev/null; then
grep -aE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt | sed 's/^[[:space:]]*/ /'
printf ' файл целиком (там же подписка и ссылки по протоколам): /root/3x-ui.txt\n'
else
warn "данных панели в /root/3x-ui.txt нет — смотрите вывод установщика выше"
fi
cat <<EOF
${B}Ссылки для подключения и QR-код${N} — в меню kit
kit → Пользователи → Показать ссылки из консоли: kit local links
EOF
if [[ -n $ports_open ]]; then
cat <<EOF
${B}Порты${N}
открыто: $ports_open
что нужно и что лишнее: kit local ports закрыть лишние: kit local ports --close-extra
EOF
fi
if [[ -n ${ssh_port:-} ]]; then
cat <<EOF
${Y}SSH переведён на порт $ssh_port.${N} Прежние (${ssh_ports_before:-22}) пока открыты намеренно:
1) проверьте вход во втором окне: ssh -p $ssh_port root@${ip_show:-<IP>}
2) закройте прежние порты: kit local ssh --close-old $ssh_port
EOF
fi
cat <<EOF
если что-то не так: kit check --deep, затем kit fix
EOF
fi
+1274 -237
View File
File diff suppressed because it is too large Load Diff
+16 -545
View File
@@ -1,549 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Hysteria2 одной командой – https://github.com/itsnotkubrick/3X-UI_KIT # Hysteria2 – https://github.com/itsnotkubrick/3X-UI_KIT
# #
# Установка: bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/hysteria2.sh) # Отдельный установщик Hysteria2 больше не поддерживается: Hysteria2 входит в основную
# Управление: hy2 help # установку 3X-UI_autoVPS (scripts/3x-ui.sh). Файл оставлен, чтобы старая команда `hy2 update`
# # получила из подписанного релиза понятное сообщение. Ничего не ставит, не удаляет и не
# Ставит официальный бинарник Hysteria2 (версия закреплена ниже, контрольная # останавливает: сервер Hysteria2, установленный раньше, продолжает работать как был.
# сумма проверяется), настраивает сертификат, сайт-заглушку, ufw и выдаёт # Код выхода 1 при любом запуске (и при `__update_binary`) – старый `hy2 update` на нём
# ссылку hy2:// с QR-кодом. # останавливается и не заменяет команду hy2.
set -Eeuo pipefail KIT_VERSION="1.2.1.6"
# На свежем VPS в фоне идут автообновления системы и держат замок dpkg: ждём его, а не падаем. cat >&2 <<'MSG'
apt-get() { command apt-get -o DPkg::Lock::Timeout=900 "$@"; } Отдельный установщик Hysteria2 больше не поддерживается.
Сервер Hysteria2 продолжает работать как раньше, но не обновляется этим скриптом.
HY_VERSION="2.12.3" Hysteria2 входит в основную установку 3X-UI_autoVPS (один из 11 протоколов).
HY_REPO="HyNetworks/hysteria" Если нужен только Hysteria2 без панели – официальный установщик:
# SHA256 бинарников этой версии, записанные в сам скрипт: проверка не зависит от файла bash <(curl -fsSL https://get.hy2.sh/)
# hashes.txt, который лежит там же, где бинарник (2026-09-30, hashes.txt релиза app/v2.12.3). MSG
declare -A HY_SHA256=( exit 1
[amd64]=8c7a68a906998b747a0db87586e364f995fbfddb95693ae6e2fdb68a6e920d3e
[arm64]=c8dc653c3ba0a28d29a26b8fa52d2086f27c0927afddce95c09965e7174e78b0
)
KIT_VERSION="1.1"
KIT_REPO_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT"
# Скрипт берём из тега релиза, а не из меняющейся ветки main.
SELF_URL="$KIT_REPO_RAW/v$KIT_VERSION/scripts/hysteria2.sh"
BIN=/usr/local/bin/hysteria
CLI=/usr/local/bin/hy2
CONF_DIR=/etc/hysteria
CONF=$CONF_DIR/config.yaml
STATE=$CONF_DIR/install.env # настройки установки
USERS=$CONF_DIR/users # строки «имя пароль»
MASQ_DIR=/var/www/masq
DATA_DIR=/var/lib/hysteria
UNIT=/etc/systemd/system/hysteria-server.service
SYSCTL=/etc/sysctl.d/99-hysteria.conf
if [[ -t 1 ]]; then
G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m'
else
G=; Y=; R=; B=; D=; N=
fi
say() { printf '%s\n' "${G}==>${N} $*"; }
warn() { printf '%s\n' "${Y}!${N} $*" >&2; }
die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
trap 'die "Ошибка в строке $LINENO. Если это установка – исправьте причину и запустите скрипт ещё раз."' ERR
need_root() { [[ $EUID -eq 0 ]] || die "Запустите от root: sudo -i, затем команду ещё раз."; }
# Прежнее имя файла настроек – переносим, чтобы старые установки не сломались.
if [[ -f $CONF_DIR/pinkman.env && ! -f $STATE && -w $CONF_DIR ]]; then
mv "$CONF_DIR/pinkman.env" "$STATE"
fi
# ---------- проверки ----------
check_os() {
[[ -r /etc/os-release ]] || die "Не удалось определить систему."
. /etc/os-release
case "${ID:-}:${VERSION_ID:-}" in
ubuntu:22.04|ubuntu:24.04|debian:12|debian:13) ;;
*) warn "Проверено на Ubuntu 22.04/24.04 и Debian 12/13, у вас ${PRETTY_NAME:-неизвестно}. Продолжаю." ;;
esac
command -v systemctl >/dev/null || die "Нужен systemd."
}
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo amd64 ;;
aarch64|arm64) echo arm64 ;;
*) die "Архитектура $(uname -m) не поддерживается." ;;
esac
}
port_busy() { # port proto(tcp|udp)
ss -H -ln"${2:0:1}" "sport = :$1" 2>/dev/null | grep -q .
}
public_ip() {
local ip
for u in https://api.ipify.org https://ifconfig.me/ip https://ipv4.icanhazip.com; do
ip=$(curl -4 -fsS -m 6 "$u" 2>/dev/null | tr -d '[:space:]') || true
[[ $ip =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && { echo "$ip"; return; }
done
ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}'
}
rand() { openssl rand -hex "${1:-16}"; }
# ---------- установка ----------
install_packages() {
say "Ставлю пакеты: curl, openssl, qrencode, ufw"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl openssl qrencode ca-certificates iproute2 ufw >/dev/null
}
install_binary() {
local arch tmp url expected actual
arch=$(detect_arch)
url="https://github.com/$HY_REPO/releases/download/app/v$HY_VERSION"
tmp=$(mktemp -d)
say "Скачиваю Hysteria $HY_VERSION ($arch) с GitHub"
curl -fsSL --retry 3 -o "$tmp/hysteria" "$url/hysteria-linux-$arch"
expected=${HY_SHA256[$arch]:-}
actual=$(sha256sum "$tmp/hysteria" | awk '{print $1}')
[[ -n $expected && $expected == "$actual" ]] || { rm -rf "$tmp"; die "Контрольная сумма не совпала – файл повреждён или подменён."; }
install -m 755 "$tmp/hysteria" "$BIN"
rm -rf "$tmp"
say "Контрольная сумма совпала: ${D}${actual:0:16}…${N}"
}
install_cli() {
# Скрипт копирует сам себя, чтобы работала команда hy2.
local src="${BASH_SOURCE[0]}"
if [[ -f $src && $src != /dev/fd/* && $src != /proc/* ]]; then
install -m 755 "$src" "$CLI"
else
curl -fsSL --retry 3 -o "$CLI" "$SELF_URL" && chmod 755 "$CLI"
fi
}
write_masq() {
mkdir -p "$MASQ_DIR"
[[ -f $MASQ_DIR/index.html ]] && return
cat >"$MASQ_DIR/index.html" <<'HTML'
<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Please wait</title><style>body{background:#080808;height:100vh;margin:0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:sans-serif}.dots{display:flex;gap:15px;margin-bottom:30px}.d{width:20px;height:20px;background:#fff;border-radius:50%;animation:b 1.4s infinite ease-in-out both}.d:nth-child(1){animation-delay:-.32s}.d:nth-child(2){animation-delay:-.16s}@keyframes b{0%,80%,100%{transform:scale(0);opacity:.2}40%{transform:scale(1);opacity:1}}.t{color:#555;font-size:14px;letter-spacing:2px;font-weight:600}</style></head><body><div class="dots"><div class="d"></div><div class="d"></div><div class="d"></div></div><div class="t">RETRYING CONNECTION</div></body></html>
HTML
}
make_self_signed() {
local sni=$1
openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout "$CONF_DIR/server.key" -out "$CONF_DIR/server.crt" \
-subj "/CN=$sni" -addext "subjectAltName=DNS:$sni" -days 3650 2>/dev/null
chown hysteria:hysteria "$CONF_DIR/server.key" "$CONF_DIR/server.crt"
chmod 600 "$CONF_DIR/server.key"
}
cert_pin() {
openssl x509 -in "$CONF_DIR/server.crt" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f'
}
render_config() {
local u p
. "$STATE"
{
echo "# Сгенерировано hy2 – правьте через команду hy2, ручные изменения перезапишутся."
echo "listen: :$PORT"
echo
if [[ -n ${DOMAIN:-} ]]; then
echo "acme:"
echo " type: http"
echo " domains:"
echo " - $DOMAIN"
echo " email: $EMAIL"
echo " dir: $DATA_DIR/acme"
else
echo "tls:"
echo " cert: $CONF_DIR/server.crt"
echo " key: $CONF_DIR/server.key"
fi
echo
echo "auth:"
echo " type: userpass"
echo " userpass:"
while read -r u p; do
[[ -n $u ]] && echo " \"$u\": \"$p\""
done <"$USERS"
echo
echo "masquerade:"
echo " type: file"
echo " file:"
echo " dir: $MASQ_DIR"
if [[ -n ${DOMAIN:-} ]]; then
echo " listenHTTP: :80"
echo " listenHTTPS: :$PORT"
echo " forceHTTPS: true"
fi
} >"$CONF.new"
chown root:hysteria "$CONF.new"
chmod 640 "$CONF.new"
mv "$CONF.new" "$CONF"
}
write_unit() {
cat >"$UNIT" <<EOF
[Unit]
Description=Hysteria2 Server (3X-UI_KIT)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=600
StartLimitBurst=5
[Service]
Type=simple
ExecStart=$BIN server --config $CONF
WorkingDirectory=$DATA_DIR
User=hysteria
Group=hysteria
Environment=HYSTERIA_LOG_LEVEL=info
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
NoNewPrivileges=true
Restart=on-failure
RestartSec=30
[Install]
WantedBy=multi-user.target
EOF
}
tune_sysctl() {
# Буферы UDP побольше – рекомендация разработчиков Hysteria для скорости.
printf 'net.core.rmem_max=16777216\nnet.core.wmem_max=16777216\n' >"$SYSCTL"
sysctl -q -p "$SYSCTL" 2>/dev/null || warn "Не удалось применить sysctl (так бывает в контейнерах) – на работу не влияет."
}
setup_ufw() {
. "$STATE"
[[ ${UFW:-yes} == yes ]] || return 0
local ssh_port
ssh_port=$(ss -H -ltnp 2>/dev/null | awk '/sshd/ {sub(/.*:/,"",$4); print $4; exit}')
ssh_port=${ssh_port:-22}
say "Настраиваю ufw: SSH $ssh_port/tcp, Hysteria $PORT/udp${DOMAIN:+, сайт 80/tcp и $PORT/tcp}"
ufw allow "$ssh_port/tcp" >/dev/null
ufw allow "$PORT/udp" >/dev/null
if [[ -n ${DOMAIN:-} ]]; then
ufw allow 80/tcp >/dev/null
ufw allow "$PORT/tcp" >/dev/null
fi
ufw --force enable >/dev/null || warn "ufw не включился (так бывает в контейнерах) – откройте порты у хостера вручную."
}
# Ждём строку «server up and running» в логе с момента запуска: с доменом
# Hysteria сначала получает сертификат, это занимает до минуты.
start_and_wait() {
local since i log
since=$(date '+%Y-%m-%d %H:%M:%S')
systemctl restart hysteria-server
for i in $(seq 1 90); do
log=$(journalctl -u hysteria-server --since "$since" --no-pager -o cat 2>/dev/null || true)
grep -q 'server up and running' <<<"$log" && return 0
if grep -q 'FATAL' <<<"$log"; then
systemctl stop hysteria-server || true
echo >&2
grep 'FATAL' <<<"$log" | tail -1 | cut -c1-400 >&2
echo >&2
if grep -qi 'acme\|certificate' <<<"$log"; then
die "Не удалось получить сертификат. Проверьте, что A-запись домена указывает на этот сервер, а порт 80/tcp открыт у хостера. Потом: hy2 restart"
fi
die "Hysteria не запустилась – причина выше. После исправления: hy2 restart"
fi
sleep 1
done
die "Hysteria не ответила за 90 секунд. Лог: journalctl -u hysteria-server -n 50"
}
cmd_install() {
need_root
check_os
[[ -f $STATE ]] && die "Hysteria уже установлена этим скриптом. Команды управления: hy2 help"
local DOMAIN="" EMAIL="" PORT=443 USERNAME="admin" HOST="" UFW=yes SNI="" yes=no
while [[ $# -gt 0 ]]; do
case $1 in
--domain) DOMAIN=$2; shift 2 ;;
--email) EMAIL=$2; shift 2 ;;
--port) PORT=$2; shift 2 ;;
--user) USERNAME=$2; shift 2 ;;
--host) HOST=$2; shift 2 ;;
--sni) SNI=$2; shift 2 ;;
--no-ufw) UFW=no; shift ;;
-y|--yes) yes=yes; shift ;;
*) die "Неизвестный параметр: $1 (см. hy2 help)" ;;
esac
done
if [[ $yes == no && -t 0 && -z $DOMAIN ]]; then
echo
echo "${B}Установка Hysteria2${N}"
echo "С доменом сертификат выпустит Let's Encrypt, а по адресу домена откроется сайт-заглушка."
echo "Без домена будет самоподписанный сертификат – это тоже работает."
read -rp "Домен (Enter – без домена): " DOMAIN
if [[ -n $DOMAIN ]]; then read -rp "Почта для Let's Encrypt: " EMAIL; fi
fi
# Значения попадают в install.env и конфиг Hysteria – пропускаем только допустимые символы.
local re_host='^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$'
if [[ -n $DOMAIN ]]; then
[[ $DOMAIN =~ $re_host ]] || die "Похоже, это не домен: $DOMAIN"
[[ $EMAIL =~ ^[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,63}$ ]] || die "Для Let's Encrypt нужна почта: --email you@example.com"
fi
[[ -z $SNI || $SNI =~ $re_host ]] || die "--sni: нужно имя сайта, например www.bing.com"
[[ -z $HOST || $HOST =~ $re_host || $HOST =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || die "--host: нужен IP или домен"
[[ $PORT =~ ^[0-9]+$ ]] && ((PORT > 0 && PORT < 65536)) || die "Неверный порт: $PORT"
[[ $USERNAME =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "Имя пользователя: латиница, цифры, _ . - (до 32 символов)."
port_busy "$PORT" udp && die "UDP-порт $PORT уже занят. Выберите другой: --port 8443"
if [[ -n $DOMAIN ]]; then
port_busy 80 tcp && die "TCP-порт 80 занят – он нужен Let's Encrypt для проверки домена."
port_busy "$PORT" tcp && die "TCP-порт $PORT занят – на нём будет сайт-заглушка."
fi
install_packages
HOST=${HOST:-${DOMAIN:-$(public_ip)}}
[[ -n $HOST ]] || die "Не удалось узнать внешний IP. Укажите его: --host 1.2.3.4"
if [[ -n $DOMAIN ]]; then
local resolved my_ip
resolved=$(getent ahostsv4 "$DOMAIN" 2>/dev/null | awk 'NR==1{print $1}') || true
[[ -n $resolved ]] || die "Домен $DOMAIN не найден в DNS. Создайте A-запись на IP сервера и подождите пару минут."
my_ip=$(public_ip)
[[ $resolved == "$my_ip" ]] || warn "Домен $DOMAIN указывает на $resolved, а IP сервера $my_ip. Если сертификат не выпустится – проверьте A-запись."
fi
SNI=${SNI:-${DOMAIN:-www.bing.com}}
id hysteria &>/dev/null || useradd --system --no-create-home --home-dir "$DATA_DIR" --shell /usr/sbin/nologin hysteria
install -d -o hysteria -g hysteria -m 750 "$DATA_DIR"
install -d -o root -g hysteria -m 750 "$CONF_DIR"
install_binary
write_masq
[[ -z $DOMAIN ]] && make_self_signed "$SNI"
# printf %q: файл потом читается через source, значения не должны превратиться в команды.
{
printf 'DOMAIN=%q\n' "$DOMAIN"
printf 'EMAIL=%q\n' "$EMAIL"
printf 'PORT=%q\n' "$PORT"
printf 'HOST=%q\n' "$HOST"
printf 'SNI=%q\n' "$SNI"
printf 'UFW=%q\n' "$UFW"
} >"$STATE"
chmod 600 "$STATE"
printf '%s %s\n' "$USERNAME" "$(rand 16)" >"$USERS"
chmod 600 "$USERS"
render_config
write_unit
tune_sysctl
install_cli
setup_ufw
say "Запускаю Hysteria${DOMAIN:+ и получаю сертификат для $DOMAIN}"
systemctl daemon-reload
systemctl enable hysteria-server >/dev/null 2>&1
start_and_wait
echo
echo "${G}${B}Готово! Hysteria2 работает.${N}"
show_link "$USERNAME"
echo
echo "Добавить пользователя: ${B}hy2 add имя${N} · все команды: ${B}hy2 help${N}"
}
# ---------- управление ----------
require_installed() { need_root; [[ -f $STATE ]] || die "Hysteria не установлена этим скриптом."; }
user_link() {
. "$STATE"
local u=$1 p q
p=$(awk -v u="$u" '$1==u {print $2}' "$USERS")
[[ -n $p ]] || die "Нет пользователя $u"
q="sni=$SNI"
if [[ -z ${DOMAIN:-} ]]; then q="$q&insecure=1&pinSHA256=$(cert_pin)"; fi
echo "hy2://$u:$p@$HOST:$PORT/?$q#$u@$HOST"
}
show_link() {
local link
link=$(user_link "$1")
echo
echo "Ссылка для ${B}$1${N} – вставьте в Hiddify, v2rayN, Streisand или NekoBox:"
echo
echo "$link"
echo
command -v qrencode >/dev/null && qrencode -t ANSIUTF8 -m 1 "$link"
}
reload_service() {
render_config
start_and_wait
}
cmd_restart() {
require_installed
systemctl reset-failed hysteria-server 2>/dev/null || true
reload_service
say "Hysteria перезапущена."
}
cmd_add() {
require_installed
local u=${1:-}
[[ $u =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "Использование: hy2 add имя (латиница, цифры, _ . -)"
awk -v u="$u" '$1==u {f=1} END {exit !f}' "$USERS" && die "Пользователь $u уже есть. Ссылка: hy2 link $u"
printf '%s %s\n' "$u" "$(rand 16)" >>"$USERS"
reload_service
say "Пользователь $u добавлен."
show_link "$u"
}
cmd_del() {
require_installed
local u=${1:-}
[[ -n $u ]] || die "Использование: hy2 del имя"
awk -v u="$u" '$1==u {f=1} END {exit !f}' "$USERS" || die "Нет пользователя $u"
[[ $(grep -c . "$USERS") -gt 1 ]] || die "Это последний пользователь – сначала добавьте другого."
awk -v u="$u" '$1!=u' "$USERS" >"$USERS.new" && mv "$USERS.new" "$USERS" && chmod 600 "$USERS"
reload_service
say "Пользователь $u удалён, его ссылка больше не работает."
}
cmd_list() {
require_installed
echo "Пользователи:"
awk '{print " • " $1}' "$USERS"
}
cmd_link() {
require_installed
local u=${1:-$(awk 'NR==1{print $1}' "$USERS")}
show_link "$u"
}
cmd_status() {
require_installed
. "$STATE"
echo "Версия: $("$BIN" version 2>/dev/null | awk '/^Version:/ {print $2}')"
echo "Адрес: $HOST:$PORT/udp"
if [[ -n ${DOMAIN:-} ]]; then echo "Сертификат: Let's Encrypt для $DOMAIN"; else echo "Сертификат: самоподписанный ($SNI)"; fi
echo "Пользователей: $(grep -c . "$USERS")"
systemctl --no-pager --lines=5 status hysteria-server || true
}
# Открытый ключ, которым автор подписывает релизы (тот же, что в kit). Обновление ставится,
# только если SHA256SUMS релиза подписан этим ключом и скрипт совпал с подписанным хешем.
KIT_SIGNERS=(
# SHA256:VDuGgJ8dOeCNXB4nBZf3+kRlWthw3+vh8rfMxzMSRIM (itsnotkubrick, 2026-09-30)
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrTGCDhhnm8XO1ekpPJuSWRVCJiFiupEspfQxcbEBmz 3X-UI KIT releases"
)
KIT_SIG_NS="3x-ui-kit-release"
KIT_SIG_ID="releases@3x-ui-kit"
cmd_update() {
require_installed
local tmp latest raw sum k
latest=$(curl -fsS -m 8 "$KIT_REPO_RAW/main/VERSION" | tr -d '[:space:]') || true
[[ $latest =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "Не удалось узнать последнюю версию: GitHub недоступен с сервера."
[[ $latest != "$KIT_VERSION" && $(printf '%s\n%s\n' "$latest" "$KIT_VERSION" | sort -V | tail -1) == "$KIT_VERSION" ]] \
&& die "На GitHub версия $latest старше вашей $KIT_VERSION – ничего не делаю."
((${#KIT_SIGNERS[@]})) || die "В этой сборке нет ключа подписи – проверить обновление нечем."
command -v ssh-keygen >/dev/null || apt-get install -y -qq openssh-client >/dev/null
tmp=$(mktemp -d)
# shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет
trap "rm -rf -- '$tmp'" EXIT
raw="$KIT_REPO_RAW/v$latest"
say "Скачиваю и проверяю подпись версии $latest"
curl -fsSL --retry 3 -o "$tmp/SHA256SUMS" "$raw/SHA256SUMS" && curl -fsSL --retry 3 -o "$tmp/SHA256SUMS.sig" "$raw/SHA256SUMS.sig" \
|| die "Не удалось скачать подпись релиза – ничего не менял."
for k in "${KIT_SIGNERS[@]}"; do printf '%s namespaces="%s" %s\n' "$KIT_SIG_ID" "$KIT_SIG_NS" "$k" >>"$tmp/allowed_signers"; done
ssh-keygen -Y verify -f "$tmp/allowed_signers" -I "$KIT_SIG_ID" -n "$KIT_SIG_NS" -s "$tmp/SHA256SUMS.sig" <"$tmp/SHA256SUMS" >/dev/null 2>&1 \
|| die "Подпись релиза $latest не сошлась с ключом автора – это не наш релиз. Ничего не менял."
grep -qx "# 3X-UI KIT $latest" "$tmp/SHA256SUMS" || die "Подписанный релиз не той версии – ничего не менял."
sum=$(awk '$2 == "scripts/hysteria2.sh" {print $1}' "$tmp/SHA256SUMS")
curl -fsSL --retry 3 -o "$tmp/hysteria2.sh" "$raw/scripts/hysteria2.sh" || die "Не удалось скачать скрипт – ничего не менял."
[[ $sum =~ ^[0-9a-f]{64}$ && $(sha256sum "$tmp/hysteria2.sh" | awk '{print $1}') == "$sum" ]] \
|| die "Скрипт не совпал с подписанным SHA256 – ничего не менял."
bash -n "$tmp/hysteria2.sh" || die "Скачанный скрипт повреждён – ничего не менял."
bash "$tmp/hysteria2.sh" __update_binary
install -m 755 "$tmp/hysteria2.sh" "$CLI"
}
cmd_update_binary() {
require_installed
local cur
cur=$("$BIN" version 2>/dev/null | awk '/^Version:/ {print $2}')
if [[ $cur == "v$HY_VERSION" ]]; then say "Уже стоит последняя проверенная версия $HY_VERSION."; return; fi
install_binary
reload_service
say "Hysteria обновлена: $cur → v$HY_VERSION"
}
cmd_uninstall() {
require_installed
local ans=""
if [[ ${1:-} != -y && -t 0 ]]; then
read -rp "Удалить Hysteria и всех пользователей? [y/N] " ans
[[ $ans =~ ^[yYдД]$ ]] || { echo "Отменено."; return; }
fi
. "$STATE"
systemctl disable --now hysteria-server >/dev/null 2>&1 || true
rm -f "$UNIT" "$BIN" "$SYSCTL"
systemctl daemon-reload
if [[ ${UFW:-yes} == yes ]] && command -v ufw >/dev/null; then
ufw delete allow "$PORT/udp" >/dev/null 2>&1 || true
if [[ -n ${DOMAIN:-} ]]; then
ufw delete allow 80/tcp >/dev/null 2>&1 || true
ufw delete allow "$PORT/tcp" >/dev/null 2>&1 || true
fi
fi
rm -rf "$CONF_DIR" "$DATA_DIR" "$MASQ_DIR"
userdel hysteria 2>/dev/null || true
rm -f "$CLI"
say "Hysteria удалена."
}
cmd_help() {
cat <<EOF
${B}hy2${N} – управление Hysteria2
hy2 add имя добавить пользователя и показать его ссылку
hy2 del имя удалить пользователя
hy2 list список пользователей
hy2 link [имя] ссылка и QR-код
hy2 status версия, адрес, состояние сервиса
hy2 restart перезапустить (например, после исправления DNS)
hy2 update обновить скрипт и Hysteria до проверенной версии
hy2 uninstall удалить всё
Параметры установки (для запуска без вопросов):
--domain example.com --email you@example.com сертификат Let's Encrypt
--port 443 UDP-порт (по умолчанию 443)
--user admin имя первого пользователя
--sni www.bing.com SNI для самоподписанного сертификата
--host 1.2.3.4 адрес в ссылке, если IP определился неверно
--no-ufw не трогать файрвол
-y не задавать вопросов
EOF
}
main() {
local cmd=${1:-}
case $cmd in
add|del|list|link|status|restart|update|uninstall|help) shift; "cmd_$cmd" "$@" ;;
__update_binary) cmd_update_binary ;;
install) shift; cmd_install "$@" ;;
-h|--help) cmd_help ;;
""|-*)
if [[ -f $STATE && $# -eq 0 ]]; then cmd_help; else cmd_install "$@"; fi ;;
*) die "Неизвестная команда: $cmd (см. hy2 help)" ;;
esac
}
main "$@"
+1114
View File
File diff suppressed because it is too large Load Diff
+392 -21
View File
@@ -14,11 +14,14 @@ https://github.com/itsnotkubrick/3X-UI_KIT
""" """
import base64 import base64
import http.client
import http.server import http.server
import ipaddress
import json import json
import os import os
import re import re
import socket import socket
import sys
import ssl import ssl
import threading import threading
import time import time
@@ -36,29 +39,63 @@ CLASH_UA = re.compile(r"clash|mihomo|flclash|stash|nyanpasu|meta", re.I)
# AmneziaWG добавляем только приложениям на ядре Mihomo. Karing, Hiddify и другие на sing-box # AmneziaWG добавляем только приложениям на ядре Mihomo. Karing, Hiddify и другие на sing-box
# тоже могут просить формат Clash (Karing так и делает), но AmneziaWG не умеют. # тоже могут просить формат Clash (Karing так и делает), но AmneziaWG не умеют.
NO_AWG_UA = re.compile(r"karing|hiddify|nekobox|sing-?box|husi|stash|shadowrocket|v2box|streisand|happ|loon|surge|quantumult", re.I) NO_AWG_UA = re.compile(r"karing|hiddify|nekobox|sing-?box|husi|stash|shadowrocket|v2box|streisand|happ|loon|surge|quantumult", re.I)
# Hiddify 4.x в строке приложения называет себя и Clash, поэтому панель отдаёт ему Clash-YAML, а он обрезает в нём пробелы и
# выдаёт «Unable to determine config format». Панели такой клиент представляем без слов про Clash: она отдаст обычный
# список ссылок (base64), который Hiddify читает без проблем.
HIDDIFY_UA = re.compile(r"hiddify", re.I)
def upstream_ua(ua):
return CLASH_UA.sub("", ua).strip() if HIDDIFY_UA.search(ua) else ua
SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$") SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$")
# Страница подписки для браузера (React от 3X-UI) подгружает скрипты и стили из «<путь>/assets/…»: пропускаем только такие файлы.
ASSET = re.compile(r"^assets/[A-Za-z0-9_.-]{1,128}\.(js|css|woff2?|svg|png|ico|map)$")
# Заголовки Happ, которые 3X-UI отдаёт в подписке (маршрутизация, баннеры, настройки клиента).
HAPP_HEADERS = ("routing", "routing-enable", "announce", "providerid", "new-url", "fallback-url",
"hide-settings", "no-limit-enabled", "ping-type", "color-profile", "tun-mode", "tun-type",
"exclude-routes", "exclude-apns-enable", "per-app-proxy-mode", "per-app-proxy-list",
"notification-subs-expire", "sub-expire", "sub-expire-button-link", "sub-info-text",
"sub-info-color", "sub-info-button-text", "sub-info-button-link",
"subscription-autoconnect", "subscription-autoconnect-type", "subscription-always-hwid-enable")
PASS_HEADERS = ("content-type", "content-disposition", "profile-title", "profile-update-interval", PASS_HEADERS = ("content-type", "content-disposition", "profile-title", "profile-update-interval",
"profile-web-page-url", "subscription-userinfo", "support-url", "cache-control") "profile-web-page-url", "subscription-userinfo", "support-url", "cache-control") + HAPP_HEADERS
with open(CONFIG, encoding="utf-8") as f: with open(CONFIG, encoding="utf-8") as f:
CONF = json.load(f) CONF = json.load(f)
PATH = "/" + CONF["path"].strip("/") + "/" PATH = "/" + CONF["path"].strip("/") + "/"
# Раздельная маршрутизация (необязательно): /etc/kit-sub/rules.yaml – через VPN только перечисленное, остальное напрямую.
# Файл читается при каждом запросе подписки, перезапуск не нужен. Нет файла или он битый – подписка как обычно (всё через VPN).
RULES_FILE = os.environ.get("KIT_SUB_RULES") or "/etc/kit-sub/rules.yaml"
RULES_MAX_BYTES, RULES_MAX_COUNT = 65536, 300
RULE_RE = re.compile(r"^(DOMAIN|DOMAIN-SUFFIX|DOMAIN-KEYWORD|GEOSITE|IP-CIDR|IP-CIDR6|GEOIP),([A-Za-z0-9._:/!@+-]{1,120})$")
DOH_RE = re.compile(r"^https://[A-Za-z0-9.-]{1,100}(:\d{1,5})?/[A-Za-z0-9._/-]{0,60}$")
DOH_DEFAULT = ["https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"]
PRIVATE_NETS = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16"]
# Дополнительные пути, которые панель показывает на странице подписки (Clash и JSON): обрабатываются так же, как основной.
EXTRA = ["/" + x.strip("/") + "/" for x in CONF.get("extra_paths", []) if isinstance(x, str) and re.fullmatch(r"/?[A-Za-z0-9_-]+/?", x)]
# После kit net site прежнее имя сайта REALITY ещё неделю принимается сервером (льготный срок), а панель берёт имя для ссылки
# случайно из списка. Карта «прежнее имя → нынешнее» от kit: в подписку уходит только нынешнее. Нет файла – ничего не меняем.
GRACE_FILE = os.environ.get("KIT_SUB_GRACE") or "/etc/kit-sub/sni-grace.json"
SITE_NAME = re.compile(r"[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+")
LINK_SNI = re.compile(r"([?&]sni=)([^&#\s]*)")
def log(msg): def log(msg):
print(msg, flush=True) print(msg, flush=True)
def upstream(sub_id, ua, host, accept): def upstream(sub_id, ua, host, accept, prefix=None):
"""GET к подписке 3X-UI. Возвращает (код, заголовки, тело) или (None, {}, b"").""" """GET к подписке 3X-UI. Возвращает (код, заголовки, тело) или (None, {}, b"")."""
req = urllib.request.Request(CONF["upstream"].rstrip("/") + PATH + sub_id, headers={ req = urllib.request.Request(CONF["upstream"].rstrip("/") + (prefix or PATH) + sub_id, headers={
"User-Agent": ua, "Host": host, "Accept": accept or "*/*"}) "User-Agent": ua, "Host": host, "Accept": accept or "*/*"})
try: try:
with urllib.request.urlopen(req, timeout=15) as r: with urllib.request.urlopen(req, timeout=15) as r:
return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read() return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read()
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read() return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
except (urllib.error.URLError, OSError, socket.timeout) as e: except (urllib.error.URLError, http.client.HTTPException, OSError, socket.timeout) as e:
log(f"upstream недоступен: {e}") log(f"upstream недоступен: {e}")
return None, {}, b"" return None, {}, b""
@@ -69,9 +106,72 @@ def fix_userinfo(value):
return "; ".join(parts) return "; ".join(parts)
def strip_links(body): JSON_FIX_MAX = 2 * 1024 * 1024
def load_grace():
"""Карта «прежнее имя сайта → нынешнее» (файл от kit). Только имена сайтов, остальное отбрасываем."""
try:
with open(GRACE_FILE, "rb") as f:
raw = f.read(65537)
data = json.loads(raw.decode("utf-8")) if len(raw) <= 65536 else {}
except (OSError, ValueError, UnicodeError, RecursionError):
return {}
if not isinstance(data, dict):
return {}
return {k: v for k, v in list(data.items())[:100] if isinstance(k, str) and isinstance(v, str)
and len(k) <= 253 and len(v) <= 253 and SITE_NAME.fullmatch(k) and SITE_NAME.fullmatch(v)}
def grace_link(line, grace):
"""Ссылка REALITY с прежним именем сайта → с нынешним; остальные ссылки как есть."""
if not grace or not line.startswith("vless://") or "security=reality" not in line:
return line
return LINK_SNI.sub(lambda m: m.group(1) + grace.get(m.group(2), m.group(2)), line)
def fix_vless_encryption(body, grace=None):
"""JSON-подписка панели: у vless-пользователя без encryption (нет поля или пусто) ставим "none" – Xray иначе
не принимает конфиг. Две формы: vnext[].users[] и плоская (settings.id/encryption). Непустое значение
(например, mlkem) не трогаем. Прежнее (льготное) имя сайта REALITY – на нынешнее (grace).
Не разобралось, слишком большое или менять нечего – тело как пришло."""
if len(body) > JSON_FIX_MAX:
return body
try:
data = json.loads(body.decode("utf-8"))
except (ValueError, UnicodeError, RecursionError):
log("JSON-подписка: тело не разобралось, отдаю как есть")
return body
changed = False
def fix(user):
nonlocal changed
if isinstance(user, dict) and (user.get("encryption") is None or user.get("encryption") == ""):
user["encryption"] = "none"
changed = True
for cfg in (data if isinstance(data, list) else [data]):
outs = cfg.get("outbounds") if isinstance(cfg, dict) else None
for o in outs if isinstance(outs, list) else []:
ss = o.get("streamSettings") if isinstance(o, dict) else None
rs = ss.get("realitySettings") if isinstance(ss, dict) else None
if grace and isinstance(rs, dict) and rs.get("serverName") in grace:
rs["serverName"] = grace[rs["serverName"]]
changed = True
st = o.get("settings") if isinstance(o, dict) and o.get("protocol") == "vless" else None
if not isinstance(st, dict):
continue
for v in st.get("vnext") if isinstance(st.get("vnext"), list) else []:
for u in v.get("users") if isinstance(v, dict) and isinstance(v.get("users"), list) else []:
fix(u)
if "id" in st:
fix(st)
return json.dumps(data, ensure_ascii=False).encode() if changed else body
def strip_links(body, grace=None):
"""Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение """Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram.""" со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram. Прежнее имя сайта REALITY – на нынешнее."""
text = body.decode("utf-8", "replace").strip() text = body.decode("utf-8", "replace").strip()
encoded = "://" not in text encoded = "://" not in text
if encoded: if encoded:
@@ -79,7 +179,7 @@ def strip_links(body):
text = base64.b64decode(text + "=" * (-len(text) % 4)).decode("utf-8", "replace") text = base64.b64decode(text + "=" * (-len(text) % 4)).decode("utf-8", "replace")
except ValueError: except ValueError:
return body return body
lines = [l for l in text.splitlines() if l.strip() and not l.startswith(("vpn://", "tg://"))] lines = [grace_link(l, grace) for l in text.splitlines() if l.strip() and not l.startswith(("vpn://", "tg://"))]
out = "\n".join(lines) out = "\n".join(lines)
return base64.b64encode(out.encode()).decode().encode() if encoded else out.encode() return base64.b64encode(out.encode()).decode().encode() if encoded else out.encode()
@@ -127,11 +227,165 @@ def merge_awg(main_yaml, awg_yaml):
return yaml.safe_dump(main, allow_unicode=True, sort_keys=False).encode() return yaml.safe_dump(main, allow_unicode=True, sort_keys=False).encode()
AUTO_GROUP = "Авто"
def add_auto(clash_yaml):
"""Группа «Авто» (url-test): клиент сам выбирает самый быстрый из рабочих протоколов и переключается,
когда один из них перестаёт отвечать. Ставится первой в выбор, поэтому берётся по умолчанию;
вручную по-прежнему можно выбрать любой протокол."""
cfg = yaml.safe_load(clash_yaml)
if not isinstance(cfg, dict):
return clash_yaml
allp = [p for p in cfg.get("proxies") or [] if isinstance(p, dict) and p.get("name")]
names = [p["name"] for p in allp]
# WireGuard и AmneziaWG разрешают имена сайтов на самом клиенте (по обычному UDP внутри туннеля), поэтому в автовыбор
# не входят: по умолчанию клиент идёт протоколами, где имя уходит на сервер. Вручную их выбрать по-прежнему можно.
auto_names = [p["name"] for p in allp if p.get("type") != "wireguard" and "amnezia-wg-option" not in p]
groups = cfg.get("proxy-groups")
if len(auto_names) < 2 or not isinstance(groups, list) or any(g.get("name") == AUTO_GROUP for g in groups if isinstance(g, dict)):
return clash_yaml
groups.insert(0, {"name": AUTO_GROUP, "type": "url-test", "url": "http://www.gstatic.com/generate_204",
"interval": 300, "tolerance": 100, "lazy": True, "proxies": auto_names})
for g in groups[1:]:
lst = g.get("proxies") if isinstance(g, dict) else None
if isinstance(g, dict) and g.get("type") == "select" and isinstance(lst, list) and any(x in names for x in lst):
g["proxies"] = [AUTO_GROUP] + lst
break
return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode()
# DNS в Clash-подписке: 3X-UI профиль без раздела dns не присылает, и как клиент будет разрешать имена, зависит от
# самого приложения (системный DNS, возможны утечки). Добавляем безопасный вариант, если своего dns в профиле нет:
# fake-ip, запросы идут как обычный трафик по правилам (то есть через прокси) и по DoH, без запасного локального DNS.
# Адреса серверов имён – числовые, чтобы не нужен был ещё один запрос для их поиска.
DNS_BLOCK = {
"enable": True,
"ipv6": False,
"enhanced-mode": "fake-ip",
"fake-ip-range": "198.18.0.1/16",
"fake-ip-filter": ["*.lan", "+.local"],
"respect-rules": True,
"default-nameserver": ["1.1.1.1", "8.8.8.8"],
"nameserver": ["https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"],
# Имена самих серверов (если сервер задан доменом) разрешаются напрямую, но по DoH.
"proxy-server-nameserver": ["https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"],
}
def add_dns(clash_yaml):
cfg = yaml.safe_load(clash_yaml)
if not isinstance(cfg, dict) or "dns" in cfg:
return clash_yaml
cfg["dns"] = dict(DNS_BLOCK)
return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode()
def load_rules(report=None):
"""Читает RULES_FILE: список via_vpn («ТИП,ЗНАЧЕНИЕ») и необязательный direct_dns (адреса DoH для остального).
Возвращает (правила, direct_dns) или None, если файла нет или он непригоден: тогда подписка остаётся прежней."""
say = report or log
try:
with open(RULES_FILE, "rb") as f:
raw = f.read(RULES_MAX_BYTES + 1)
except FileNotFoundError:
return None
except OSError as e:
say(f"правила: не удалось прочитать {RULES_FILE}: {e}")
return None
if len(raw) > RULES_MAX_BYTES:
say(f"правила: файл больше {RULES_MAX_BYTES // 1024} КБ – не применяю")
return None
try:
data = yaml.safe_load(raw.decode("utf-8-sig")) or {}
except (yaml.YAMLError, UnicodeError) as e:
say(f"правила: файл не разобран ({e}) – не применяю")
return None
items = data.get("via_vpn") if isinstance(data, dict) else None
if not isinstance(items, list) or not items:
say("правила: нет списка via_vpn – не применяю")
return None
rules, seen = [], set()
for item in items[:RULES_MAX_COUNT + 50]:
r = re.sub(r"\s*,\s*", ",", str(item).strip())
kind, _, value = r.partition(",")
r = kind.upper() + "," + value # «domain-suffix,…» тоже понимаем
m = RULE_RE.match(r)
if not m:
say(f"правила: пропускаю непонятное правило {str(item)[:80]!r}")
continue
kind, value = m.group(1), m.group(2)
if kind in ("IP-CIDR", "IP-CIDR6"):
try:
net = ipaddress.ip_network(value, strict=False)
except ValueError:
net = None
if net is None or net.version != (6 if kind == "IP-CIDR6" else 4):
say(f"правила: неверная подсеть {value!r} – пропускаю")
continue
if kind == "DOMAIN-KEYWORD" and len(value) < 4:
say(f"правила: слово {value!r} слишком короткое (под него попадёт почти всё) – пропускаю")
continue
if (kind, value) not in seen and len(rules) < RULES_MAX_COUNT:
seen.add((kind, value))
rules.append((kind, value))
if not rules:
say("правила: ни одного пригодного – не применяю")
return None
direct = data.get("direct_dns")
direct = [d for d in direct if isinstance(d, str) and DOH_RE.match(d)][:4] if isinstance(direct, list) else []
return rules, direct
def apply_rules(clash_yaml, loaded):
"""Раздельная маршрутизация. Перечисленное идёт через VPN (первая группа прокси), остальное – напрямую.
DNS при этом не течёт: имена из списка в fake-ip режиме у клиента не разрешаются вовсе (сайт называется серверу),
запросы про них (на случай IP-подключений) – по DoH через VPN, а для остальных – системный DNS или direct_dns."""
cfg = yaml.safe_load(clash_yaml)
if not isinstance(cfg, dict):
return clash_yaml
groups = [g.get("name") for g in cfg.get("proxy-groups") or [] if isinstance(g, dict) and g.get("name")]
proxies = [p.get("name") for p in cfg.get("proxies") or [] if isinstance(p, dict) and p.get("name")]
target = groups[0] if groups else proxies[0] if proxies else None
if target is None:
return clash_yaml
rules, direct_dns = loaded
out = [f"IP-CIDR,{n},DIRECT,no-resolve" for n in PRIVATE_NETS]
# DoH-серверы – через VPN, чтобы запросы про сайты из списка не выходили мимо него.
out += [f"IP-CIDR,{h}/32,{target},no-resolve" for h in ("1.1.1.1", "8.8.8.8")]
policy = {}
for kind, value in rules:
# Правилам по адресам не даём разрешать имена (иначе это обычный DNS-запрос мимо VPN).
out.append(f"{kind},{value},{target}" + (",no-resolve" if kind in ("IP-CIDR", "IP-CIDR6", "GEOIP") else ""))
if kind == "GEOSITE":
policy["geosite:" + value] = DOH_DEFAULT
elif kind == "DOMAIN-SUFFIX":
policy["+." + value] = DOH_DEFAULT
elif kind == "DOMAIN":
policy[value] = DOH_DEFAULT
out.append("MATCH,DIRECT")
cfg["mode"] = "rule"
cfg["rules"] = out
dns = dict(DNS_BLOCK)
dns["nameserver"] = direct_dns or ["system"]
if policy:
dns["nameserver-policy"] = policy
cfg["dns"] = dns
# Для подключений по IP (без имени) домен берётся из TLS/HTTP/QUIC – иначе правила по доменам их не увидят.
cfg["sniffer"] = {"enable": True, "override-destination": False,
"sniff": {"TLS": {"ports": [443, 8443]}, "HTTP": {"ports": [80, "8080-8880"]}, "QUIC": {"ports": [443]}}}
return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode()
class Handler(http.server.BaseHTTPRequestHandler): class Handler(http.server.BaseHTTPRequestHandler):
server_version = "nginx" server_version = "nginx"
sys_version = "" sys_version = ""
protocol_version = "HTTP/1.1" # как у nginx; ответы всегда с Content-Length
timeout = 20 # зависшие соединения не держим timeout = 20 # зависшие соединения не держим
def version_string(self): # «Server: nginx» без хвостового пробела
return self.server_version
def setup(self): def setup(self):
# TLS-рукопожатие – в потоке запроса, а не в общем цикле приёма соединений. # TLS-рукопожатие – в потоке запроса, а не в общем цикле приёма соединений.
# Без сертификата (за nginx, на 127.0.0.1) работаем по обычному HTTP. # Без сертификата (за nginx, на 127.0.0.1) работаем по обычному HTTP.
@@ -149,33 +403,66 @@ class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # без IP клиентов в логах def log_message(self, fmt, *args): # без IP клиентов в логах
pass pass
def send_connection(self):
# nginx на ответ с keep-alive пишет «Connection: keep-alive», перед закрытием – «Connection: close».
self.send_header("Connection", "close" if self.close_connection else "keep-alive")
def send_plain(self, code, text=""): def send_plain(self, code, text=""):
body = text.encode() body = text.encode()
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_connection()
self.end_headers() self.end_headers()
if self.command != "HEAD": if self.command != "HEAD":
self.wfile.write(body) self.wfile.write(body)
def send_error(self, code, message=None, explain=None):
# Свои ответы вместо страницы ошибок Python, в том же виде, что у обычного nginx.
# На мусорную строку запроса Python считает запрос HTTP/0.9 и пишет одно тело, без строки статуса;
# nginx отвечает «HTTP/1.1 400 Bad Request» с заголовками.
self.request_version = "HTTP/1.1"
self.close_connection = True
self.send_html_error(code)
def send_html_error(self, code):
phrase = self.responses.get(code, ("Error",))[0]
body = (f"<html>\r\n<head><title>{code} {phrase}</title></head>\r\n<body>\r\n<center><h1>{code} {phrase}</h1></center>\r\n"
"<hr><center>nginx</center>\r\n</body>\r\n</html>\r\n").encode()
self.send_response(code)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(body)))
self.send_connection()
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def do_other(self):
# Тело запроса не читаем – значит, и соединение дальше не держим.
self.close_connection = True
self.send_html_error(404)
do_POST = do_PUT = do_DELETE = do_PATCH = do_OPTIONS = do_other
def do_HEAD(self): def do_HEAD(self):
self.do_GET() self.do_GET()
def do_GET(self): def do_GET(self):
path = self.path.split("?", 1)[0] path = self.path.split("?", 1)[0]
if not path.startswith(PATH): prefix = next((x for x in (PATH, *EXTRA) if path.startswith(x)), None)
return self.send_plain(404, "404 page not found") if prefix is None:
sub_id = path[len(PATH):] return self.send_html_error(404)
if not SUB_ID.match(sub_id): sub_id = path[len(prefix):]
return self.send_plain(404, "404 page not found") if not SUB_ID.match(sub_id) and not (prefix == PATH and ASSET.match(sub_id)):
return self.send_html_error(404)
ua = self.headers.get("User-Agent", "") ua = self.headers.get("User-Agent", "")
host = self.headers.get("Host", CONF.get("host", "")) host = CONF.get("link_host") or self.headers.get("Host", CONF.get("host", ""))
accept = self.headers.get("Accept", "") accept = self.headers.get("Accept", "")
code, headers, body = upstream(sub_id, ua, host, accept) code, headers, body = upstream(sub_id, upstream_ua(ua), host, accept, prefix)
if code is None: if code is None:
return self.send_plain(502, "subscription backend is unavailable") return self.send_html_error(502)
clash = bool(CLASH_UA.search(ua)) and "yaml" in headers.get("content-type", "") clash = (bool(CLASH_UA.search(ua)) or prefix != PATH) and "yaml" in headers.get("content-type", "")
awg = clash and not NO_AWG_UA.search(ua) awg = clash and not NO_AWG_UA.search(ua)
# В журнал – только приложение и что ему отдали, без IP. # В журнал – только приложение и что ему отдали, без IP.
log(f"{ua[:80]!r} → {'clash+awg' if awg else 'clash' if clash else headers.get('content-type', '?').split(';')[0]}") log(f"{ua[:80]!r} → {'clash+awg' if awg else 'clash' if clash else headers.get('content-type', '?').split(';')[0]}")
@@ -184,11 +471,25 @@ class Handler(http.server.BaseHTTPRequestHandler):
body = strip_awg(body) body = strip_awg(body)
elif code == 200 and awg and not sub_id.endswith(("-awg", "-tg")): elif code == 200 and awg and not sub_id.endswith(("-awg", "-tg")):
# Установки до kit 1.1 держали AmneziaWG в подписке «<id>-awg» – подмешиваем её. # Установки до kit 1.1 держали AmneziaWG в подписке «<id>-awg» – подмешиваем её.
acode, _, abody = upstream(sub_id + "-awg", ua, host, accept) acode, _, abody = upstream(sub_id + "-awg", ua, host, accept, prefix)
if acode == 200 and abody: if acode == 200 and abody:
body = merge_awg(body, abody) body = merge_awg(body, abody)
elif code == 200 and ("json" in headers.get("content-type", "") or (
"text/plain" in headers.get("content-type", "") and body.lstrip()[:1] in (b"[", b"{"))):
# Панель 3.9 отдаёт JSON-подписку с Content-Type text/plain – определяем по телу.
body = fix_vless_encryption(body, load_grace())
elif code == 200 and "text/plain" in headers.get("content-type", ""): elif code == 200 and "text/plain" in headers.get("content-type", ""):
body = strip_links(body) body = strip_links(body, load_grace())
if code == 200 and clash and CONF.get("auto", True):
body = add_auto(body)
if code == 200 and clash and CONF.get("dns", True):
body = add_dns(body)
if code == 200 and awg:
# Правила – только приложениям на ядре Mihomo (проверено: geosite, sniffer, nameserver-policy). Karing, Hiddify
# и другие на sing-box тоже могут просить формат Clash, но свою маршрутизацию они ведут сами: им подписка прежняя.
loaded = load_rules()
if loaded:
body = apply_rules(body, loaded)
except (yaml.YAMLError, UnicodeError) as e: except (yaml.YAMLError, UnicodeError) as e:
log(f"не удалось обработать подписку: {e}") log(f"не удалось обработать подписку: {e}")
@@ -196,9 +497,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
for k in PASS_HEADERS: for k in PASS_HEADERS:
if k in headers: if k in headers:
v = fix_userinfo(headers[k]) if k == "subscription-userinfo" else headers[k] v = fix_userinfo(headers[k]) if k == "subscription-userinfo" else headers[k]
if v: if v and "\r" not in v and "\n" not in v:
self.send_header(k.title(), v) self.send_header(k.title(), v)
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_connection()
self.end_headers() self.end_headers()
if self.command != "HEAD": if self.command != "HEAD":
self.wfile.write(body) self.wfile.write(body)
@@ -213,17 +515,85 @@ class Server(http.server.ThreadingHTTPServer):
address_family = socket.AF_INET6 if ":" in CONF.get("listen", "") else socket.AF_INET address_family = socket.AF_INET6 if ":" in CONF.get("listen", "") else socket.AF_INET
def routing_payload(kind, loaded):
"""Профиль маршрутизации для самой панели 3X-UI (она раздаёт его приложениям на Xray): kind = happ | json.
happ – заголовок Routing для Happ: через VPN только список, GlobalProxy=false.
json – правила для JSON-подписки Xray (серверы DoH 1.1.1.1 и 8.8.8.8 – тоже через VPN); конструктор панели всегда ставит в конец «всё через прокси», поэтому остальное
отправляем напрямую раньше (порядок block-proxy-direct, прямые regexp:.* и 0.0.0.0/0)."""
rules, direct_dns = loaded
sites, ips = [], []
for k, v in rules:
if k == "GEOSITE":
sites.append("geosite:" + v)
elif k == "DOMAIN-SUFFIX":
sites.append("domain:" + v)
elif k == "DOMAIN":
sites.append("full:" + v)
elif k == "DOMAIN-KEYWORD":
sites.append(v)
elif k == "GEOIP":
ips.append("geoip:" + v)
else:
ips.append(v)
if kind == "happ":
data = {"Name": "3X-UI_autoVPS", "GlobalProxy": "false", "DomainStrategy": "IPIfNonMatch", "ProxySites": sites,
"ProxyIp": ips, "DirectIp": ["geoip:private"]}
else:
# localhost – системный DNS клиента для прямых сайтов (как в подписке Mihomo); список direct_dns его заменяет.
data = {"Name": "3X-UI_autoVPS", "DomainStrategy": "IPIfNonMatch", "RouteOrder": "block-proxy-direct",
"ProxySites": sites, "ProxyIp": ["1.1.1.1", "8.8.8.8"] + ips, "DirectSites": ["regexp:.*"],
"DirectIp": ["geoip:private", "0.0.0.0/0", "::/0"], "DomesticDNSDomain": direct_dns[0] if direct_dns else "localhost"}
text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
# Happ ждёт в заголовке ссылку happ://routing/onadd/<base64>; панели для JSON-подписки нужен обычный JSON.
return "happ://routing/onadd/" + base64.b64encode(text.encode()).decode() if kind == "happ" else text
GEO_URL = "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/{}.dat"
def unknown_geo(kind, names):
"""Сверяет категории GEOSITE/GEOIP с базой, которую скачают приложения: неизвестное имя ломает запуск конфига у клиента.
Нет доступа к GitHub – не проверяем."""
if not names:
return []
try:
with urllib.request.urlopen(GEO_URL.format(kind), timeout=30) as r:
data = r.read(48 * 1024 * 1024)
except (urllib.error.URLError, OSError) as e:
print(f"Категории {kind.upper()} не проверил: база недоступна ({e}). Названия сверьте по github.com/MetaCubeX/meta-rules-dat.")
return []
return [n for n in names if b"\x0a" + bytes([len(n)]) + n.upper().encode() not in data]
def main(): def main():
if "--routing-payload" in sys.argv:
# kit net split: профиль для панели (для Happ и Xray); пусто и код 1, если правил нет.
loaded = load_rules(report=lambda m: print(m, file=sys.stderr))
if not loaded:
raise SystemExit(1)
print(routing_payload(sys.argv[sys.argv.index("--routing-payload") + 1], loaded))
raise SystemExit(0)
if "--check-rules" in sys.argv:
# kit net split check: тот же разбор, что и при выдаче подписки, но с отчётом на экран.
loaded = load_rules(report=print)
if loaded:
print(f"Правил через VPN: {len(loaded[0])}; DNS для остального: {', '.join(loaded[1]) or 'системный'}")
bad = [f"{k} {n}" for k in ("geosite", "geoip") for n in unknown_geo(k, [v for kk, v in loaded[0] if kk == k.upper()])]
if bad:
print("Нет такой категории: " + ", ".join(bad) + ". Приложение не сможет запустить конфиг – исправьте или удалите эти строки.")
raise SystemExit(2)
raise SystemExit(0 if loaded else 1)
cert, key = CONF.get("cert"), CONF.get("key") cert, key = CONF.get("cert"), CONF.get("key")
if cert and CREDS and os.path.exists(os.path.join(CREDS, "cert.pem")): if cert and CREDS and os.path.exists(os.path.join(CREDS, "cert.pem")):
cert, key = os.path.join(CREDS, "cert.pem"), os.path.join(CREDS, "key.pem") cert, key = os.path.join(CREDS, "cert.pem"), os.path.join(CREDS, "key.pem")
if not cert: if not cert:
srv = Server((CONF.get("listen", "127.0.0.1"), int(CONF["port"])), Handler) srv = Server((CONF.get("listen", "127.0.0.1"), int(CONF["port"])), Handler)
log(f"kit-sub слушает http://{CONF.get('listen', '127.0.0.1')}:{CONF['port']}{PATH} (TLS снимает nginx)") log(f"kit-sub слушает {CONF.get('listen', '127.0.0.1')}:{CONF['port']} (TLS снимает nginx)") # путь подписки в журнал не пишем: он секретный
srv.serve_forever() srv.serve_forever()
return return
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.set_alpn_protocols(["http/1.1"]) # как nginx без http2: клиент предложил http/1.1 – соглашаемся
ctx.load_cert_chain(cert, key) ctx.load_cert_chain(cert, key)
stamp = [os.path.getmtime(cert)] stamp = [os.path.getmtime(cert)]
@@ -243,9 +613,10 @@ def main():
threading.Thread(target=reload_cert, daemon=True).start() threading.Thread(target=reload_cert, daemon=True).start()
srv = Server((CONF.get("listen", "0.0.0.0"), int(CONF["port"])), Handler) srv = Server((CONF.get("listen", "0.0.0.0"), int(CONF["port"])), Handler)
srv.ssl_ctx = ctx srv.ssl_ctx = ctx
log(f"kit-sub слушает {CONF.get('listen', '0.0.0.0')}:{CONF['port']}{PATH}") log(f"kit-sub слушает {CONF.get('listen', '0.0.0.0')}:{CONF['port']}")
srv.serve_forever() srv.serve_forever()
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+2938 -92
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
# Матрица «протокол × клиент» # Матрица «протокол × клиент»
Проверяет настоящим подключением, что каждый протокол 3X-UI работает с каждым клиентом: Проверяет, что каждый протокол 3X-UI работает с каждым клиентом:
Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. Прогонять на каждой новой версии 3X-UI и Xray. Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. Прогонять на каждой новой версии 3X-UI и Xray.
1. Сервер: чистый Ubuntu в Docker с systemd, на нём `scripts/3x-ui.sh`, затем `mk-inbounds.sh` – 1. Сервер: чистый Ubuntu в Docker с systemd, на нём `scripts/3x-ui.sh`, затем `mk-inbounds.sh` –
@@ -28,7 +28,7 @@ Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. П
| VMess WS + TLS | ✓ | ✓ | ✓ | | VMess WS + TLS | ✓ | ✓ | ✓ |
| Shadowsocks 2022 | ✓ | ✓ | ✓ | | Shadowsocks 2022 | ✓ | ✓ | ✓ |
| TUIC v5 | – | ✓ | ✓ | | TUIC v5 | – | ✓ | ✓ |
| WireGuard | ✓ | ✓ | ✓ | (локально; через DPI в России – ✗ у всех клиентов) | | WireGuard | ✓ | ✓ | ✓ | (локально; через интернет к удалённому серверу – ✗ у всех клиентов) |
| AmneziaWG (классика) | – | ✓ | – | | AmneziaWG (классика) | – | ✓ | – |
| AmneziaWG 3.1 | – | ✓ | – | | AmneziaWG 3.1 | – | ✓ | – |
@@ -38,12 +38,12 @@ MTProto: `mtg doctor` – все дата-центры Telegram доступны
Со своим (самоподписанным) сертификатом подписка Clash от 3X-UI не передаёт Mihomo отпечаток – Со своим (самоподписанным) сертификатом подписка Clash от 3X-UI не передаёт Mihomo отпечаток –
TLS-протоколы в ней не работают; ссылки с `pcs` работают. TLS-протоколы в ней не работают; ссылки с `pcs` работают.
## Через интернет к серверу в России, 2026-09-27 ## Через интернет к удалённому серверу, 2026-09-27
Сервер Ubuntu 24.04 в России, `scripts/3x-ui.sh` с настоящим сертификатом Let's Encrypt на IP, Удалённый сервер Ubuntu 24.04, `scripts/3x-ui.sh` с настоящим сертификатом Let's Encrypt на IP,
клиенты – на Mac через интернет. Подписка-ссылки и подписка Clash: все протоколы ✓, кроме: клиенты – на Mac через интернет. Подписка-ссылки и подписка Clash: все протоколы ✓, кроме:
- **WireGuard** – ✗ у Xray, Mihomo и sing-box: рукопожатие доходит до сервера, ответ режет DPI. - **WireGuard** – ✗ у Xray, Mihomo и sing-box: рукопожатие доходит до сервера, ответ не возвращается.
Поэтому из набора по умолчанию убран. Поэтому из набора по умолчанию убран.
- **MTProto** – сервер хостера не достаёт до Telegram, прокси бесполезен; установщик теперь - **MTProto** – сервер хостера не достаёт до Telegram, прокси бесполезен; установщик теперь
проверяет это и пропускает MTProto. проверяет это и пропускает MTProto.
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Справка `kit help` обязана перечислять все команды: авторские (их берём прямо из диспетчера
# scripts/kit.sh) и наши. Если автор добавит команду, а мы забудем про справку — проверка упадёт,
# и сборка релиза остановится: так справка не отстанет молча.
set -Eeuo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
help=$(awk '/^kit_help_all\(\) \{/,/^\}/' scripts/kit-local.sh)
[[ -n $help ]] || { echo "✗ не нашёл kit_help_all в scripts/kit-local.sh" >&2; exit 1; }
bad=0
# 1) авторские команды: первое слово каждой ветки диспетчера kit.sh
while read -r word; do
[[ -n $word ]] || continue
case $word in __* | local | help | -h | --help | -v | --version) continue ;; esac
# «user off» в справке может стоять как «user on|off» — поэтому пробел допускаем и как разделитель вариантов
pat=${word// /[ |]}
grep -qE " kit ${pat}" <<<"$help" || { echo " ✗ в справке нет авторской команды: kit $word" >&2; bad=1; }
done < <(grep -oE '^ +"[a-z][a-z0-9 -]*"\)|^ +"[a-z][a-z0-9 -]*" \*\)' scripts/kit.sh | sed 's/)$//' | tr -d '"' | sed 's/^ *//' | sort -u)
# 2) наши команды из ветки kit_local_cmd
while read -r word; do
[[ -n $word ]] || continue
grep -qF " kit local $word" <<<"$help" || { echo " ✗ в справке нет нашей команды: kit local $word" >&2; bad=1; }
done < <(awk '/^kit_local_cmd\(\) \{/,/^\}/' scripts/kit-local.sh | grep -oE '^ [a-z-]+' | tr -d ' ' | sort -u)
if (( bad )); then
echo "✗ справка kit help отстала от набора команд — поправьте kit_help_all в scripts/kit-local.sh" >&2
exit 1
fi
echo " ✓ справка kit help содержит все команды (авторские и наши)"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Слежение за чужими версиями: сравнивает закреплённые в скриптах версии с последними релизами.
# Ничего не меняет, только печатает. Запуск: tools/check-upstream.sh
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
latest() { # репозиторий шаблон-тега (регулярка)
git ls-remote --tags --refs "https://github.com/$1" 2>/dev/null | awk '{print $2}' | sed 's#refs/tags/##' | grep -E "$2" | sort -V | tail -1
}
pinned() { sed -n "s/^$2=\"\(.*\)\"/\1/p" "$1" | head -1; }
row() { # название закреплено последняя
local mark=" "
[[ -n $3 && $2 != "$3" ]] && mark="⬆ "
printf '%s%-12s закреплено %-12s последняя %s\n' "$mark" "$1" "$2" "${3:-?}"
}
echo "Закреплённые версии и последние релизы (⬆ – есть новее, прочитайте список изменений и протестируйте):"
row "3X-UI" "$(pinned scripts/3x-ui.sh XUI_VERSION)" "$(latest MHSanaei/3x-ui '^v[0-9]+\.[0-9]+\.[0-9]+$')"
row "Xray-core" "$(pinned scripts/3x-ui.sh XRAY_CORE)" "$(latest XTLS/Xray-core '^v[0-9]+\.[0-9]+\.[0-9]+$')"
zv=$(sed -n "s#.*zashboard/releases/download/\(v[0-9.]*\)/dist.zip.*#\1#p" tools/lib/mihomo.js | head -1)
row "zashboard" "$zv" "$(latest Zephyruso/zashboard '^v[0-9]+\.[0-9]+\.[0-9]+$')"
printf ' %-12s последняя %s\n' "Mihomo" "$(latest MetaCubeX/mihomo '^v[0-9]+\.[0-9]+\.[0-9]+$')"
printf ' %-12s последняя %s\n' "sing-box" "$(latest SagerNet/sing-box '^v[0-9]+\.[0-9]+\.[0-9]+$')"
echo
echo "Что проверить при новой версии 3X-UI или Xray: tests/matrix (протокол × клиент), kit panel update, kit check."
echo "Новое ядро Xray сначала проверяется клиентами: на тест-сервере с KIT запустите bash tools/core-compat.sh vX.Y.Z"
echo "(с 26.9.30 REALITY требует от клиента ключ X25519MLKEM768: Mihomo его отправляет, sing-box пока нет). Версия ядра в KIT"
echo "меняется, только когда проверка показывает «работает» у обоих клиентов."
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Проверка, подходит ли новая версия ядра Xray клиентам на Mihomo и sing-box (REALITY по TCP).
# Запускать от root на ТЕСТОВОМ сервере с установленным KIT: скрипт на время подменяет ядро панели
# проверенной по SHA256 версией, прогоняет клиентов и в любом случае возвращает прежнее ядро.
#
# bash core-compat.sh v26.9.30 проверить версию ядра
# bash core-compat.sh проверить то, что сейчас стоит (контроль)
# MIHOMO=v1.19.32 SINGBOX=1.14.2 bash core-compat.sh v26.9.30 взять конкретные клиенты
# MIHOMO_SHA256=… SINGBOX_SHA256=… свои суммы архивов клиентов, если GitHub не прислал digest
set -Eeuo pipefail
CAND=${1:-}
BIN=/usr/local/x-ui/bin/xray-linux-amd64
W=$(mktemp -d); BAK=$W/xray.orig
[[ $EUID -eq 0 && -x $BIN ]] || { echo "Нужен root на сервере с панелью 3X-UI." >&2; exit 1; }
command -v kit >/dev/null || { echo "Нужен установленный kit." >&2; exit 1; }
arch=$(uname -m); case $arch in x86_64) xa=64; ma=amd64 ;; aarch64) xa=arm64-v8a; ma=arm64 ;; *) echo "Только amd64 и arm64." >&2; exit 1 ;; esac
restore() { if [[ -f $BAK ]]; then systemctl stop x-ui; install -m 755 "$BAK" "$BIN"; systemctl start x-ui; sleep 8; fi; pkill -f "$W/" 2>/dev/null || true; rm -rf "$W"; }
trap restore EXIT
gh() { curl -fsSL "$1"; }
latest_tag() { gh "https://api.github.com/repos/$1/releases?per_page=30" | python3 -c "
import json, sys
for r in json.load(sys.stdin):
if not r['prerelease'] and not r['draft']:
print(r['tag_name']); break"; }
# --- ядро-кандидат ---
if [[ -n $CAND ]]; then
cp -a "$BIN" "$BAK"
gh "https://github.com/XTLS/Xray-core/releases/download/$CAND/Xray-linux-$xa.zip" >"$W/x.zip"
gh "https://github.com/XTLS/Xray-core/releases/download/$CAND/Xray-linux-$xa.zip.dgst" >"$W/x.dgst"
want=$(awk 'tolower($0) ~ /sha2-256/ {print $NF; exit}' "$W/x.dgst"); got=$(sha256sum "$W/x.zip" | awk '{print $1}')
[[ -n $want && $want == "$got" ]] || { echo "Сумма ядра $CAND не совпала – остановился." >&2; exit 1; }
unzip -q -o "$W/x.zip" xray -d "$W"; systemctl stop x-ui; install -m 755 "$W/xray" "$BIN"; systemctl start x-ui; sleep 8
fi
echo "Ядро на сервере: $("$BIN" -version | head -1)"
# --- клиенты (суммы – из метаданных релиза) ---
fetch() { # репозиторий метка-файла-шаблон выходной-файл
gh "https://api.github.com/repos/$1/releases/tags/$2" | python3 -c "
import json, re, sys
r = json.load(sys.stdin)
for a in r['assets']:
if re.fullmatch(sys.argv[1], a['name']):
print(a['browser_download_url'], (a.get('digest') or '').replace('sha256:', '')); break" "$3"; }
MV=${MIHOMO:-$(latest_tag MetaCubeX/mihomo)}; SV=${SINGBOX:-$(latest_tag SagerNet/sing-box | sed 's/^v//')}
read -r murl msum < <(fetch MetaCubeX/mihomo "$MV" "mihomo-linux-$ma-compatible-$MV.gz") || true
read -r surl ssum < <(fetch SagerNet/sing-box "v$SV" "sing-box-$SV-linux-$ma.tar.gz") || true
# Без суммы ничего не запускаем: либо digest из GitHub, либо закреплённая вручную.
msum=${MIHOMO_SHA256:-${msum:-}}; ssum=${SINGBOX_SHA256:-${ssum:-}}
[[ -n ${murl:-} && $msum =~ ^[0-9a-f]{64}$ ]] || { echo "Нет архива или SHA256 для mihomo $MV (GitHub не прислал digest) – не ставлю. Задайте MIHOMO_SHA256=… со страницы релиза." >&2; exit 1; }
[[ -n ${surl:-} && $ssum =~ ^[0-9a-f]{64}$ ]] || { echo "Нет архива или SHA256 для sing-box $SV (GitHub не прислал digest) – не ставлю. Задайте SINGBOX_SHA256=… со страницы релиза." >&2; exit 1; }
gh "$murl" >"$W/m.gz"; [[ $(sha256sum "$W/m.gz" | awk '{print $1}') == "$msum" ]] || { echo "Сумма mihomo не совпала." >&2; exit 1; }
gunzip -c "$W/m.gz" >"$W/mihomo"; chmod +x "$W/mihomo"
gh "$surl" >"$W/s.tgz"; [[ $(sha256sum "$W/s.tgz" | awk '{print $1}') == "$ssum" ]] || { echo "Сумма sing-box не совпала." >&2; exit 1; }
mkdir -p "$W/sb"; tar xzf "$W/s.tgz" -C "$W/sb" --strip-components=1
link=$(KIT_NO_QR=1 kit user link "${USER_NAME:-admin}" --all 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -a -E '^ vless://.*security=reality.*type=tcp' | head -1 | tr -d ' ')
[[ -n $link ]] || { echo "Не нашёл ссылку REALITY (tcp) у пользователя ${USER_NAME:-admin}." >&2; exit 1; }
export LINK=$link W
python3 - <<'PY'
import json, os
from urllib.parse import urlparse, parse_qs
u = urlparse(os.environ["LINK"]); q = {k: v[0] for k, v in parse_qs(u.query).items()}
W = os.environ["W"]
json.dump({"mixed-port": 17890, "log-level": "warning", "rules": ["MATCH,P"], "proxies": [{
"name": "P", "type": "vless", "server": u.hostname, "port": u.port, "uuid": u.username, "network": "tcp", "tls": True,
"udp": True, "flow": q.get("flow", ""), "servername": q["sni"], "client-fingerprint": q.get("fp", "chrome"),
"reality-opts": {"public-key": q["pbk"], "short-id": q["sid"], "support-x25519mlkem768": q.get("support-x25519mlkem768") == "true"}}]}, open(W + "/m.yaml", "w"))
json.dump({"log": {"level": "warn"}, "inbounds": [{"type": "mixed", "listen": "127.0.0.1", "listen_port": 17891}], "outbounds": [{
"type": "vless", "server": u.hostname, "server_port": u.port, "uuid": u.username, "flow": q.get("flow", ""),
"tls": {"enabled": True, "server_name": q["sni"], "utls": {"enabled": True, "fingerprint": q.get("fp", "chrome")},
"reality": {"enabled": True, "public_key": q["pbk"], "short_id": q["sid"]}}}]}, open(W + "/s.json", "w"))
PY
probe() { # порт
curl -sS -m 12 -x "http://127.0.0.1:$1" -o /dev/null -w '%{http_code}' "https://www.cloudflare.com/cdn-cgi/trace?x=$RANDOM" 2>/dev/null || true
}
setsid nohup "$W/mihomo" -d "$W" -f "$W/m.yaml" >"$W/m.log" 2>&1 </dev/null & sleep 5
mc=$(probe 17890); pkill -f "$W/mihomo" || true
setsid nohup "$W/sb/sing-box" run -c "$W/s.json" >"$W/s.log" 2>&1 </dev/null & sleep 4
sc=$(probe 17891); pkill -f "$W/sb/sing-box" || true
ok() { [[ $1 == 200 ]] && echo "работает" || echo "НЕ РАБОТАЕТ (http=${1:-000})"; }
echo
echo "Ядро Xray: ${CAND:-как стоит}"
echo " Mihomo $MV: $(ok "$mc")"
echo " sing-box $SV: $(ok "$sc")"
echo "Xray-клиенты (Happ, v2rayN) здесь не проверяются: они на том же ядре."
[[ $mc == 200 && $sc == 200 ]]
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# Робот ежесуточной синхронизации: проверяет обновления у автора проекта и, если они есть,
# переносит их в нашу ветку dev — но только когда всё сошлось.
#
# Где живёт: запускается из .gitea/workflows/fork-sync.yml на раннере (CT 114 «runner»).
# Что НЕ делает: не выпускает релиз и не подписывает его — закрытый ключ подписи остаётся
# только на рабочей станции владельца, поэтому версия автора публикуется вручную.
#
# Переменные окружения (все имеют значения по умолчанию, кроме TOKEN):
# TOKEN токен Gitea с правом записи в репозиторий (берётся из секретов workflow)
# GITEA_URL внутренний адрес Gitea (снаружи контейнер её не видит: имя резолвится в публичный IP)
# REPO владелец/репозиторий
# UPSTREAM адрес авторского репозитория
# WORK рабочая копия на раннере
# BRANCH наша ветка для синхронизации
# LOG файл отчёта последнего запуска (его читают из контейнера руками)
set -Eeuo pipefail
GITEA_URL=${GITEA_URL:-http://10.10.10.54:3000}
REPO=${REPO:-hacker07/3X-UI_autoVPS}
UPSTREAM=${UPSTREAM:-https://github.com/itsnotkubrick/3X-UI_KIT}
WORK=${WORK:-/opt/kit-fork-sync/repo}
BRANCH=${BRANCH:-dev}
LOG=${LOG:-/opt/kit-fork-sync/last-run.log}
TOKEN=${TOKEN:-}
say() { printf '\n==> %s\n' "$*"; }
ok() { printf ' ✓ %s\n' "$*"; }
die() { printf '✗ %s\n' "$*" >&2; exit 1; }
mkdir -p "$(dirname "$LOG")"
exec > >(tee -a "$LOG") 2>&1
printf '\n===== запуск %s =====\n' "$(date '+%F %T %Z')"
[[ -n $TOKEN ]] || die "нет TOKEN: workflow не передал секрет GITEA_TOKEN"
command -v git >/dev/null || die "нет git"
PY=${PY:-$(command -v python3 || command -v python || true)}
[[ -n $PY ]] || die "нет python3 — без него не применить швы"
auth_url="${GITEA_URL}/${REPO}.git"
auth_url="${auth_url/http:\/\//http://x-access-token:${TOKEN}@}"
# ---------------------------------------------------------------- рабочая копия
if [[ ! -d $WORK/.git ]]; then
say "Первая синхронизация: клонирую репозиторий"
git clone -q "$auth_url" "$WORK" || die "не смог склонировать $REPO"
git -C "$WORK" remote set-url origin "$auth_url"
git -C "$WORK" remote add upstream "$UPSTREAM" 2>/dev/null || git -C "$WORK" remote set-url upstream "$UPSTREAM"
else
git -C "$WORK" remote set-url origin "$auth_url"
git -C "$WORK" remote set-url upstream "$UPSTREAM" 2>/dev/null || git -C "$WORK" remote add upstream "$UPSTREAM"
fi
cd "$WORK"
say "Забираю ветки: наша $BRANCH и авторскую main"
git fetch -q origin --prune
git fetch -q upstream 'refs/heads/main:refs/heads/upstream' --prune
git checkout -q "$BRANCH"
git reset -q --hard "origin/$BRANCH"
author_version=$(git show upstream:VERSION 2>/dev/null | tr -d '[:space:]' || true)
[[ -n $author_version ]] || die "у автора не читается VERSION — проверьте ветку upstream"
ours=$(tr -d '[:space:]' <VERSION)
echo " у автора: $author_version · у нас: $ours"
new_commits=$(git rev-list --count "$BRANCH..upstream")
if [[ $new_commits == 0 ]]; then
say "У автора нет новых коммитов — публиковать нечего"
ok "проверка выполнена, ветка $BRANCH не изменялась"
exit 0
fi
say "У автора $new_commits новых коммитов — переношу наши правки на свежий код"
git --no-pager log --oneline "$BRANCH..upstream" | sed 's/^/ /'
# ---------------------------------------------------------------- перенос и проверки
# Скрипт форка делает перенос (rebase на ветку автора) и все проверки: швы, синтаксис, LF, тесты.
# Без --apply: швы в нашей ветке уже накатаны, повторный накат на такое дерево падает (якоря автора
# заменены нашими строками). Номер версии поднимаем сами — по той же логике, что и в fork-sync.sh.
if ! bash tools/fork-sync.sh; then
die "перенос не прошёл (конфликт в местах наших правок). Ветка $BRANCH не изменена. Разберите вручную на рабочей станции"
fi
# Номер версии: у автора 1.3 → наш 1.3.1; если база та же, а мы дорабатываем — 1.3.2. Обязан расти.
base=${author_version%%.*}
cur=$(tr -d '[:space:]' <VERSION)
if [[ ${cur%%.*} == "$base" ]]; then
new_version="$base.$(( ${cur##*.} + 1 ))"
else
new_version="${author_version}.1"
fi
printf '%s\n' "$new_version" >VERSION
for f in scripts/kit.sh scripts/3x-ui.sh scripts/hysteria2.sh scripts/3x-ui-local.sh; do
sed -i "s/^KIT_VERSION=\"[0-9.]*\"/KIT_VERSION=\"$new_version\"/" "$f"
done
"$PY" tools/fork_apply.py >/dev/null 2>&1 || true # на дереве со швами это ожидаемо: якорей уже нет
echo " версия сборки: $cur → $new_version"
# Заглушка записи в нашем журнале версий: без раздела «## v<версия>» сборщик релиза откажется
# подписывать. Текст — на владельца, он правит его перед выпуском.
if ! grep -q "^## v$new_version" FORK-CHANGELOG.md; then
say "Добавляю запись в FORK-CHANGELOG для версии $new_version"
"$PY" - "$new_version" "$author_version" <<'PY'
import sys, pathlib
new, author = sys.argv[1], sys.argv[2]
p = pathlib.Path("FORK-CHANGELOG.md")
text = p.read_text(encoding="utf-8")
block = (f"## v{new}: обновление от автора {author}\n\n"
f"* Автоматическая синхронизация подтянула код автора {author} в нашу сборку: наши правки (швы)\n"
f" перенесены на свежий код, проверки прошли. Описать, что изменилось у автора, до выпуска релиза.\n\n")
marker = "## v"
i = text.find(marker)
p.write_text(text[:i] + block + text[i:], encoding="utf-8", newline="\n")
print(f" ✓ раздел «## v{new}» добавлен")
PY
fi
# Финальный контроль: швы на месте, синтаксис и LF — как того требует сборщик релиза.
"$PY" tools/fork_apply.py --check >/dev/null || die "после переноса швы не проверяются — ничего не публикую"
for f in scripts/*.sh tools/*.sh; do bash -n "$f" || die "$f: синтаксис"; done
if grep -qU $'\r' scripts/*.sh tools/*.sh VERSION 2>/dev/null; then
die "в файлах CRLF — сервер сверяет SHA256, сумма не сойдётся. Ничего не публикую"
fi
ok "швы на месте, синтаксис и переводы строк в порядке"
# ---------------------------------------------------------------- публикация
if git diff --quiet origin/"$BRANCH" -- VERSION scripts tools .gitea 2>/dev/null && [[ $new_version == "$ours" ]]; then
say "Содержимое не изменилось — публиковать нечего"
exit 0
fi
say "Публикую в $BRANCH"
git add -A
git -c user.name="fork-sync-bot" -c user.email="fork-sync@rukavishnikoff.ru" \
commit -q -m "Синхронизация с автором ${author_version}: швы и версия ${new_version}" \
-m "Автоматика CT 114. Проверки: швы на месте, bash -n, LF. Релиз и подпись — вручную с рабочей станции." \
|| die "не смог закоммитить"
curl -fsS -o /dev/null -H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/branches/${BRANCH}" || die "Gitea недоступна перед отправкой"
# Перенос идёт rebase'ом: наша ветка переписывается на свежий код автора, поэтому отправляем
# с --force-with-lease — если за это время в ветку что-то попало с рабочей станции, отправка
# откажется, а не затрёт чужой коммит.
git push -q --force-with-lease=refs/heads/"$BRANCH" origin "$BRANCH" \
|| die "не смог отправить ветку $BRANCH (возможно, в ней есть ваши свежие коммиты — тогда синхронизируйте вручную)"
ok "ветка $BRANCH обновлена: $(git --no-pager log --oneline -1)"
cat <<EOF
Готово. Дальше вручную, с рабочей станции:
1) git fetch origin && git checkout $BRANCH && git reset --hard origin/$BRANCH
2) описать изменения в FORK-CHANGELOG.md (раздел уже добавлен)
3) bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key — подпись и релиз
4) git tag v$new_version && git push origin $BRANCH v$new_version
EOF
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# 3X-UI_KIT (форк hacker07): подтянуть обновление автора в ветку dev и прогнать проверки.
#
# Ничего не публикует и ничего не обновляет на серверах: после проверок печатает, что делать
# дальше (версия, подпись, тег, push). Запускать с рабочей станции, по шагам.
#
# bash tools/fork-sync.sh # подтянуть автора и проверить
# bash tools/fork-sync.sh --apply # ещё и вписать VERSION/KIT_VERSION (номер версии)
#
# Логика версии: у автора 1.3 → наш номер 1.3.1; если база та же, а мы что-то доработали —
# 1.3.2. Номер обязан строго расти: kit у серверов не принимает откат назад.
set -Eeuo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
PY=${PY:-$(command -v python3 || command -v python || true)}
[[ -n $PY ]] || { echo "! нужен python3" >&2; exit 1; }
apply=no
case ${1:-} in --apply) apply=yes ;; "") ;; *) echo "Использование: bash tools/fork-sync.sh [--apply]" >&2; exit 1 ;; esac
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
die() { printf '! %s\n' "$*" >&2; exit 1; }
[[ -z $(git status --porcelain) ]] || die "в рабочей копии есть незакоммиченные правки — сначала закоммить или убери их"
say "Забираю ветку автора"
git fetch --prune upstream 'refs/heads/main:refs/heads/upstream'
author_version=$(git show upstream:VERSION | tr -d '[:space:]')
echo "у автора: $author_version"
say "Переношу наши правки на свежую ветку автора"
git checkout -q dev
if ! git rebase upstream; then
echo
echo "Конфликт при переносе. Это ожидаемо, если автор переписал те же строки."
echo " 1) разбери конфликт: git status, правь файлы, git add, git rebase --continue"
echo " 2) если проще собрать швы заново: git rebase --abort, затем"
echo " git checkout -B dev upstream && $PY tools/fork_apply.py && git commit -am 'fork: швы'"
die "остановился, ничего не публикую"
fi
say "Проверяю, что наши швы ещё на месте"
"$PY" tools/fork_apply.py --check
say "Проверки кода (как на стороне сервера и в CI автора)"
for f in scripts/*.sh tools/*.sh; do bash -n "$f" || die "$f: синтаксис"; done
echo " ✓ bash -n"
"$PY" -m py_compile scripts/kit-sub.py && echo " ✓ python-скрипты собираются"
if grep -qU $'\r' scripts/*.sh tools/*.sh VERSION 2>/dev/null; then
die "в файлах есть CRLF — сервер сверяет SHA256, и его сумма не сойдётся. Проверь core.autocrlf (нужен false)"
fi
echo " ✓ переводы строк LF"
if command -v shellcheck >/dev/null; then
shellcheck -S warning -e SC1090,SC2034,SC2211 scripts/*.sh tools/release.sh tools/fork-sync.sh && echo " ✓ shellcheck"
else
echo " – shellcheck не установлен, пропускаю"
fi
if command -v node >/dev/null; then
node --test tools/test/*.test.js >/dev/null && echo " ✓ node --test"
else
echo " – node нет, пропускаю тесты генераторов"
fi
"$PY" tools/test/restore_env_test.py >/dev/null && "$PY" tools/test/restore_archive_test.py >/dev/null \
&& echo " ✓ тесты копии (--restore)"
say "Готово: наши правки лежат на свежем коде автора"
git --no-pager log --oneline upstream..dev
echo
echo "Версия автора: $author_version · наша сейчас: $(tr -d '[:space:]' <VERSION)"
if [[ $apply == yes ]]; then
base=${author_version%%.*}
cur=$(tr -d '[:space:]' <VERSION)
if [[ ${cur%%.*} == "$base" ]]; then
new="$base.$(( ${cur##*.} + 1 ))"
else
new="$author_version.1"
fi
printf '%s\n' "$new" >VERSION
"$PY" tools/fork_apply.py >/dev/null
echo "Версия сборки → $new (VERSION и KIT_VERSION в трёх скриптах)"
fi
cat <<'NEXT'
Дальше вручную, по шагам:
1) подпись и релиз: bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key
2) коммит и тег: git add -A && git commit -m "Релиз v$(cat VERSION): швы форка + подпись"
git tag v$(cat VERSION)
3) публикация: git push origin dev v$(cat VERSION) upstream
4) на сервере: kit update --force && kit check
NEXT
+709
View File
@@ -0,0 +1,709 @@
#!/usr/bin/env python3
"""3X-UI_KIT (форк hacker07): накатывает наши «швы» на чистый код автора.
Это источник правды для форка: единственное место, где описано, ЧТО именно мы правим в
файлах автора. Всё, что мы дописываем по существу, живёт в scripts/kit-local.sh — файле,
который автор не трогает, поэтому обновления автора его не ломают.
Правила:
* каждый якорь обязан найтись РОВНО один раз, иначе — стоп (автор изменил код, правку
надо пересмотреть руками);
* при любой ошибке ничего не пишется наполовину: сначала прогоняй --check;
* открытый ключ подписи берётся из tools/fork-signer.pub (закрытая часть — только на
рабочей станции владельца, в репозиторий не попадает).
Использование:
python3 tools/fork_apply.py --check [корень-репозитория]
python3 tools/fork_apply.py [корень-репозитория]
"""
import os
import pathlib
import re
import sys
GITEA = os.environ.get("FORK_GITEA_BASE", "https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS")
SIGNER_FILE = os.environ.get("FORK_SIGNER_PUB", "tools/fork-signer.pub")
# README.md форка — НАШ файл целиком: авторское описание из него убрано, чтобы никто не
# ставил сервер авторской командой и не оказался на авторском канале обновлений. Шва здесь нет:
# при обновлении из авторского проекта README конфликтует и решается в нашу пользу (см. FORK.md).
HOOK = """
# --- 3X-UI_KIT (форк hacker07): локальные команды и хуки ---
# Подключаем scripts/kit-local.sh, если он установлен. Наш файл: правки автора его не касаются.
# Все команды форка — под «kit local …» (см. kit local help).
KIT_LOCAL_LIB="${KIT_LOCAL_LIB:-/usr/local/lib/kit/kit-local.sh}"
if [[ -r $KIT_LOCAL_LIB ]]; then . "$KIT_LOCAL_LIB"; kit_local_dispatch "$@"; fi
"""
def die(msg):
print("! " + msg, file=sys.stderr)
sys.exit(1)
def ok(msg):
print("==> " + msg)
def count(text, anchor, regex):
if regex:
return len(re.findall(anchor, text, re.MULTILINE))
return text.count(anchor)
def main():
args = [a for a in sys.argv[1:]]
check = False
if "--check" in args:
check = True
args.remove("--check")
root = pathlib.Path(args[0] if args else ".").resolve()
if not (root / "VERSION").exists():
die("%s не похож на репозиторий 3X-UI_autoVPS (нет VERSION)" % root)
os.chdir(root)
version = (root / "VERSION").read_text(encoding="utf-8").strip()
if not re.fullmatch(r"[0-9]+(\.[0-9]+)+", version):
die("VERSION=%r: нужен числовой номер вида 1.2.1" % version)
signer_path = pathlib.Path(SIGNER_FILE)
if not signer_path.exists():
die(
"нет %s — сначала создай ключ подписи:\n"
" ssh-keygen -t ed25519 -C \"3X-UI KIT fork releases\" -f ~/.ssh/3x-ui-kit-fork/release_key\n"
" mkdir -p tools && ssh-keygen -y -f ~/.ssh/3x-ui-kit-fork/release_key > tools/fork-signer.pub"
% SIGNER_FILE
)
pub = ""
for line in signer_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(("ssh-ed25519 ", "ssh-rsa ")):
pub = line
break
if not pub:
die("%s: не нашёл строку открытого ключа (ssh-ed25519 …)" % signer_path)
version_re = r'^KIT_VERSION="[0-9.]+"$'
edits = [
(
'scripts/kit.sh',
'# kit – управление сервером 3X-UI KIT: пользователи, обновление, резервная копия.\n',
False,
'# kit – управление сервером 3X-UI_autoVPS: пользователи, обновление, резервная копия.\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
'kit_ref_raw() { echo "https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v$1"; }\n',
False,
'kit_ref_raw() { echo "https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$1"; }\n\n# --- 3X-UI_KIT (форк hacker07): локальные команды и хуки ---\n# Подключаем scripts/kit-local.sh, если он установлен. Наш файл: правки автора его не касаются.\n# Все команды форка — под «kit local …» (см. kit local help).\nKIT_LOCAL_LIB="${KIT_LOCAL_LIB:-/usr/local/lib/kit/kit-local.sh}"\nif [[ -r $KIT_LOCAL_LIB ]]; then . "$KIT_LOCAL_LIB"; kit_local_dispatch "$@"; fi\n\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
'Description=3X-UI KIT: общий лимит трафика пользователей (сумма по всем записям)\n',
False,
'Description=3X-UI_autoVPS: общий лимит трафика пользователей (сумма по всем записям)\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
'Description=3X-UI KIT: проверка общего лимита трафика раз в 5 минут\n',
False,
'Description=3X-UI_autoVPS: проверка общего лимита трафика раз в 5 минут\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' # SHA256:bNjI10eGVGjdFeDJnAHvUFi73rlrXVy6vKFf8lmNyxU (itsnotkubrick, 2026-10-14; прежний ключ SHA256:VDuGgJ8d… утерян, снят)\n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEWDFGq2Wno/ADRS2E6UKsgFbUSLvaiPIfRBvSEGJr81 3X-UI KIT releases"\n',
False,
' # Наш шов: здесь только ключ форка (hacker07). Авторские ключи сняты намеренно — сервер обязан\n # принимать обновления лишь из нашего канала (git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS).\n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)"\n',
'авто-шов: replace, строк 2',
),
(
'scripts/kit.sh',
'update_hint() {\n',
False,
'update_hint() { # подсказка про новую версию\n [[ -d /etc/kit ]] || return 0 # kit удалён — подсказывать нечего (раньше тут сыпались ошибки про latest-version)\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' echo "3X-UI KIT $KIT_VERSION"\n',
False,
' echo "3X-UI_autoVPS $KIT_VERSION"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' curl -fsS -m 5 "$KIT_RAW/CHANGELOG.md" 2>/dev/null | awk -v v="## v$1" \'\n',
False,
' { curl -fsS -m 5 "$KIT_RAW/CHANGELOG.md" 2>/dev/null; curl -fsS -m 5 "$KIT_RAW/FORK-CHANGELOG.md" 2>/dev/null; } | awk -v v="## v$1" \'\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' grep -qx "# 3X-UI KIT $v" "$d/SHA256SUMS" || { warn "Подписанный релиз не той версии – ничего не ставлю."; return 1; }\n for f in scripts/kit.sh scripts/kit-sub.py; do\n',
False,
' grep -qxE "# (3X-UI KIT|3X-UI_autoVPS) $v" "$d/SHA256SUMS" || { warn "Подписанный релиз не той версии – ничего не ставлю."; return 1; }\n for f in scripts/kit.sh scripts/kit-sub.py scripts/kit-local.sh; do\n',
'авто-шов: replace, строк 2',
),
(
'scripts/kit.sh',
'Description=kit-sub: подписка с учётом приложения (3X-UI KIT)\n',
False,
'Description=kit-sub: подписка с учётом приложения (3X-UI_autoVPS)\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
'Description=3X-UI KIT: автообновление kit и kit-sub (только подписанные релизы)\n',
False,
'Description=3X-UI_autoVPS: автообновление kit и kit-sub (только подписанные релизы)\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
'Description=3X-UI KIT: проверка обновлений раз в сутки\n',
False,
'Description=3X-UI_autoVPS: проверка обновлений раз в сутки\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' if [[ $latest == "$KIT_VERSION" ]]; then say "Применяю исправления версии $latest"; else say "3X-UI KIT $KIT_VERSION → $latest"; fi\n',
False,
' if [[ $latest == "$KIT_VERSION" ]]; then say "Применяю исправления версии $latest"; else say "3X-UI_autoVPS $KIT_VERSION → $latest"; fi\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' install -m 755 "$tmp/kit.sh" /usr/local/bin/kit.new && mv -f /usr/local/bin/kit.new /usr/local/bin/kit\n',
False,
' install -m 755 "$tmp/kit.sh" /usr/local/bin/kit.new && mv -f /usr/local/bin/kit.new /usr/local/bin/kit\n # Форк: ставим наши локальные дополнения из того же подписанного релиза.\n install -d -m 755 /usr/local/lib/kit\n install -m 644 "$tmp/kit-local.sh" /usr/local/lib/kit/kit-local.sh\n',
'авто-шов: insert, строк 1',
),
(
'scripts/kit.sh',
' echo "${G}✓ Готово: 3X-UI KIT $latest.${N} Пользователи, ссылки и подписки не менялись."\n',
False,
' echo "${G}✓ Готово: 3X-UI_autoVPS $latest.${N} Пользователи, ссылки и подписки не менялись."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
'split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/<base64> с Name "3X-UI KIT"\n',
False,
'split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/<base64> с Name "3X-UI_autoVPS"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' grep -q \'"Name":"3X-UI KIT"\' <<<"$v"\n',
False,
' grep -q \'"Name":"3X-UI_autoVPS"\' <<<"$v"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' box "3X-UI KIT $KIT_VERSION – управление сервером" "1. Пользователи" "2. Протоколы, порты и маскировка" "3. Проверка сервера" "" \\\n',
False,
' box "3X-UI_autoVPS $KIT_VERSION – управление сервером" "1. Пользователи" "2. Протоколы, порты и маскировка" "3. Проверка сервера" "" \\\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' "7. Резервная копия" "8. Версии" "" "9. Панель 3X-UI (меню x-ui)" "10. Удалить KIT с сервера" "0. Выход"\n',
False,
' "7. Резервная копия" "8. Версии" "" "9. Доступ к панели, ссылки и заглушка" "10. Панель x-ui" "11. Удалить KIT с сервера" "0. Выход"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' 9) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 10) run_action cmd_uninstall; pause ;;\n',
False,
' # Наш шов: пункт 9 — доступ к панели, ссылки на подключения и состояние заглушки в одном месте.\n 9) kit local access; pause ;;\n 10) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 11) run_action cmd_uninstall; pause ;;\n',
'авто-шов: replace, строк 2',
),
(
'scripts/kit.sh',
' echo "${B}Полное удаление 3X-UI KIT${N}"\n',
False,
' echo "${B}Полное удаление 3X-UI_autoVPS${N}"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
'${B}3X-UI KIT $KIT_VERSION${N} – команды (то же самое есть в меню: просто ${B}kit${N})\n',
False,
'${B}3X-UI_autoVPS $KIT_VERSION${N} – команды (то же самое есть в меню: просто ${B}kit${N})\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
'KIT_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v$KIT_VERSION"\n',
False,
'KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$KIT_VERSION"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
'SNI_CANDIDATES=(dl.google.com www.amazon.com www.samsung.com www.yahoo.com)\n',
False,
'# Наш шов: добавлены известные сайты (Microsoft, AMD, Intel, NVIDIA) — их можно выбрать вручную.\nSNI_CANDIDATES=(dl.google.com www.amazon.com www.samsung.com www.yahoo.com www.microsoft.com www.amd.com www.intel.com www.nvidia.com)\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
" seq 1 254 | xargs -P 8 -I{} bash -c '\n",
False,
" # Наш шов: 32 потока вместо 8 и таймаут 3 с — скан подсети занимает секунды, а не минуту.\n seq 1 254 | xargs -P 32 -I{} bash -c '\n",
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' echo | timeout 4 openssl s_client -connect "$ip:443" -tls1_3 -alpn h2 2>/dev/null | openssl x509 -noout -ext subjectAltName 2>/dev/null \\\n | tr "," "\\n" | sed -n "s/^ *DNS://p"\' _ "$1.{}" "$me" 2>/dev/null | sort -u | grep -E "$SNI_RE" | head -40 || true\n',
False,
' echo | timeout 3 openssl s_client -connect "$ip:443" -tls1_3 -alpn h2 2>/dev/null | openssl x509 -noout -ext subjectAltName 2>/dev/null \\\n | tr "," "\\n" | sed -n "s/^ *DNS://p"\' _ "$1.{}" "$me" 2>/dev/null | sort -u | grep -E "$SNI_RE" | head -60 || true\n',
'авто-шов: replace, строк 2',
),
(
'scripts/3x-ui.sh',
' local me a b c base n names found=0\n',
False,
' local me a b c base n names out\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' [[ $me =~ ^[0-9]{1,3}(\\.[0-9]{1,3}){3}$ ]] || return 0\n IFS=. read -r a b c _ <<<"$me"\n',
False,
' [[ $me =~ ^[0-9]{1,3}(\\.[0-9]{1,3}){3}$ ]] || return 0\n IFS=. read -r a b c _ <<<"$me"\n # Наш шов: имена проверяются параллельно (8 сразу) — раньше по очереди, и это была основная\n # причина «долго сканирует». Функции передаём в подпроцессы через export -f.\n export -f same_net sni_alive sni_trusted sni_quality sni_ok host_ip\n export HOST\n',
'авто-шов: insert, строк 2',
),
(
'scripts/3x-ui.sh',
' names=$(nearby_scan "$base")\n for n in $names; do\n [[ $n == "$me" || $n =~ $SNI_DYN_RE || $n =~ $SNI_BRAND_RE || $n =~ $SNI_BAD_RE ]] && continue\n if same_net "$n" && sni_alive "$n" && sni_trusted "$n" && sni_quality "$n"; then echo "$n"; found=$((found + 1)); ((found >= 8)) && return 0; fi\n done\n ((found > 0)) && return 0\n',
False,
' names=$(nearby_scan "$base" | grep -vE "^(${me})$" || true)\n [[ -n $names ]] || continue\n out=$(while IFS= read -r n; do\n [[ $n =~ $SNI_DYN_RE || $n =~ $SNI_BRAND_RE || $n =~ $SNI_BAD_RE ]] && continue\n printf \'%s\\n\' "$n"\n done <<<"$names" | xargs -P 8 -I{} bash -c \'same_net "$1" && sni_alive "$1" && sni_trusted "$1" && sni_quality "$1" && printf "%s\\n" "$1"\' _ {} 2>/dev/null | head -8)\n [[ -n $out ]] && { printf \'%s\\n\' "$out"; return 0; }\n',
'авто-шов: replace, строк 6',
),
(
'scripts/3x-ui.sh',
' [[ -x $acme ]] || { warn "Не нашёл acme.sh, которым установщик 3X-UI получает сертификаты: не могу выпустить сертификат для $DOMAIN."; return 1; }\n',
False,
' [[ -x $acme ]] || { warn "Не нашёл acme.sh, которым установщик 3X-UI получает сертификаты: не могу выпустить сертификат для $DOMAIN."; return 1; }\n # Наш шов: лимит Let\'s Encrypt считается на точный набор имён (5 сертификатов за 7 суток). Если у acme.sh\n # уже лежит годный сертификат на этот домен — берём его и не заказываем новый, лишний заказ упирается в лимит.\n local need_issue=yes\n if [[ -s /root/.acme.sh/${DOMAIN}_ecc/fullchain.cer ]] \\\n && openssl x509 -in "/root/.acme.sh/${DOMAIN}_ecc/fullchain.cer" -noout -checkhost "$DOMAIN" 2>/dev/null | grep -q \'does match\' \\\n && openssl x509 -in "/root/.acme.sh/${DOMAIN}_ecc/fullchain.cer" -noout -checkend 86400 >/dev/null 2>&1; then\n ok "у acme.sh уже есть годный сертификат для ${B}$DOMAIN${N} — беру его, новый не заказываю"\n need_issue=no\n fi\n',
'авто-шов: insert, строк 1',
),
(
'scripts/3x-ui.sh',
' [[ $rc == 0 || $rc == 2 ]] || { warn "Let\'s Encrypt не выдал сертификат для $DOMAIN. Обычно дело в одном из трёх: A-запись ещё не обновилась, порт 80 закрыт у хостера или домен за проксированием Cloudflare. Лог: $log"; return 1; }\n',
False,
' [[ $rc == 0 || $rc == 2 ]] || {\n # Наш шов: лимит Let\'s Encrypt — частая причина, и она не про сервер. Говорим прямо и с датой повтора.\n if grep -qE \'rateLimited|too many certificates\' "$log"; then\n local till\n till=$(grep -oE \'retry after [0-9-]+ [0-9:]+ UTC\' "$log" | tail -1 | sed \'s/^retry after //\')\n warn "Лимит Let\'s Encrypt: на имя ${B}$DOMAIN${N} за 7 суток уже выпущено 5 сертификатов${till:+ — повтор возможен после $till}. С вашим сервером всё в порядке: DNS, порты и ufw не при чём. Подождите до этой даты либо возьмите другое имя (например node3.…): лимит считается на точный набор имён."\n else\n warn "Let\'s Encrypt не выдал сертификат для $DOMAIN. Обычно дело в одном из трёх: A-запись ещё не обновилась, порт 80 закрыт у хостера или домен за проксированием Cloudflare. Лог: $log"\n fi\n return 1\n }\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' local san="IP:$HOST"\n [[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$HOST"\n',
False,
' local san="IP:$HOST"\n [[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$HOST"\n # Наш шов: если причина — лимит Let\'s Encrypt, говорим это прямо и с датой повтора, как для домена.\n # Иначе человек читает «частые причины» и ищет проблему у хостера, которой нет.\n local till\n till=$(grep -rhoE \'retry after [0-9-]+ [0-9:]+ UTC\' /var/log/3x-ui-install.log "$HOME"/.acme.sh/*/ca 2>/dev/null | tail -1 | sed \'s/^retry after //\')\n if grep -rqsE \'rateLimited|too many certificates\' /var/log/3x-ui-install.log 2>/dev/null; then\n warn "Причина: лимит Let\'s Encrypt — на этот IP за 7 суток уже выпущено 5 сертификатов${till:+; повтор возможен после $till}. Это ограничение Let\'s Encrypt, а не вашего сервера: порт 80 и файрвол тут не при чём."\n else\n',
'авто-шов: insert, строк 2',
),
(
'scripts/3x-ui.sh',
' echo " – сбой у самого Let\'s Encrypt (лог: /var/log/3x-ui-install.log)."\n',
False,
' echo " – сбой у самого Let\'s Encrypt (лог: /var/log/3x-ui-install.log)."\n } >&2\n fi\n {\n',
'авто-шов: insert, строк 1',
),
(
'scripts/3x-ui.sh',
" _ _ _ _ _ _ _\n (_) |_ ___ _ __ ___ | |_| | ___ _| |__ _ __(_) ___| | __\n | | __/ __| '_ \\ / _ \\| __| |/ / | | | '_ \\| '__| |/ __| |/ /\n | | |_\\__ \\ | | | (_) | |_| <| |_| | |_) | | | | (__| <\n |_|\\__|___/_| |_|\\___/ \\__|_|\\_\\\\__,_|_.__/|_| |_|\\___|_|\\_\\\n",
False,
" _ _ _ ___ _____\n | | | | __ _ ___| | _____ _ __ / _ \\___ |\n | |_| |/ _` |/ __| |/ / _ \\ '__| | | | | / /\n | _ | (_| | (__| < __/ | | |_| |/ /\n |_| |_|\\__,_|\\___|_|\\_\\___|_| \\___//_/\n",
'авто-шов: replace, строк 5',
),
(
'scripts/3x-ui.sh',
' echo "${B}3X-UI KIT $KIT_VERSION${N} · it\'s not Kubrick. it\'s just a VPN."\n',
False,
' echo "${B}3X-UI_autoVPS $KIT_VERSION${N} · сборка форка: панель 3X-UI и VPN-протоколы на одном порту"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP."\n fi\n\n',
False,
' later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP."\n fi\n\n# ---------------------------------------------------------------------------\n# Наш шов: выбор сайта маскировки по результатам скана подсети.\n# Печатает выбранное имя в stdout (пусто — оставляем автоподбор автора).\n# role — подпись для человека: REALITY или XHTTP/MTProto.\nkl_sni_menu() {\n local role=$1; shift\n local -a found=("$@")\n local -a known=("${SNI_CANDIDATES[@]}")\n local src=${KL_ASK_SRC:-/dev/tty} ans i k n auto_idx known_idx own_idx try\n n=${#found[@]}\n printf \'\\n%s%s%s\\n\' "$B" "$role" "$N" >&2\n if ((n)); then\n printf \'Найдено в подсети:\\n\\n\' >&2\n for i in "${!found[@]}"; do printf \' %d - %s\\n\' "$((i + 1))" "${found[i]}" >&2; done\n auto_idx=$((n + 1)); known_idx=$((n + 2)); own_idx=$((n + 3))\n printf \' %d - автовыбор из списка\\n\' "$auto_idx" >&2\n printf \' %d - выбрать общедоступный (например %s, %s)\\n\' "$known_idx" "${known[0]}" "${known[1]}" >&2\n printf \' %d - ввести свой домен\\n\\n\' "$own_idx" >&2\n else\n auto_idx=0; known_idx=1; own_idx=2\n printf \'В подсети доменов не найдено, выберите:\\n\\n\' >&2\n printf \' 1 - общедоступный (например %s, %s)\\n\' "${known[0]}" "${known[1]}" >&2\n printf \' 2 - ввести свой домен\\n\\n\' >&2\n fi\n printf \'Ваш выбор (Enter — автовыбор): \' >&2\n for try in 1 2 3; do\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n [[ -z $ans ]] && return 0\n [[ $ans =~ ^[0-9]+$ ]] || { printf \' введите номер из списка\\n\' >&2; continue; }\n i=$((10#$ans))\n if ((n)) && ((i >= 1 && i <= n)); then printf \'sni:%s\' "${found[i - 1]}"; return 0; fi\n ((i == auto_idx)) && return 0\n if ((i == known_idx)); then\n printf \'\\nОбщедоступные сайты:\\n\' >&2\n for k in "${!known[@]}"; do printf \' %d - %s\\n\' "$((k + 1))" "${known[k]}" >&2; done\n # Наш шов: последним пунктом — автовыбор из этого списка (раньше выбор был только по номеру).\n printf \' %d - автовыбор из списка\\n\' "$((${#known[@]} + 1))" >&2\n printf \' 0 - назад\\nВаш выбор: \' >&2\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans >= 1 && 10#$ans <= ${#known[@]})); then\n printf \'sni:%s\' "${known[10#$ans - 1]}"; return 0\n fi\n if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans == ${#known[@]} + 1)); then\n local autopick="${known[RANDOM % ${#known[@]}]}"\n printf \' автовыбор: %s\\n\' "$autopick" >&2\n printf \'sni:%s\' "$autopick"; return 0\n fi\n printf \' ничего не выбрано — оставляю автовыбор\\n\' >&2\n return 0\n fi\n if ((i == own_idx)); then\n printf \' адрес сайта (например vpn.example.com): \' >&2\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n [[ $ans =~ ^[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$ ]] && { printf \'own:%s\' "$ans"; return 0; }\n printf \' не похоже на адрес сайта — оставляю автовыбор\\n\' >&2\n return 0\n fi\n printf \' такого номера нет\\n\' >&2\n done\n return 0\n}\n\n',
'авто-шов: insert, строк 3',
),
(
'scripts/3x-ui.sh',
' elif [[ -z $SNI && $yes == no && $PANEL_SSL == ip && $multi == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
False,
' # Наш шов: когда выбор сайта делает наше меню (KL_PICK_SNI=1), меню автора не показываем — иначе\n # человек отвечает на «Свой домен / Сосед / Свой сайт», и до скана подсети дело не доходит.\n elif [[ -z $SNI && $yes == no && $PANEL_SSL == ip && $multi == no && -t 1 && ${KL_PICK_SNI:-0} != 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' echo "Подбираю сайт для маскировки среди соседей по подсети… (около 250 коротких обращений к порту 443 соседних адресов, около минуты; не нужно – запустите с --sni или --domain)"\n',
False,
' echo "Сканирую подсеть: около 250 коротких обращений к порту 443 соседних адресов (около минуты)."\n echo "По результатам предложу сайты для маскировки. Не нужно – запустите с --sni или --domain."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' if ((${#nb[@]})); then\n SNI=${nb[0]}; SNI2=${nb[1]:-}; SNI3=${nb[2]:-}\n',
False,
' # Наш шов: показываем найденное и даём выбрать — отдельно для REALITY и для XHTTP/MTProto.\n if [[ ${KL_PICK_SNI:-} == 1 && $yes == no ]] && { : </dev/tty; } 2>/dev/null; then\n local pick pick2 own_sni=no\n pick=$(kl_sni_menu "REALITY (TCP 443) — под какой сайт маскировать" ${nb[@]+"${nb[@]}"})\n case $pick in\n own:*)\n own_sni=yes\n DOMAIN=${pick#own:}\n # Наш шов: домен вводится заново, пока он не указывает на этот сервер. Раньше при промахе\n # установка молча маскировалась под чужой сайт, а при домене из флага — обрывалась.\n # A-запись у регистратора расходится не мгновенно, поэтому первым пунктом — «проверить снова».\n while :; do\n if domain_points_here "$DOMAIN"; then\n SNI=$DOMAIN\n ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let\'s Encrypt выпустит установщик)"\n # Наш шов: спрашиваем, где открывать панель и подписку, и при интерактивном вводе домена тоже.\n # У автора этот вопрос задавался только при домене из флага --domain, а при выборе «ввести свой\n # домен» в меню маскировки — нет, и панель молча оставалась по IP с самоподписанным сертификатом.\n panel_on_choice\n break\n fi\n echo " Домен ${B}$DOMAIN${N} пока не указывает на этот сервер." >&2\n echo " ${B}1${N} проверить снова A-запись расходится по DNS от нескольких минут до нескольких часов" >&2\n echo " ${B}2${N} ввести другой домен" >&2\n echo " ${B}3${N} без своего домена: маскировка соседом по подсети" >&2\n ask_tty " Выбор [1]: " || break\n case ${REPLY//[[:space:]]/} in\n 2) ask_tty " адрес сайта (например vpn.example.com): " || break\n local d=${REPLY//[[:space:]]/}\n [[ -n $d ]] && DOMAIN=$d ;;\n 3) own_sni=no; DOMAIN=""; break ;;\n *) sleep 5 ;;\n esac\n done\n ;;\n sni:*) SNI=${pick#sni:} ;;\n esac\n if [[ -n $SNI ]]; then\n if [[ $own_sni == no ]] && ! sni_ok "$SNI"; then\n warn "$SNI не отвечает по TLS 1.3 + HTTP/2 — беру найденное в подсети"\n SNI="${nb[0]:-$SNI}"\n fi\n ok "маскировка REALITY: ${B}$SNI${N}"\n fi\n if [[ " ${PROTOS[*]} " == *" xhttp "* || " ${PROTOS[*]} " == *" mtproto "* ]]; then\n pick2=$(kl_sni_menu "XHTTP и MTProto (различаются по имени сайта) — какой сайт взять" ${nb[@]+"${nb[@]}"})\n [[ $pick2 == sni:* ]] && pick2=${pick2#sni:}\n [[ $pick2 == own:* ]] && pick2=${pick2#own:}\n [[ -n $pick2 ]] && SNI2=$pick2\n [[ -n $SNI2 ]] && ok "маскировка XHTTP/MTProto: ${B}$SNI2${N}"\n fi\n fi\n if ((${#nb[@]})); then ok "подходящих соседей найдено: ${#nb[@]}"\n else warn "в подсети подходящих сайтов не нашлось (адреса проверены, откликов нет)"; fi\n if [[ -z $SNI ]] && ((${#nb[@]})); then\n SNI=${nb[0]}; SNI2=${SNI2:-${nb[1]:-}}; SNI3=${nb[2]:-}\n',
'авто-шов: replace, строк 2',
),
(
'scripts/3x-ui.sh',
' ok "найден: ${B}$SNI${N} · та же подсеть · TLS 1.3, h2, сертификат верный"\n else\n',
False,
' ok "найден: ${B}$SNI${N} · та же подсеть · TLS 1.3, h2, сертификат верный"\n elif [[ -z $SNI ]]; then\n',
'авто-шов: replace, строк 2',
),
(
'scripts/3x-ui.sh',
' if [[ $yes == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
False,
' if [[ -n ${KL_PICK_SNI:-} && ${KL_PICK_SNI} == 1 && -z $SNI ]]; then\n : # сайт уже выбран в нашем меню — вопрос автора не нужен\n elif [[ $yes == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' if [[ $TRUSTED == yes && $multi == no && ( -z $SNI2 || -z $SNI3 ) ]]; then\n',
False,
' # Наш шов: если XHTTP и MTProto не выбраны, имена для них сейчас не нужны — не сканируем соседей\n # по подсети впустую (около 250 обращений к чужим 443). Имена SNI2/SNI3 возьмутся из общего списка ниже.\n if [[ $TRUSTED == yes && $multi == no && ( -z $SNI2 || -z $SNI3 ) ]] \\\n && [[ " ${PROTOS[*]} " == *" xhttp "* || " ${PROTOS[*]} " == *" mtproto "* ]]; then\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' issue_domain_cert || die "Без сертификата для $DOMAIN продолжать нельзя. Исправьте причину и запустите скрипт снова."\n',
False,
' # Наш шов: провал сертификата не обрывает установку. Человек может повторить попытку (DNS доехал,\n # лимит Let\'s Encrypt кончился) или ввести другое имя — установка продолжится с него же.\n while ! issue_domain_cert; do\n echo\n echo " ${B}Что делать дальше?${N}"\n echo " ${B}1${N} повторить попытку если ждали обновления A-записи или лимита Let\'s Encrypt"\n echo " ${B}2${N} ввести другой домен проверим, что он указывает на этот сервер, и повторим"\n echo " ${B}3${N} остановиться поставить без своего домена: перезапустите и выберите соседа по подсети"\n ask_tty " Выбор [1]: " || die "Сертификат для $DOMAIN не выпущен. Подробности: /var/log/kit-domain-cert.log"\n case ${REPLY//[[:space:]]/} in\n 2) ask_tty " адрес сайта (например vpn.example.com): " || continue\n local d=${REPLY//[[:space:]]/}\n [[ -n $d ]] || continue\n if domain_points_here "$d"; then DOMAIN=$d; SNI=$DOMAIN\n else warn "оставляю прежний домен $DOMAIN — новый не указывает на этот сервер"; fi ;;\n 3) # Наш шов: продолжаем без своего домена прямо здесь — маскировка соседом по подсети\n # (или известным сайтом), панель и подписка по IP. Перезапускать установку не нужно.\n local nb2\n nb2=$(nearby_sites 2>/dev/null | shuf | head -1)\n [[ -n $nb2 ]] || nb2=dl.google.com\n DOMAIN=""; PANEL_ON=ip; LINK_HOST=$HOST\n SNI=$nb2; SNI2=${SNI2:-$nb2}\n ok "продолжаю без своего домена: маскировка ${B}$SNI${N}, панель и подписка — по IP"\n break ;;\n *) sleep 5 ;;\n esac\n done\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' ok "подключения работают: REALITY, XHTTP и Hysteria2 проверены клиентом с самого сервера"\n',
False,
' ok "подключения работают (проверено клиентом с самого сервера): ${CREATED[*]:-нет}"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' local extra\n extra=$(sub_links "$SUBID-awg" 1; sub_links "$SUBID-tg" 1)\n',
False,
' local extra="" # пустая строка, иначе set -u валит скрипт, когда AWG/MTProto не выбраны\n # Наш шов: подписки «-awg»/«-tg» есть только у установок с AmneziaWG и MTProto — не тянем их,\n # если эти протоколы не выбраны (никаких лишних обращений к панели).\n if [[ " ${PROTOS[*]} " == *" awg "* || " ${PROTOS[*]} " == *" awg3 "* || " ${PROTOS[*]} " == *" mtproto "* ]]; then\n extra=$(sub_links "$SUBID-awg" 1; sub_links "$SUBID-tg" 1)\n fi\n',
'авто-шов: replace, строк 2',
),
(
'scripts/3x-ui.sh',
' echo "3X-UI KIT (3X-UI $XUI_VERSION) – данные для входа (файл виден только root)"\n',
False,
' echo "3X-UI_autoVPS (3X-UI $XUI_VERSION) – данные для входа (файл виден только root)"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' } >"$RESULT"\n\n echo\n echo "${G}${B}Готово! Сервер работает.${N}"\n [[ -n $DOMAIN ]] && echo "Маскировка: свой домен ${B}$DOMAIN${N}, сертификат Let\'s Encrypt продлевается сам."\n [[ $SELF_IP_CERT == yes ]] && later "Сертификат на IP самоподписанный: пользуйтесь ссылками на отдельные подключения, подписка в приложениях может не открыться."\n',
False,
' } >"$RESULT"\n\n echo\n',
'авто-шов: delete, строк 6',
),
(
'scripts/3x-ui.sh',
' echo "${B}ПАНЕЛЬ${N}"\n if [[ $TRUSTED == yes ]]; then\n echo " $panel_url"\n else\n echo " Только через SSH-туннель. На вашем компьютере:"\n echo " ssh -L $XUI_PANEL_PORT:127.0.0.1:$XUI_PANEL_PORT root@$HOST"\n echo " Затем в браузере: http://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH"\n fi\n echo " Логин: ${B}$XUI_USERNAME${N} Пароль: ${B}$XUI_PASSWORD${N}"\n echo " ${D}(сохранено в $RESULT, файл виден только root)${N}"\n echo\n if [[ $TRUSTED == yes ]]; then\n echo "${B}ПОДПИСКА${N} – одна ссылка для всех протоколов"\n echo " ${B}$SUB_URL${N}"\n echo " Вставьте в Happ, Hiddify, Karing, v2rayN, Clash Verge или FlClash."\n qrencode -t ANSIUTF8 -m 1 "$SUB_URL" || true\n else\n /usr/local/bin/kit __links "$NAME" "$SUBID" || true\n fi\n local -a tp=(\n "Управление сервером: введите команду ${B}kit${N} – откроется меню. Запоминать команды не нужно."\n "Ссылки на отдельные протоколы (REALITY, Hysteria2, Telegram и другие): kit → Пользователи → Показать ссылки."\n "Добавить пользователя: kit → Пользователи → Добавить."\n "Резервная копия: kit → Резервная копия. Храните её как пароль.")\n tips "Что дальше" "${tp[@]}"\n',
False,
' echo "${G}${B}Установка завершена.${N} Доступы к панели, подписка и QR-код — в самом конце вывода."\n',
'авто-шов: replace, строк 25',
),
(
'scripts/3x-ui.sh',
'KIT_CLI_URL="$KIT_RAW/scripts/kit.sh"\n',
False,
'KIT_CLI_URL="$KIT_RAW/scripts/kit.sh"\nKIT_LOCAL_URL="$KIT_RAW/scripts/kit-local.sh"\n',
'авто-шов: insert, строк 1',
),
(
'scripts/3x-ui.sh',
' else curl -fsSL --retry 3 -o /usr/local/bin/kit "$KIT_CLI_URL" && chmod 755 /usr/local/bin/kit; fi\n',
False,
' else curl -fsSL --retry 3 -o /usr/local/bin/kit "$KIT_CLI_URL" && chmod 755 /usr/local/bin/kit; fi\n # Форк: ставим наши локальные дополнения (см. scripts/kit-local.sh).\n install -d -m 755 /usr/local/lib/kit\n if [[ -n $src && -f $d/kit-local.sh ]]; then\n install -m 644 "$d/kit-local.sh" /usr/local/lib/kit/kit-local.sh\n else\n curl -fsSL --retry 3 -o /usr/local/lib/kit/kit-local.sh "$KIT_LOCAL_URL" \\\n || warn "Локальные дополнения kit не скачались: kit local не будет работать"\n fi\n',
'авто-шов: insert, строк 1',
),
(
'scripts/3x-ui.sh',
'KIT_INSTALL_CMD="bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh)"\n',
False,
'KIT_INSTALL_CMD="bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui.sh)"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' echo "# Сгенерировано 3x-ui.sh (3X-UI KIT) – перезаписывается при повторном запуске."\n',
False,
' echo "# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
'# Сгенерировано 3x-ui.sh (3X-UI KIT) – перезаписывается при повторном запуске.\n',
False,
'# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске.\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' upd=$(jq -c --arg path "$SUB_PATH" --argjson ip "$SUB_INTERNAL" --arg title "3X-UI KIT" --arg uri "$uri" \'\n',
False,
' upd=$(jq -c --arg path "$SUB_PATH" --argjson ip "$SUB_INTERNAL" --arg title "3X-UI_autoVPS" --arg uri "$uri" \'\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' upd=$(jq -c --arg path "$SUB_PATH" --arg title "3X-UI KIT" \'\n',
False,
' upd=$(jq -c --arg path "$SUB_PATH" --arg title "3X-UI_autoVPS" \'\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
'Description=kit-sub: подписка с учётом приложения (3X-UI KIT)\n',
False,
'Description=kit-sub: подписка с учётом приложения (3X-UI_autoVPS)\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' [[ $(LC_ALL=C tr -d \'\\n -~\' <"$f" | wc -c) -eq 0 ]] \\\n || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
False,
' [[ $(LC_ALL=C tr -d \'\\n -~\' <"$f" | wc -c) -eq 0 ]] \\\n || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 2',
),
(
'scripts/3x-ui.sh',
' [[ $rc -eq 1 ]] \\\n || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
False,
' [[ $rc -eq 1 ]] \\\n || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 2',
),
(
'scripts/3x-ui.sh',
' [[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI KIT или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял."\n',
False,
' [[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' *) die "Это не резервная копия 3X-UI KIT или она повреждена (имя $n в ${f##*/}). Ничего не менял." ;;\n',
False,
' *) die "Это не резервная копия 3X-UI_autoVPS или она повреждена (имя $n в ${f##*/}). Ничего не менял." ;;\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' || die "Это не резервная копия 3X-UI KIT или она повреждена (значение $n в ${f##*/}). Ничего не менял."\n',
False,
' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (значение $n в ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' python3 - "$file" "$tmp" <<\'PY\' || die "Это не резервная копия 3X-UI KIT или она повреждена. Ничего не менял."\n',
False,
' python3 - "$file" "$tmp" <<\'PY\' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена. Ничего не менял."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' || die "Это не резервная копия 3X-UI KIT или она повреждена (порт панели). Ничего не менял."\n',
False,
' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (порт панели). Ничего не менял."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' [[ $BACKUP_HOST =~ ^[A-Za-z0-9.:-]+$ ]] || die "Это не резервная копия 3X-UI KIT или она повреждена (адрес сервера). Ничего не менял."\n',
False,
' [[ $BACKUP_HOST =~ ^[A-Za-z0-9.:-]+$ ]] || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (адрес сервера). Ничего не менял."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' || die "Это не резервная копия 3X-UI KIT или она повреждена (нет своего сертификата). Ничего не менял."\n',
False,
' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (нет своего сертификата). Ничего не менял."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/3x-ui.sh',
' [[ -n $DOMAIN ]] && { issue_domain_cert || die "Не удалось получить сертификат для $DOMAIN: порт 80 должен быть свободен, а A-запись домена указывать на этот сервер. Лог: /var/log/kit-domain-cert.log"; }\n',
False,
' # Наш шов: открываем 80 в ufw ПРЯМО ПЕРЕД выпуском сертификата. Установщик открывает список портов\n # позже (после сборки nginx), поэтому на свежем сервере проверка Let\'s Encrypt доходила до сервера и\n # «проглатывалась» — в логе acme это «Timeout during connect (likely firewall problem)».\n command -v ufw >/dev/null 2>&1 && ufw allow 80/tcp >/dev/null 2>&1 || true\n [[ -n $DOMAIN ]] && { issue_domain_cert || die "Без сертификата для $DOMAIN продолжать нельзя — причина выше, подробности в /var/log/kit-domain-cert.log"; }\n',
'авто-шов: replace, строк 1',
),
(
'scripts/hysteria2.sh',
'# установку 3X-UI KIT (scripts/3x-ui.sh). Файл оставлен, чтобы старая команда `hy2 update`\n',
False,
'# установку 3X-UI_autoVPS (scripts/3x-ui.sh). Файл оставлен, чтобы старая команда `hy2 update`\n',
'авто-шов: replace, строк 1',
),
(
'scripts/hysteria2.sh',
'Hysteria2 входит в основную установку 3X-UI KIT (один из 11 протоколов).\n',
False,
'Hysteria2 входит в основную установку 3X-UI_autoVPS (один из 11 протоколов).\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit-sub.py',
' data = {"Name": "3X-UI KIT", "GlobalProxy": "false", "DomainStrategy": "IPIfNonMatch", "ProxySites": sites,\n',
False,
' data = {"Name": "3X-UI_autoVPS", "GlobalProxy": "false", "DomainStrategy": "IPIfNonMatch", "ProxySites": sites,\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit-sub.py',
' data = {"Name": "3X-UI KIT", "DomainStrategy": "IPIfNonMatch", "RouteOrder": "block-proxy-direct",\n',
False,
' data = {"Name": "3X-UI_autoVPS", "DomainStrategy": "IPIfNonMatch", "RouteOrder": "block-proxy-direct",\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit-sub.py',
' main()\n',
False,
' main()\n\n',
'авто-шов: insert, строк 1',
),
(
'scripts/kit.sh',
version_re,
True,
f'KIT_VERSION="{version}"',
'версия сборки форка',
),
(
'scripts/3x-ui.sh',
version_re,
True,
f'KIT_VERSION="{version}"',
'версия сборки форка',
),
(
'scripts/hysteria2.sh',
version_re,
True,
f'KIT_VERSION="{version}"',
'версия сборки форка',
),
(
'scripts/kit.sh',
'KIT_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main"',
False,
'KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main"',
'канал обновлений: наш Gitea вместо GitHub',
),
]
bad = 0
for path, anchor, is_re, new, why in edits:
p = pathlib.Path(path)
if not p.exists():
print("✗ %s: файла нет" % path)
bad += 1
continue
text = p.read_text(encoding="utf-8", newline="")
if check:
# --check смотрит на готовое дерево dev: наш шов обязан быть на месте и ровно один раз
n = text.count(new)
what, shown = "наш шов", new
else:
# применение — только на чистое дерево автора: якорь обязан найтись ровно один раз
n = count(text, anchor, is_re)
what, shown = "якорь автора", anchor
short = shown if len(shown) <= 54 else shown[:51] + "…"
short = short.replace("\n", "⏎")
if n != 1:
print("✗ %-20s %-56s %s найден %d раз (%s)" % (path, short, what, n, why))
bad += 1
continue
if check:
print("✓ %-20s %-56s (%s)" % (path, short, why))
continue
if is_re:
text = re.sub(anchor, new, text, count=1, flags=re.MULTILINE)
else:
text = text.replace(anchor, new, 1)
p.write_text(text, encoding="utf-8", newline="")
print("✓ %-20s %-56s (%s)" % (path, short, why))
# Массовая замена бренда в файлах автора: строки, которые видит человек (справка, версия,
# описания служб, баннер). Строку проверки шапки SHA256SUMS не трогаем — её правит отдельный шов,
# а сама шапка ещё один релиз пишется в старом виде (иначе уже установленные серверы откажутся
# принимать обновление: они сверяют точный вид строки).
globals_ = [
("scripts/kit.sh", "3X-UI KIT", "3X-UI_autoVPS"),
("scripts/3x-ui.sh", "3X-UI KIT", "3X-UI_autoVPS"),
("scripts/hysteria2.sh", "3X-UI KIT", "3X-UI_autoVPS"),
("scripts/kit-sub.py", "3X-UI KIT", "3X-UI_autoVPS"),
]
for path, old, new in globals_:
p = pathlib.Path(path)
if not p.exists():
print("✗ %s: файла нет" % path)
bad += 1
continue
text = p.read_text(encoding="utf-8", newline="")
# строки, где старое имя — часть служебной сверки, не переименовываем
keep = lambda line: "grep -qx" in line
if check:
if new not in text:
print("✗ %-20s нет нашего имени «%s»" % (path, new))
bad += 1
continue
left = [l for l in text.splitlines() if old in l and not keep(l)]
if left:
print("✗ %-20s осталось старое имя в %d строк(ах)" % (path, len(left)))
bad += 1
continue
print("✓ %-20s бренд: %s" % (path, new))
continue
out = []
changed = 0
for line in text.splitlines(keepends=True):
if old in line and not keep(line):
out.append(line.replace(old, new))
changed += line.count(old)
else:
out.append(line)
if not changed:
if new in text:
print("– %-20s уже переименовано" % path)
continue
print("✗ %-20s не нашёл «%s»" % (path, old))
bad += 1
continue
p.write_text("".join(out), encoding="utf-8", newline="")
print("✓ %-20s переименовано вхождений: %d" % (path, changed))
if bad:
if check:
die("якорей не найдено: %d — автор изменил код, пересмотри правки в tools/fork_apply.py" % bad)
die("якорей не найдено: %d — правки не применены" % bad)
if check:
ok("все якоря на месте (версия сборки %s)" % version)
else:
ok("швы накатаны, версия сборки %s" % version)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -7,7 +7,7 @@
// Сервисы: какие наборы правил отправлять через прокси. // Сервисы: какие наборы правил отправлять через прокси.
const SERVICES = [ const SERVICES = [
{ id: 'blocked', group: 'Заблокированное', label: 'Заблокированное в России', hint: 'список re:filter – основные заблокированные сайты', on: true, { id: 'blocked', group: 'Re:filter', label: 'Сайты из списка re:filter', hint: 'список re:filter – основные сайты', on: true,
sets: [['refilter@domain', REFILTER + 'domain-rule.mrs', 'domain'], ['refilter@ipcidr', REFILTER + 'ip-rule.mrs', 'ipcidr']] }, sets: [['refilter@domain', REFILTER + 'domain-rule.mrs', 'domain'], ['refilter@ipcidr', REFILTER + 'ip-rule.mrs', 'ipcidr']] },
{ id: 'youtube', label: 'YouTube', on: true, sets: [['youtube@domain', META + 'geosite/youtube.mrs', 'domain']] }, { id: 'youtube', label: 'YouTube', on: true, sets: [['youtube@domain', META + 'geosite/youtube.mrs', 'domain']] },
{ id: 'telegram', label: 'Telegram', on: true, { id: 'telegram', label: 'Telegram', on: true,
+2 -2
View File
@@ -83,12 +83,12 @@
const BASES = { const BASES = {
zkeen: { label: 'ZKeen', hint: 'ставится XKeen по умолчанию, рекомендуется', site: 'zkeen.dat', ip: 'zkeenip.dat' }, zkeen: { label: 'ZKeen', hint: 'ставится XKeen по умолчанию, рекомендуется', site: 'zkeen.dat', ip: 'zkeenip.dat' },
v2fly: { label: 'V2Fly', hint: 'нужна для отдельных сервисов и блокировки рекламы', site: 'geosite_v2fly.dat', ip: 'geoip_v2fly.dat' }, v2fly: { label: 'V2Fly', hint: 'нужна для отдельных сервисов и блокировки рекламы', site: 'geosite_v2fly.dat', ip: 'geoip_v2fly.dat' },
refilter: { label: 'Re:filter', hint: 'большой список заблокированного', site: 'geosite_refilter.dat', ip: 'geoip_refilter.dat' }, refilter: { label: 'Re:filter', hint: 'большой список сайтов', site: 'geosite_refilter.dat', ip: 'geoip_refilter.dat' },
}; };
// Сервис → правила по базам. d: домены, i: IP. Берутся все доступные базы. // Сервис → правила по базам. d: домены, i: IP. Берутся все доступные базы.
const SERVICES = [ const SERVICES = [
{ id: 'blocked', label: 'Заблокированное в России', on: true, { id: 'blocked', label: 'Сайты из списка re:filter', on: true,
zkeen: { d: ['domains', 'other', 'politic'] }, refilter: { d: ['refilter'], i: ['refilter'] } }, zkeen: { d: ['domains', 'other', 'politic'] }, refilter: { d: ['refilter'], i: ['refilter'] } },
{ id: 'youtube', label: 'YouTube', on: true, zkeen: { d: ['youtube'], i: ['youtube'] }, v2fly: { d: ['youtube'] } }, { id: 'youtube', label: 'YouTube', on: true, zkeen: { d: ['youtube'], i: ['youtube'] }, v2fly: { d: ['youtube'] } },
{ id: 'telegram', label: 'Telegram', on: true, zkeen: { i: ['telegram'] }, v2fly: { d: ['telegram'] } }, { id: 'telegram', label: 'Telegram', on: true, zkeen: { i: ['telegram'] }, v2fly: { d: ['telegram'] } },
+81 -12
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Подпись релиза 3X-UI KIT. Запускает только автор, на своём компьютере, перед тегом: # Подпись релиза 3X-UI_autoVPS. Запускает только автор, на своём компьютере, перед тегом:
# #
# tools/release.sh [~/.ssh/3xui_kit_release] # tools/release.sh [~/.ssh/3xui_kit_release]
# #
# Проверяет, что версии сходятся и установщик 3X-UI не подменили, пишет SHA256SUMS, # Проверяет, что версии сходятся и установщик 3X-UI не подменили, пишет SHA256SUMS,
# подписывает его закрытым ключом (ssh-keygen спросит пароль ключа) и сразу проверяет # подписывает его закрытым ключом (ssh-keygen спросит пароль ключа) и сразу проверяет
# подпись открытым ключом, записанным в kit. Сервер 3X-UI KIT ставит обновление, только # подпись открытым ключом, записанным в kit. Сервер 3X-UI_autoVPS ставит обновление, только
# если эта проверка у него тоже сошлась. Закрытый ключ никуда не отправляется. # если эта проверка у него тоже сошлась. Закрытый ключ никуда не отправляется.
set -euo pipefail set -euo pipefail
@@ -13,42 +13,111 @@ cd "$(dirname "$0")/.."
die() { printf '✗ %s\n' "$*" >&2; exit 1; } die() { printf '✗ %s\n' "$*" >&2; exit 1; }
ok() { printf '✓ %s\n' "$*"; } ok() { printf '✓ %s\n' "$*"; }
warn() { printf '! %s\n' "$*" >&2; } # сеть до GitHub отвалилась: проверку пропускаем, релиз не блокируем
sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; }
key=${1:-$HOME/.ssh/3xui_kit_release} key=${1:-$HOME/.ssh/3xui_kit_release}
[[ -f $key ]] || die "Нет закрытого ключа $key. Создать: ssh-keygen -t ed25519 -C \"3X-UI KIT releases\" -f $key" [[ -f $key ]] || die "Нет закрытого ключа $key. Создать: ssh-keygen -t ed25519 -C \"3X-UI_autoVPS releases\" -f $key"
NS="3x-ui-kit-release" NS="3x-ui-kit-release"
ID="releases@3x-ui-kit" ID="releases@3x-ui-kit"
FILES=(scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh scripts/kit-sub.py VERSION) FILES=(scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh scripts/kit-sub.py scripts/kit-local.sh scripts/3x-ui-local.sh VERSION)
v=$(tr -d '[:space:]' <VERSION) v=$(tr -d '[:space:]' <VERSION)
[[ $v =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "VERSION: $v" [[ $v =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "VERSION: $v"
for f in scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh; do for f in scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh; do
grep -qx "KIT_VERSION=\"$v\"" "$f" || die "$f: KIT_VERSION не $v" grep -qx "KIT_VERSION=\"$v\"" "$f" || die "$f: KIT_VERSION не $v"
done done
grep -q "^## v$v" CHANGELOG.md || die "В CHANGELOG.md нет раздела ## v$v" (grep -q "^## v$v" CHANGELOG.md || grep -q "^## v$v" FORK-CHANGELOG.md) || die "Нет раздела ## v$v ни в CHANGELOG.md, ни в FORK-CHANGELOG.md (форк ведёт свои записи в FORK-CHANGELOG.md)"
ok "Версия $v во всех скриптах и в CHANGELOG" ok "Версия $v во всех скриптах и в CHANGELOG"
[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит." # Переводы строк. Сервер получает файл из git (там LF), а суммы считаются по рабочей копии:
# если в рабочей копии окажутся CR, суммы не сойдутся и kit update откажется ставить релиз.
for f in "${FILES[@]}"; do
[[ -z $(tr -cd "\r" <"$f") ]] || die "$f: переводы строк Windows (CR). Уберите: tr -d '\r' < \"$f\" > \"$f.lf\" && mv \"$f.lf\" \"$f\""
done
ok "Переводы строк во всех файлах релиза — LF"
export PY
bash tools/check-help.sh || die "справка kit help отстала от набора команд"
# «Строки-сироты»: строка с флагом без команды (например потерянный "#" в комментарии) не ловится
# ни bash -n, ни справкой, но при каждом запуске печатает «command not found». Проверяем холостым прогоном.
err=$(bash scripts/3x-ui-local.sh --dry-run 2>&1 >/dev/null || true)
if grep -qE "command not found|syntax error|bad substitution" <<<"$err"; then
printf '%s\n' "$err" | grep -E "command not found|syntax error|bad substitution" | head -3
die "установщик ругается на строку без команды — поправьте файл (см. выше)"
fi
ok "Установщик не ругается на пустые строки и опечатки"
# Наши функции в установщике обязаны быть определены РАНЬШЕ первого вызова: `bash -n` и холостой
# прогон этого не видят, а человек видит «command not found» посреди установки (так и было 13.10).
for fn in ask_yes ask_ssh_port ssh_ports_now ask_protocols proto_parse proto_menu interactive clean_previous installer_failed; do
# || true обязателен: без него grep, не найдя совпадений, возвращает 1 и set -e молча обрывает релиз.
d=$(grep -n "^$fn()" scripts/3x-ui-local.sh | head -1 | cut -d: -f1 || true)
c=$(grep -n "^[^#]*[^a-z_]$fn[ ;\"]" scripts/3x-ui-local.sh | grep -v "^${d:-0}:" | head -1 | cut -d: -f1 || true)
[[ -n $d ]] || die "scripts/3x-ui-local.sh: функция $fn не определена, а вызывается"
[[ -z $c || $c -gt $d ]] || die "scripts/3x-ui-local.sh: $fn вызывается (строка $c) раньше, чем определена ($d)"
done
ok "Все наши функции определены раньше своих вызовов"
[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md FORK-CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
# Часть проверок сверяется с GitHub, а он из домашней сети отвечает через раз. Сначала короткая проба:
# нет связи — сразу предупреждаем и пропускаем внешние проверки (подписи это не мешает), а не висим минутами.
gh_ok=yes
curl -fsI --connect-timeout 8 --max-time 12 https://github.com >/dev/null 2>&1 || gh_ok=no
[[ $gh_ok == yes ]] || warn "GitHub недоступен из вашей сети — установщик, суммы ядра и панели не сверяю. Подписи это не мешает."
gh_curl() { [[ ${gh_ok:-no} == yes ]] || return 1; curl -fsSL --retry 2 --retry-delay 2 --connect-timeout 15 --max-time 90 "$@"; }
# Установщик 3X-UI: хеш в скрипте должен совпадать с тем, что сейчас лежит на GitHub. # Установщик 3X-UI: хеш в скрипте должен совпадать с тем, что сейчас лежит на GitHub.
xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh) xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh)
xs=$(sed -n 's/^XUI_INSTALL_SHA256="\(.*\)"/\1/p' scripts/3x-ui.sh) xs=$(sed -n 's/^XUI_INSTALL_SHA256="\(.*\)"/\1/p' scripts/3x-ui.sh)
got=$(curl -fsSL "https://raw.githubusercontent.com/MHSanaei/3x-ui/$xv/install.sh" | sha | awk '{print $1}') got=$(gh_curl "https://raw.githubusercontent.com/MHSanaei/3x-ui/$xv/install.sh" | sha | awk '{print $1}') \
|| { warn "GitHub недоступен — проверку установщика 3X-UI пропускаю (релиз не блокирую)"; got=$xs; }
[[ $got == "$xs" ]] || die "Установщик 3X-UI $xv изменился: $got вместо $xs. Проверьте, что поменялось, прежде чем выпускать." [[ $got == "$xs" ]] || die "Установщик 3X-UI $xv изменился: $got вместо $xs. Проверьте, что поменялось, прежде чем выпускать."
ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256" ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256"
# Открытые ключи из kit и hy2 – одинаковые, и среди них есть ключ, которым подписываем. # Суммы архивов ядра Xray в скрипте должны совпадать с официальными (.dgst релиза Xray-core).
xc=$(sed -n 's/^XRAY_CORE="\(.*\)"/\1/p' scripts/3x-ui.sh)
for a in 64 arm64-v8a; do
pin=$(sed -n "s/^ \[$a\]=\([0-9a-f]\{64\}\)\$/\1/p" scripts/3x-ui.sh)
off=$(gh_curl "https://github.com/XTLS/Xray-core/releases/download/$xc/Xray-linux-$a.zip.dgst" | awk '/^SHA2-256=/ {print $2}' || true)
[[ -n $off ]] || { warn "GitHub недоступен — суммы Xray для $a пропускаю"; continue; }
[[ -n $pin && $pin == "$off" ]] || die "Сумма ядра Xray $xc ($a) в 3x-ui.sh не совпадает с официальной: «$pin» вместо «$off»."
done
ok "Суммы архивов ядра Xray $xc совпадают с официальными"
# Проверенная панель и ядро в kit.sh (kit panel update, kit check): те же версии, что в установщике,
# а суммы архива панели и скрипта меню x-ui – официальные.
kp=$(sed -n 's/^XUI_PIN="\(.*\)"/\1/p' scripts/kit.sh)
[[ $kp == "$xv" ]] || die "XUI_PIN в kit.sh ($kp) не совпадает с XUI_VERSION в 3x-ui.sh ($xv)."
kx=$(sed -n 's/^XRAY_PIN="\(.*\)"/\1/p' scripts/kit.sh)
[[ $kx == "$xc" ]] || die "XRAY_PIN в kit.sh ($kx) не совпадает с XRAY_CORE в 3x-ui.sh ($xc)."
for a in amd64 arm64; do
pin=$(sed -n "s/^ \[$a\]=\([0-9a-f]\{64\}\)\$/\1/p" scripts/kit.sh)
off=$(gh_curl "https://github.com/MHSanaei/3x-ui/releases/download/$kp/x-ui-linux-$a.tar.gz.sha256" | awk 'NR == 1 {print $1}' || true)
[[ -n $off ]] || { warn "GitHub недоступен — сумму панели для $a пропускаю"; continue; }
[[ -n $pin && $pin == "$off" ]] || die "Сумма архива панели $kp ($a) в kit.sh не совпадает с официальной: «$pin» вместо «$off»."
done
shs=$(sed -n 's/^XUI_SH_SHA256=\(.*\)/\1/p' scripts/kit.sh)
got=$(gh_curl "https://raw.githubusercontent.com/MHSanaei/3x-ui/$kp/x-ui.sh" | sha | awk '{print $1}' || true)
[[ -n $got ]] || { warn "GitHub недоступен — проверку x-ui.sh $kp пропускаю"; got=$shs; }
[[ $shs == "$got" ]] || die "Сумма x-ui.sh $kp в kit.sh не совпадает с официальной: «$shs» вместо «$got»."
ok "Панель $kp и ядро $kx в kit.sh совпадают с установщиком и официальными суммами"
# В kit записан открытый ключ, которым подписываем.
signers() { awk '/^KIT_SIGNERS=\(/ {on = 1; next} on && /^\)/ {exit} on && /^ *"/ {gsub(/^ *"|"$/, ""); print}' "$1"; } signers() { awk '/^KIT_SIGNERS=\(/ {on = 1; next} on && /^\)/ {exit} on && /^ *"/ {gsub(/^ *"|"$/, ""); print}' "$1"; }
[[ -n $(signers scripts/kit.sh) ]] || die "В scripts/kit.sh пустой KIT_SIGNERS: впишите туда открытую часть ключа ($key.pub)." [[ -n $(signers scripts/kit.sh) ]] || die "В scripts/kit.sh пустой KIT_SIGNERS: впишите туда открытую часть ключа ($key.pub)."
[[ $(signers scripts/kit.sh) == "$(signers scripts/hysteria2.sh)" ]] || die "KIT_SIGNERS в kit.sh и hysteria2.sh различаются."
pub=$(ssh-keygen -y -f "$key" | awk '{print $1, $2}') pub=$(ssh-keygen -y -f "$key" | awk '{print $1, $2}')
signers scripts/kit.sh | awk '{print $1, $2}' | grep -qxF "$pub" || die "Ключа $key нет в KIT_SIGNERS – серверы не примут такую подпись." signers scripts/kit.sh | awk '{print $1, $2}' | grep -qxF "$pub" || die "Ключа $key нет в KIT_SIGNERS – серверы не примут такую подпись."
ok "Ключ подписи записан в kit и hy2" ok "Ключ подписи записан в kit"
{ echo "# 3X-UI KIT $v"; sha "${FILES[@]}"; } >SHA256SUMS { echo "# 3X-UI KIT $v"; echo "# 3X-UI_autoVPS $v"; sha "${FILES[@]}" | awk '{ sub(/^\*/, "", $2); print $1" "$2 }'; } >SHA256SUMS
rm -f SHA256SUMS.sig rm -f SHA256SUMS.sig
ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null # Ключ защищён парольной фразой: её вводит человек. Понятная подсказка на случай опечатки.
echo "Подписываю ключом $key — если он защищён парольной фразой, введите её сейчас."
ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null \
|| die "Ключ не подписал. Чаще всего это неверная парольная фраза («incorrect passphrase») — запустите команду ещё раз и введите её внимательно. Файл SHA256SUMS при этом не портится."
tmp=$(mktemp) tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT trap 'rm -f "$tmp"' EXIT
signers scripts/kit.sh | while read -r k; do printf '%s namespaces="%s" %s\n' "$ID" "$NS" "$k"; done >"$tmp" signers scripts/kit.sh | while read -r k; do printf '%s namespaces="%s" %s\n' "$ID" "$NS" "$k"; done >"$tmp"
+111
View File
@@ -0,0 +1,111 @@
"""Проверки fix_vless_encryption в kit-sub: python3 tools/test/jsonfix_test.py"""
import json, os, threading, urllib.request, tempfile, unittest, importlib.util
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
tmp = tempfile.mkdtemp()
cfgp = os.path.join(tmp, "config.json")
json.dump({"listen": "127.0.0.1", "port": 1, "path": "/sub/", "upstream": "http://127.0.0.1:1/"}, open(cfgp, "w"))
os.environ["KIT_SUB_CONFIG"] = cfgp
os.environ["KIT_SUB_RULES"] = os.path.join(tmp, "rules.yaml")
spec = importlib.util.spec_from_file_location("kit_sub", os.path.join(root, "scripts", "kit-sub.py"))
ks = importlib.util.module_from_spec(spec); spec.loader.exec_module(ks)
def vnext(**u):
return {"protocol": "vless", "settings": {"vnext": [{"address": "1.2.3.4", "port": 443, "users": [dict(id="x", **u)]}]}}
def flat(**u):
return {"protocol": "vless", "settings": dict(address="1.2.3.4", port=443, id="x", **u)}
def run(obj):
return json.loads(ks.fix_vless_encryption(json.dumps(obj).encode()))
def enc_vnext(o):
return o["outbounds"][0]["settings"]["vnext"][0]["users"][0].get("encryption")
class Fix(unittest.TestCase):
def test_vnext(self):
self.assertEqual(enc_vnext(run({"outbounds": [vnext()]})), "none")
self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="")]})), "none")
self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="none")]})), "none")
self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="mlkem768x25519plus.native.0rtt.abc")]})),
"mlkem768x25519plus.native.0rtt.abc")
def test_flat(self):
for u, want in (({}, "none"), ({"encryption": ""}, "none"), ({"encryption": "none"}, "none"), ({"encryption": "mlkem.x"}, "mlkem.x")):
self.assertEqual(run({"outbounds": [flat(**u)]})["outbounds"][0]["settings"]["encryption"], want)
def test_array_and_many(self):
r = run([{"outbounds": [vnext(), flat(), vnext(encryption="mlkem.x")]}, {"outbounds": [vnext()]}])
self.assertEqual(enc_vnext(r[0]), "none")
self.assertEqual(r[0]["outbounds"][1]["settings"]["encryption"], "none")
self.assertEqual(r[0]["outbounds"][2]["settings"]["vnext"][0]["users"][0]["encryption"], "mlkem.x")
self.assertEqual(enc_vnext(r[1]), "none")
def test_not_vless_untouched(self):
src = {"outbounds": [{"protocol": "trojan", "settings": {"servers": [{"address": "a", "password": "p"}]}},
{"protocol": "freedom", "settings": {}}, {"protocol": "blackhole"},
{"protocol": "shadowsocks", "settings": {"id": "x"}}]}
raw = json.dumps(src).encode()
self.assertEqual(ks.fix_vless_encryption(raw), raw)
def test_bytes_as_is(self):
for raw in (b"", b"not json", b"{broken", b"\xff\xfe\x00", b"123", b"null", b'"s"', b"[1,2]", b'{"outbounds": 5}',
b'{"outbounds": [null, 1, {"protocol": "vless", "settings": 3}]}'):
self.assertEqual(ks.fix_vless_encryption(raw), raw)
def test_big(self):
raw = json.dumps({"outbounds": [vnext()], "pad": "a" * (ks.JSON_FIX_MAX + 1)}).encode()
self.assertEqual(ks.fix_vless_encryption(raw), raw)
def test_unchanged_is_identical_and_idempotent(self):
raw = json.dumps({"outbounds": [vnext(encryption="none")]}, indent=2).encode()
self.assertEqual(ks.fix_vless_encryption(raw), raw)
once = ks.fix_vless_encryption(json.dumps({"outbounds": [vnext()]}).encode())
self.assertEqual(ks.fix_vless_encryption(once), once)
def test_utf8(self):
out = ks.fix_vless_encryption(json.dumps({"remarks": "Привет", "outbounds": [vnext()]}, ensure_ascii=False).encode())
self.assertIn("Привет".encode(), out)
def test_deep_nesting_as_is(self):
raw = b"[" * 100000 + b"]" * 100000
self.assertEqual(ks.fix_vless_encryption(raw), raw)
class Http(unittest.TestCase):
"""Через настоящий обработчик, upstream подменён заглушкой."""
def get(self, ctype, body):
orig = ks.upstream
ks.upstream = lambda *a, **k: (200, {"content-type": ctype}, body)
srv = ks.Server(("127.0.0.1", 0), ks.Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
req = urllib.request.Request("http://127.0.0.1:%d%sabc123" % (srv.server_address[1], ks.PATH),
headers={"User-Agent": "v2rayN/7"})
return urllib.request.urlopen(req, timeout=10).read()
finally:
srv.shutdown(); srv.server_close(); ks.upstream = orig
def test_json_text_plain(self):
raw = json.dumps([{"outbounds": [flat(encryption="")]}], indent=2).encode()
for ct in ("text/plain; charset=utf-8", "application/json"):
out = json.loads(self.get(ct, raw))
self.assertEqual(out[0]["outbounds"][0]["settings"]["encryption"], "none")
def test_json_text_plain_keeps_lines(self):
raw = b'{"a": "vpn://x", "outbounds": []}\n'
self.assertEqual(self.get("text/plain", raw), raw)
def test_plain_text_still_stripped(self):
out = self.get("text/plain", b"vless://a@b:1#x\nvpn://zzz\n")
self.assertIn(b"vless://", out)
self.assertNotIn(b"vpn://", out)
if __name__ == "__main__":
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Проверки портов подключений в kit.sh (inbound_listens, inbounds_down, xray_heal): TCP и UDP отдельно,
перезапуск ядра только если правка закрыла порт. python3 tools/test/kit_ports_test.py"""
import json, os, re, subprocess, tempfile, unittest
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
src = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
funcs = "".join(re.search(r"^%s\(\) \{.*?^\}\n" % n, src, re.S | re.M).group(0)
for n in ("port_net", "inbound_listens", "inbounds_down", "xray_heal"))
INBOUNDS = [
{"remark": "REALITY", "protocol": "vless", "port": 10443, "enable": True},
{"remark": "Hysteria2", "protocol": "hysteria", "port": 443, "enable": True},
{"remark": "Shadowsocks", "protocol": "shadowsocks", "port": 8388, "enable": True},
{"remark": "VMess-WS", "protocol": "vmess", "port": 10452, "enable": False},
]
# ss и api – заглушки: открытые порты лежат в файле «t:443», «u:443»; перезапуск ядра открывает порты из $AFTER.
STUBS = r'''
set -Eeuo pipefail
say() { echo "SAY: $*"; }; warn() { echo "WARN: $*"; }; sleep() { :; }
ss() { local p=${1:3:1} port=${2##*:}; grep -qx "$p:$port" "$LISTEN" && echo LISTEN || true; }
api() { cat "$LIST"; }
xray_restart_core() { echo RESTART; cp "$AFTER" "$LISTEN"; }
'''
def run(listen, body, after=None):
d = tempfile.mkdtemp()
paths = {k: os.path.join(d, k) for k in ("listen", "list", "after")}
for k, text in (("listen", "\n".join(listen) + "\n"), ("after", "\n".join(after if after is not None else listen) + "\n"),
("list", json.dumps(INBOUNDS))):
with open(paths[k], "w") as f:
f.write(text)
env = dict(os.environ, LISTEN=paths["listen"], LIST=paths["list"], AFTER=paths["after"])
r = subprocess.run(["bash", "-c", STUBS + funcs + body], capture_output=True, text=True, env=env)
return r.returncode, r.stdout + r.stderr
ALL = ["t:10443", "u:443", "t:8388", "u:8388"]
class Ports(unittest.TestCase):
def test_all_listen(self):
code, out = run(ALL + ["t:443"], 'inbounds_down; echo END')
self.assertEqual(code, 0, out)
self.assertEqual(out.strip(), "END")
def test_nginx_tcp_is_not_hysteria_udp(self):
code, out = run(["t:10443", "t:443", "t:8388", "u:8388"], 'inbounds_down')
self.assertEqual(out.strip(), "Hysteria2\t443\tudp", out)
def test_shadowsocks_needs_both(self):
code, out = run(["t:10443", "u:443", "t:8388"], 'inbounds_down')
self.assertEqual(out.strip(), "Shadowsocks\t8388\ttcp+udp", out)
def test_disabled_ignored(self):
code, out = run(ALL, 'inbounds_down | grep -c VMess || true')
self.assertEqual(out.strip(), "0")
def test_bad_port_value(self):
code, out = run(ALL, 'inbound_listens "443;id" udp && echo YES || echo NO')
self.assertEqual(out.strip(), "NO")
def test_heal_restarts_when_patch_closed_port(self):
code, out = run(["t:10443", "t:8388", "u:8388"], 'xray_heal ""', after=ALL)
self.assertEqual(code, 0, out)
self.assertIn("RESTART", out)
self.assertIn("Hysteria2", out)
self.assertNotIn("WARN", out)
def test_heal_quiet_when_nothing_closed(self):
code, out = run(ALL, 'xray_heal ""')
self.assertEqual(out.strip(), "")
def test_heal_ignores_port_down_before_patch(self):
code, out = run(["t:10443", "t:8388", "u:8388"], 'xray_heal "$(printf "Hysteria2\\t443\\tudp")"')
self.assertNotIn("RESTART", out)
def test_heal_warns_if_restart_did_not_help(self):
code, out = run(["t:10443", "t:8388", "u:8388"], 'xray_heal ""')
self.assertIn("RESTART", out)
self.assertIn("WARN: После перезапуска ядра не слушают: Hysteria2:443", out)
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
"""Ответы kit-sub как у nginx (режим --multi-port): python3 tools/test/kitsub_http_test.py"""
import json, os, shutil, socket, ssl, subprocess, sys, tempfile, threading, time, unittest, importlib.util
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
tmp = tempfile.mkdtemp()
cfgp = os.path.join(tmp, "config.json")
json.dump({"listen": "127.0.0.1", "port": 1, "path": "/sub/", "upstream": "http://127.0.0.1:1/"}, open(cfgp, "w"))
os.environ["KIT_SUB_CONFIG"] = cfgp
os.environ["KIT_SUB_RULES"] = os.path.join(tmp, "rules.yaml")
spec = importlib.util.spec_from_file_location("kit_sub", os.path.join(root, "scripts", "kit-sub.py"))
ks = importlib.util.module_from_spec(spec); spec.loader.exec_module(ks)
srv = ks.Server(("127.0.0.1", 0), ks.Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
def raw(data):
s = socket.create_connection(srv.server_address[:2]); s.settimeout(3); s.sendall(data); out = b""
try:
while True:
b = s.recv(65536)
if not b:
break
out += b
except socket.timeout:
out += b"<open>"
s.close()
return out.decode(errors="replace")
class Http(unittest.TestCase):
def test_status_and_server(self):
r = raw(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
self.assertTrue(r.startswith("HTTP/1.1 404 Not Found\r\n"), r[:40])
self.assertIn("\r\nServer: nginx\r\n", r)
self.assertIn("\r\nContent-Length: ", r)
def test_keep_alive(self):
r = raw(b"GET / HTTP/1.1\r\nHost: x\r\n\r\nGET /a HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
self.assertEqual(r.count("HTTP/1.1 404 Not Found"), 2)
self.assertNotIn("<open>", r)
def test_post_closes(self):
r = raw(b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello")
self.assertTrue(r.startswith("HTTP/1.1 404"))
self.assertIn("Connection: close", r)
self.assertNotIn("<open>", r)
def test_garbage_request_line(self):
for req in (b"GARBAGE\r\n\r\n", b"GET\r\n\r\n", b"GET / HTTP/1.1 x\r\n\r\n", b"POST /\r\n\r\n"):
r = raw(req)
self.assertTrue(r.startswith("HTTP/1.1 400 Bad Request\r\n"), (req, r[:60]))
self.assertIn("\r\nServer: nginx\r\n", r)
self.assertIn("Connection: close", r)
self.assertIn("<center><h1>400 Bad Request</h1></center>", r)
self.assertNotIn("<open>", r)
def test_bad_version(self):
r = raw(b"GET / HTTP/9.9\r\n\r\n")
self.assertTrue(r.startswith("HTTP/1.1 505"), r[:40])
def test_connection_header(self):
r = raw(b"GET / HTTP/1.1\r\nHost: x\r\n\r\nGET /a HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
first, second = r.split("HTTP/1.1 404", 2)[1:]
self.assertIn("\r\nConnection: keep-alive\r\n", first)
self.assertIn("\r\nConnection: close\r\n", second)
self.assertEqual(r.count("Connection:"), 2)
def test_http10_closes(self):
r = raw(b"GET / HTTP/1.0\r\n\r\n")
self.assertTrue(r.startswith("HTTP/1.1 404"))
self.assertIn("\r\nConnection: close\r\n", r)
self.assertNotIn("<open>", r)
@unittest.skipUnless(shutil.which("openssl"), "нужен openssl")
class Tls(unittest.TestCase):
"""Свой TLS (--multi-port): ALPN http/1.1, как у nginx без http2."""
def test_alpn(self):
d = tempfile.mkdtemp()
cert, key = os.path.join(d, "c.pem"), os.path.join(d, "k.pem")
subprocess.run(["openssl", "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
"-keyout", key, "-out", cert, "-subj", "/CN=1.2.3.4", "-days", "1"], check=True, capture_output=True)
s = socket.socket(); s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]; s.close()
cfg = os.path.join(d, "config.json")
with open(cfg, "w") as f:
json.dump({"listen": "127.0.0.1", "port": port, "path": "/sub/", "upstream": "http://127.0.0.1:1/", "cert": cert, "key": key}, f)
env = dict(os.environ, KIT_SUB_CONFIG=cfg, KIT_SUB_RULES=os.path.join(d, "rules.yaml"))
env.pop("CREDENTIALS_DIRECTORY", None)
p = subprocess.Popen([sys.executable, os.path.join(root, "scripts", "kit-sub.py")], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
for alpn, want in ((["h2", "http/1.1"], "http/1.1"), (["h2"], None)):
ctx.set_alpn_protocols(alpn)
for _ in range(50):
try:
c = socket.create_connection(("127.0.0.1", port), timeout=3)
break
except OSError:
time.sleep(0.1)
with ctx.wrap_socket(c) as t:
self.assertEqual(t.selected_alpn_protocol(), want)
t.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
self.assertTrue(t.recv(64).startswith(b"HTTP/1.1 404"))
finally:
p.terminate(); p.wait()
if __name__ == "__main__":
unittest.main()
+211
View File
@@ -0,0 +1,211 @@
"""Проверка архива копии и сертификатов при --restore (3x-ui.sh): python3 tools/test/restore_archive_test.py"""
import io, json, os, re, sqlite3, subprocess, tarfile, tempfile, unittest
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
src = open(os.path.join(root, "scripts", "3x-ui.sh"), encoding="utf-8").read()
def heredoc(after):
i = src.index(after)
j = src.index("<<'PY'", i)
body = src[src.index("\n", j) + 1:]
return body[:body.index("\nPY\n") + 1]
CHECK = heredoc('python3 - "$file" "$tmp"')
CERT_FILES = heredoc("db_cert_files() {")
PIN = heredoc("pin_cert_in_db() {")
def archive(extra=(), drop=()):
files = {"etc/x-ui/x-ui.db": b"db", "etc/x-ui/install-result.env": b"X=1\n", "kit-backup.env": b"BACKUP_SSL=ip\n"}
files.update({k: v for k, v in extra if v is not None})
d = tempfile.mkdtemp()
p = os.path.join(d, "b.tar.gz")
with tarfile.open(p, "w:gz") as t:
for name, data in list(files.items()) + [(k, v) for k, v in extra if v is None]:
if name in drop:
continue
ti = tarfile.TarInfo(name)
if data is None:
ti.type = tarfile.DIRTYPE
t.addfile(ti)
else:
ti.size = len(data)
t.addfile(ti, io.BytesIO(data))
return p
def check(p):
out = tempfile.mkdtemp()
return subprocess.run(["python3", "-", p, out], input=CHECK, capture_output=True, text=True)
PEM = b"-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----\n"
# Проверка файлов настроек – те же строки, что в --restore, после распаковки архива.
FUNCS = "".join(re.search(r"^%s\(\) \{.*?^\}\n" % n, src, re.S | re.M).group(0) for n in ("safe_env", "env_get"))
ENV_CHECK = src[src.index(' safe_env "$tmp/kit-backup.env"'):src.index(' python3 -c "import sqlite3')]
ENVS = {
"kit-backup.env": b"BACKUP_KIT_VERSION=1.2\nBACKUP_HOST=1.2.3.4\nBACKUP_SSL=ip\nBACKUP_DATE=2026-10-06\n",
"etc/x-ui/install-result.env": b"XUI_USERNAME=abcDEF1234\nXUI_PASSWORD=Pass1234567890abcdef\nXUI_PANEL_PORT=31234\n"
b"XUI_WEB_BASE_PATH=AbCdEf123456789012\nXUI_ACCESS_URL=https://1.2.3.4:31234/AbCdEf123456789012\n"
b"XUI_API_TOKEN=tok_ABCdef0123456789\nXUI_DB_TYPE=sqlite\n",
"etc/kit/kit.env": b"HOST=1.2.3.4\nLINK_HOST=1.2.3.4\nPANEL_ON=ip\nSUB_BASE=https://1.2.3.4/AbC123/\nSUB_PATH=/AbC123/\n"
b"SUB_INTERNAL=10446\nSINGLE=yes\nMTPROTO_INNER=10445\n",
}
# kit.env 1.1.x: без LINK_HOST и PANEL_ON, подписка на своём порту.
KIT_11 = b"HOST=1.2.3.4\nSUB_BASE=https://1.2.3.4:2096/AbC123/\nSUB_PATH=/AbC123/\nSUB_INTERNAL=2096\nSINGLE=no\nMTPROTO_INNER=''\n"
def restore_env(over=None):
files = dict(ENVS)
files.update(over or {})
p = archive([(k, v) for k, v in files.items()])
out = tempfile.mkdtemp()
r = subprocess.run(["python3", "-", p, out], input=CHECK, capture_output=True, text=True)
if r.returncode:
return r
script = 'set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + FUNCS + 'tmp=$1\n' + ENV_CHECK + 'echo ENV-OK\n'
return subprocess.run(["bash", "-c", script, "t", out], capture_output=True, text=True)
class Archive(unittest.TestCase):
def test_good(self):
r = check(archive())
self.assertEqual(r.returncode, 0, r.stderr)
def test_good_with_self_cert(self):
r = check(archive([("root/cert/self", None), ("root/cert/self/fullchain.pem", PEM), ("root/cert/self/privkey.pem", PEM)]))
self.assertEqual(r.returncode, 0, r.stderr)
def test_empty_self_dir(self):
r = check(archive([("root/cert/self", None)]))
self.assertNotEqual(r.returncode, 0)
self.assertIn("root/cert/self/fullchain.pem", r.stderr)
def test_empty_custom_dir(self):
self.assertNotEqual(check(archive([("root/cert/custom", None)])).returncode, 0)
def test_self_without_key(self):
self.assertNotEqual(check(archive([("root/cert/self/fullchain.pem", PEM)])).returncode, 0)
def test_self_empty_key(self):
self.assertNotEqual(check(archive([("root/cert/self/fullchain.pem", PEM), ("root/cert/self/privkey.pem", b"")])).returncode, 0)
def test_no_db(self):
self.assertNotEqual(check(archive(drop=("etc/x-ui/x-ui.db",))).returncode, 0)
def test_dotdot(self):
self.assertNotEqual(check(archive([("etc/x-ui/../../tmp/x", b"x")])).returncode, 0)
class RestoreEnv(unittest.TestCase):
def test_good_12(self):
r = restore_env()
self.assertIn("ENV-OK", r.stdout, r.stdout + r.stderr)
def test_good_11(self):
r = restore_env({"etc/kit/kit.env": KIT_11,
"kit-backup.env": b"BACKUP_KIT_VERSION=1.1.2\nBACKUP_HOST=1.2.3.4\nBACKUP_SSL=ip\nBACKUP_DATE=2026-09-01\n"})
self.assertIn("ENV-OK", r.stdout, r.stdout + r.stderr)
def bad(self, over):
r = restore_env(over)
self.assertNotIn("ENV-OK", r.stdout)
self.assertIn("Это не резервная копия 3X-UI_autoVPS", r.stdout + r.stderr)
def test_mtproto_inner_sed(self):
for v in (b"MTPROTO_INNER='[0-9]*.*/touch \\/tmp\\/pwn-test/e;#'", b"MTPROTO_INNER='1/x/;1e touch /tmp/pwn-test #'", b"MTPROTO_INNER=1/x/e"):
self.bad({"etc/kit/kit.env": ENVS["etc/kit/kit.env"].replace(b"MTPROTO_INNER=10445", v)})
def test_panel_path_nginx(self):
for v in (b"XUI_WEB_BASE_PATH='x/ { return 200; } location /zz'", b"XUI_WEB_BASE_PATH=x/../y", b"XUI_WEB_BASE_PATH=$(id)"):
self.bad({"etc/x-ui/install-result.env": ENVS["etc/x-ui/install-result.env"].replace(b"XUI_WEB_BASE_PATH=AbCdEf123456789012", v)})
def test_binary_bytes_every_file(self):
# Аудит 1.2 (N6): нулевой байт и прочее непечатное в файлах настроек отклоняет уже проверка архива.
for name, body in ENVS.items():
first = body.index(b"\n") + 1
for bad in (b"\0", b"\r", b"\x1b", b"\x80", b"\xff", b"\t", b"\x7f"):
for variant in (bad + body, body[:first] + bad + b"\n" + body[first:],
body.replace(b"1.2.3.4", b"1.2" + bad + b".3.4", 1), body + b"PATH=/tmp/x" + bad + b"\n",
body + b"PATH=/tmp/x\n" + bad, body + bad + b"\n"):
r = check(archive([(k, v) for k, v in dict(ENVS, **{name: variant}).items()]))
self.assertNotEqual(r.returncode, 0, "%s %r" % (name, variant))
self.assertIn("байты " + name, r.stderr)
# Слишком большой файл (одна длинная строка) – тоже отказ.
r = check(archive([(k, v) for k, v in dict(ENVS, **{name: body + b"X" * 70000 + b"\n"}).items()]))
self.assertIn("байты " + name, r.stderr)
# Тот же файл в архиве дважды (tar распакует последний) – проверяются оба.
p = archive([(k, v) for k, v in ENVS.items()])
with tarfile.open(p, "r:gz") as t:
ms = [(m, t.extractfile(m).read()) for m in t.getmembers()]
with tarfile.open(p, "w:gz") as t:
for m, data in ms + [(tarfile.TarInfo(name), body + b"\0")]:
m.size = len(data)
t.addfile(m, io.BytesIO(data))
self.assertIn("байты " + name, check(p).stderr)
def test_3xui_txt(self):
good = "3X-UI_autoVPS – данные для входа\n\nПанель: https://1.2.3.4/x/\n\tvless://a@1.2.3.4:443?x=1#Мой 🙂\n".encode()
r = check(archive([(k, v) for k, v in ENVS.items()] + [("root/3x-ui.txt", good)]))
self.assertEqual(r.returncode, 0, r.stderr)
for bad in (b"\0", b"\x1b]0;x\x07", b"\x1b[2J", b"\r", b"\xff", b"\xc2\x9b", b"\x7f"):
r = check(archive([(k, v) for k, v in ENVS.items()] + [("root/3x-ui.txt", good + bad + b"\n")]))
self.assertIn("байты root/3x-ui.txt", r.stderr, repr(bad))
def test_values_every_file(self):
for name in ENVS:
for line in ENVS[name].splitlines():
n = line.split(b"=")[0]
for v in (b"'a;b'", b"$(touch /tmp/pwn-test)", b"a b", b"x" * 300):
self.bad({name: ENVS[name].replace(line, n + b"=" + v)})
def make_db():
d = tempfile.mkdtemp()
p = os.path.join(d, "x-ui.db")
db = sqlite3.connect(p)
db.execute("CREATE TABLE inbounds (id INTEGER PRIMARY KEY, remark TEXT, settings TEXT, stream_settings TEXT)")
hy = {"network": "hysteria", "security": "tls", "tlsSettings": {"certificates": [
{"certificateFile": "/root/cert/ip/fullchain.pem", "keyFile": "/root/cert/ip/privkey.pem"}], "settings": {"fingerprint": "chrome"}},
"hysteriaSettings": {"masquerade": {"content": "<p>Кофейня</p>"}}}
tuic = {"server": {"certificate": "/root/cert/ip/fullchain.pem"}}
other = {"tlsSettings": {"certificates": [{"certificateFile": "/root/cert/custom/fullchain.pem"}]}}
db.executemany("INSERT INTO inbounds (remark, settings, stream_settings) VALUES (?, ?, ?)", [
("Hysteria2", "{}", json.dumps(hy)), ("TUIC", json.dumps(tuic), ""), ("Other", "{}", json.dumps(other)),
("Broken", "not json", "[1]")])
db.commit(); db.close()
return p
def run_db(code, p, *args):
code = code.replace("/etc/x-ui/x-ui.db", p)
return subprocess.run(["python3", "-", *args], input=code, capture_output=True, text=True)
class Certs(unittest.TestCase):
def test_cert_files(self):
r = run_db(CERT_FILES, make_db())
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(r.stdout.split(), ["/root/cert/custom/fullchain.pem", "/root/cert/ip/fullchain.pem"])
def test_pin_only_matching_inbound(self):
p = make_db()
r = run_db(PIN, p, "/root/cert/ip/fullchain.pem", "ab" * 32)
self.assertEqual(r.returncode, 0, r.stderr)
rows = dict(sqlite3.connect(p).execute("SELECT remark, stream_settings FROM inbounds"))
hy = json.loads(rows["Hysteria2"])
self.assertEqual(hy["tlsSettings"]["settings"], {"fingerprint": "chrome", "pinnedPeerCertSha256": ["ab" * 32]})
self.assertEqual(hy["hysteriaSettings"]["masquerade"]["content"], "<p>Кофейня</p>")
self.assertNotIn("pinnedPeerCertSha256", rows["Other"])
self.assertEqual(rows["Broken"], "[1]")
def test_restore_writes_only_own_paths(self):
# Путь сертификата приходит из базы копии: генерировать можно только в /root/cert/ip и /root/cert/self.
self.assertIn(r"^/root/cert/(ip|self)/fullchain\.pem$", src)
if __name__ == "__main__":
unittest.main()
+186
View File
@@ -0,0 +1,186 @@
"""Проверки разбора файлов настроек из резервной копии (safe_env, env_get в 3x-ui.sh):
python3 tools/test/restore_env_test.py"""
import os, re, subprocess, tempfile, unittest
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
src = open(os.path.join(root, "scripts", "3x-ui.sh"), encoding="utf-8").read()
funcs = "".join(re.search(r"^%s\(\) \{.*?^\}\n" % n, src, re.S | re.M).group(0) for n in ("safe_env", "env_get"))
BACKUP = ("BACKUP_KIT_VERSION", "BACKUP_HOST", "BACKUP_SSL", "BACKUP_DATE")
XUI = ("XUI_USERNAME", "XUI_PASSWORD", "XUI_PANEL_PORT", "XUI_WEB_BASE_PATH", "XUI_ACCESS_URL", "XUI_API_TOKEN", "XUI_DB_TYPE")
KIT = ("HOST", "LINK_HOST", "PANEL_ON", "SUB_BASE", "SUB_PATH", "SUB_INTERNAL", "SINGLE", "MTPROTO_INNER")
# Как пишут kit backup, установщик 3X-UI и install_kit_cli (printf %q).
GOOD = {
"kit-backup.env": "BACKUP_KIT_VERSION=1.2\nBACKUP_HOST=1.2.3.4\nBACKUP_SSL=ip\nBACKUP_DATE=2026-10-06\n",
"install-result.env": "XUI_USERNAME=abcDEF1234\nXUI_PASSWORD=Pass1234567890abcdef\nXUI_PANEL_PORT=31234\n"
"XUI_WEB_BASE_PATH=AbCdEf123456789012\nXUI_ACCESS_URL=https://1.2.3.4:31234/AbCdEf123456789012\n"
"XUI_API_TOKEN=tok_ABCdef0123456789\nXUI_DB_TYPE=sqlite\n",
"kit.env": "HOST=1.2.3.4\nLINK_HOST=1.2.3.4\nPANEL_ON=ip\nSUB_BASE=https://1.2.3.4/AbC123/\nSUB_PATH=/AbC123/\n"
"SUB_INTERNAL=2096\nSINGLE=yes\nMTPROTO_INNER=''\n",
}
NAMES = {"kit-backup.env": BACKUP, "install-result.env": XUI, "kit.env": KIT}
# Другие настоящие варианты: kit.env 1.1.x (без LINK_HOST и PANEL_ON, свой порт подписки), «всё на 443» с доменом,
# install-result.env с путём панели в слешах (панель стояла до установщика) и пустыми значениями.
GOOD_MORE = [
("kit.env", "HOST=1.2.3.4\nSUB_BASE=https://1.2.3.4:2096/AbC123/\nSUB_PATH=/AbC123/\nSUB_INTERNAL=2096\nSINGLE=no\nMTPROTO_INNER=''\n"),
("kit.env", "HOST=1.2.3.4\nSUB_BASE=http://127.0.0.1:2096/AbC123/\nSUB_PATH=/AbC123/\nSUB_INTERNAL=2096\nSINGLE=no\nMTPROTO_INNER=''\n"),
("kit.env", "HOST=1.2.3.4\nSUB_BASE=https://1.2.3.4/Ab-C_123/\nSUB_PATH=/Ab-C_123/\nSUB_INTERNAL=10446\nSINGLE=yes\nMTPROTO_INNER=10445\n"),
("kit.env", "HOST=vpn.example.com\nLINK_HOST=vpn.example.com\nPANEL_ON=domain\nSUB_BASE=https://vpn.example.com/AbC123/\n"
"SUB_PATH=/AbC123/\nSUB_INTERNAL=10446\nSINGLE=yes\nMTPROTO_INNER=10445\n"),
("install-result.env", "XUI_USERNAME=admin\nXUI_PASSWORD=Pass_1234-abc.def\nXUI_PANEL_PORT=2053\nXUI_WEB_BASE_PATH=/AbCdEf123456789012/\n"
"XUI_ACCESS_URL=http://SERVER_IP_UNKNOWN:2053//AbCdEf123456789012/\nXUI_API_TOKEN=''\nXUI_DB_TYPE=sqlite\n"),
("kit-backup.env", "BACKUP_KIT_VERSION=1.1.2\nBACKUP_HOST=vpn.example.com\nBACKUP_SSL=custom\nBACKUP_DATE=2026-09-01\n"),
]
# Плохие значения: проверяются для каждого имени в каждом файле.
BAD_VALUES = ("$(touch /tmp/pwn-test)", "`id`", "1;id", "1; id", "'1;id'", "'a b'", "a b", "'", "\"x\"", "$HOME", "x\\y",
"1\nid", "'1\n/e;#'", "a" * 300, "9" * 300, "x{", "a|b", "a&b", "a>b", "a*b")
def run(name, text, extra=""):
d = tempfile.mkdtemp()
p = os.path.join(d, name)
with open(p, "w") as fh:
fh.write(text)
script = ('set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + funcs
+ 'RESULT=/root/3x-ui.txt\nsafe_env "$1" ' + " ".join(NAMES[name]) + '\necho OK\n' + extra)
r = subprocess.run(["bash", "-c", script, "t", p], capture_output=True, text=True)
return r.returncode, r.stdout + r.stderr
class SafeEnv(unittest.TestCase):
def test_good_copy(self):
for name, text in GOOD.items():
code, out = run(name, text)
self.assertEqual(code, 0, out)
self.assertIn("OK", out)
def test_good_variants(self):
for name, text in GOOD_MORE:
code, out = run(name, text)
self.assertEqual(code, 0, name + ": " + out)
self.assertIn("OK", out)
def test_bad_value_every_field(self):
for name, text in GOOD.items():
for n in NAMES[name]:
for bad in BAD_VALUES:
lines = [l for l in text.splitlines() if not l.startswith(n + "=")] + [n + "=" + bad]
self.rejected(name, "\n".join(lines) + "\n")
def test_wrong_format(self):
# Безопасные символы, но не тот формат.
for name, n, bad in (("kit.env", "MTPROTO_INNER", "1/x/e"), ("kit.env", "MTPROTO_INNER", "abc"), ("kit.env", "SINGLE", "maybe"),
("kit.env", "PANEL_ON", "x"), ("kit.env", "SUB_PATH", "/../"), ("kit.env", "SUB_PATH", "/a"),
("kit.env", "SUB_BASE", "file:///etc/"), ("kit.env", "SUB_INTERNAL", "20x6"), ("kit.env", "HOST", ""),
("kit.env", "LINK_HOST", "a/b"), ("kit-backup.env", "BACKUP_SSL", "le"), ("kit-backup.env", "BACKUP_DATE", "today"),
("kit-backup.env", "BACKUP_KIT_VERSION", "1.2/x"), ("install-result.env", "XUI_WEB_BASE_PATH", "a/b"),
("install-result.env", "XUI_WEB_BASE_PATH", ""), ("install-result.env", "XUI_WEB_BASE_PATH", "a.b"),
("install-result.env", "XUI_DB_TYPE", "mysql"), ("install-result.env", "XUI_PANEL_PORT", "123456"),
("install-result.env", "XUI_USERNAME", ""), ("install-result.env", "XUI_ACCESS_URL", "ftp://1.2.3.4/x")):
lines = [l for l in GOOD[name].splitlines() if not l.startswith(n + "=")] + [n + "=" + bad]
self.rejected(name, "\n".join(lines) + "\n")
def test_audit_payloads(self):
# Из повторного аудита 1.2: выражение sed с флагом e и чужие директивы nginx.
for bad in ("MTPROTO_INNER='[0-9]*.*/touch \\/tmp\\/pwn-test/e;#'", "MTPROTO_INNER='1/x/;1e touch /tmp/pwn-test #'"):
self.rejected("kit.env", GOOD["kit.env"].replace("MTPROTO_INNER=''\n", bad + "\n"))
self.rejected("install-result.env", GOOD["install-result.env"].replace(
"XUI_WEB_BASE_PATH=AbCdEf123456789012", "XUI_WEB_BASE_PATH='x/ { return 200; } location /zz'"))
def test_values(self):
code, out = run("kit-backup.env", GOOD["kit-backup.env"],
'echo "[$(env_get "$1" BACKUP_HOST)|$(env_get "$1" BACKUP_SSL)|$(env_get "$1" NOPE)]"\n')
self.assertIn("[1.2.3.4|ip|]", out)
code, out = run("kit.env", GOOD["kit.env"], 'echo "[$(env_get "$1" SUB_PATH)|$(env_get "$1" MTPROTO_INNER)]"\n')
self.assertIn("[/AbC123/|]", out)
def test_env_get_does_not_touch_shell(self):
code, out = run("kit-backup.env", GOOD["kit-backup.env"], 'env_get "$1" BACKUP_HOST >/dev/null; echo "[${BACKUP_HOST:-none}]"\n')
self.assertIn("[none]", out)
def rejected(self, name, text):
code, out = run(name, text, 'echo "RESULT=$RESULT"\n')
self.assertNotEqual(code, 0, out)
self.assertIn("Это не резервная копия 3X-UI_autoVPS", out)
self.assertNotIn("\nOK\n", "\n" + out)
def test_result_in_backup_env(self):
self.rejected("kit-backup.env", GOOD["kit-backup.env"] + "RESULT=/etc/cron.d/x\n")
def test_kit_version_in_kit_env(self):
self.rejected("kit.env", GOOD["kit.env"] + "KIT_VERSION=9.9\n")
def test_foreign_names(self):
for bad in ("PATH=/tmp", "IFS=x", "XUI_ENV=/etc/cron.d/x", "HOST=5.6.7.8", "KIT_RAW=https://example.org/"):
self.rejected("kit-backup.env", GOOD["kit-backup.env"] + bad + "\n")
for bad in ("PATH=/tmp", "RESULT=/etc/cron.d/x", "XUI_ENV=/tmp/x", "KIT_VERSION=9.9"):
self.rejected("install-result.env", GOOD["install-result.env"] + bad + "\n")
for bad in ("RESULT=/etc/cron.d/x", "XUI_PIN=v0", "BACKUP_HOST=1.2.3.4"):
self.rejected("kit.env", GOOD["kit.env"] + bad + "\n")
def test_binary_bytes(self):
# Аудит 1.2 (N6): с нулевым байтом GNU grep без -a считал файл двоичным и молчал – проверка «проходила»,
# а bash при «.» выкидывает нулевые байты и выполняет остальное. Лишнее имя PATH плюс один байт в каждом месте.
for name, text in GOOD.items():
body = text.encode()
first = body.index(b"\n") + 1
for bad in (b"\0", b"\r", b"\x1b", b"\x80", b"\xff"):
for variant in (bad + body, # в начале
body[:first] + bad + b"\n" + body[first:], # отдельной строкой
body[:5] + bad + body[5:], # в середине имени или в начале значения
body.replace(b"1.2.3.4", b"1.2" + bad + b".3.4", 1), # в середине значения
body + b"PATH=/tmp/x" + bad + b"\n", # в значении лишнего имени
body + b"PATH=/tmp/x\n" + bad, # в конце, без перевода строки
body + b"PATH=/tmp/x\n" + bad + b"\n"): # в конце, одиночный
self.rejected_bytes(name, variant)
# Без лишнего имени, только байт – тоже отказ.
self.rejected_bytes(name, body + b"\0\n")
self.rejected_bytes(name, body.replace(b"\n", b"\r\n"))
def rejected_bytes(self, name, data):
d = tempfile.mkdtemp()
p = os.path.join(d, name)
with open(p, "wb") as fh:
fh.write(data)
script = ('set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + funcs
+ 'safe_env "$1" ' + " ".join(NAMES[name]) + '\necho OK\n')
r = subprocess.run(["bash", "-c", script, "t", p], capture_output=True)
out = (r.stdout + r.stderr).decode("utf-8", "replace")
self.assertNotEqual(r.returncode, 0, "%s %r: %s" % (name, data, out))
self.assertIn("Это не резервная копия 3X-UI_autoVPS", out, "%s %r" % (name, data))
self.assertNotIn("\nOK\n", "\n" + out)
def test_substitutions(self):
for bad in ("BACKUP_DATE=$(id)", "BACKUP_DATE=`id`", 'BACKUP_DATE="$HOME"', "export BACKUP_DATE=1", "BACKUP_DATE=1; id", " BACKUP_DATE=1"):
self.rejected("kit-backup.env", GOOD["kit-backup.env"] + bad + "\n")
kit_src = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
PANEL_PATH = re.search(r"^panel_web_path\(\) \{.*?^\}\n", kit_src, re.S | re.M).group(0)
class NginxPaths(unittest.TestCase):
"""Путь панели и подписки перед сборкой nginx (kit.sh panel_web_path, 3x-ui.sh setup_nginx)."""
def path(self, base, sub="/AbC123/"):
r = subprocess.run(["bash", "-c", PANEL_PATH + 'XUI_WEB_BASE_PATH=$1 SUB_PATH=$2\npanel_web_path || echo NO', "t", base, sub],
capture_output=True, text=True)
return r.stdout.strip()
def test_good(self):
self.assertEqual(self.path("AbCdEf123456789012"), "/AbCdEf123456789012/")
self.assertEqual(self.path("/AbC_d-1/"), "/AbC_d-1/")
def test_bad(self):
for bad in ("x/ { return 200; } location /zz", "", "/", "a/b", "a;b", "a\nb", "$(id)", "a" * 65):
self.assertEqual(self.path(bad), "NO", bad)
for bad in ("/a b/", "/a/ { }", "", "/../"):
self.assertEqual(self.path("AbC", bad), "NO", bad)
def test_setup_nginx_checks(self):
self.assertIn('[[ $XUI_WEB_BASE_PATH =~ ^/?[A-Za-z0-9_-]{1,64}/?$ && $SUB_PATH =~ ^/[A-Za-z0-9_-]{1,64}/$ ]]', src)
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Проверки раздельной маршрутизации kit-sub: python3 tools/test/rules_test.py"""
import json, os, sys, tempfile, unittest, importlib.util
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
tmp = tempfile.mkdtemp()
cfgp = os.path.join(tmp, "config.json")
json.dump({"listen": "127.0.0.1", "port": 1, "path": "/sub/", "upstream": "http://127.0.0.1:1/"}, open(cfgp, "w"))
os.environ["KIT_SUB_CONFIG"] = cfgp
os.environ["KIT_SUB_RULES"] = os.path.join(tmp, "rules.yaml")
spec = importlib.util.spec_from_file_location("kit_sub", os.path.join(root, "scripts", "kit-sub.py"))
ks = importlib.util.module_from_spec(spec); spec.loader.exec_module(ks)
import yaml
BASE = yaml.safe_dump({"proxies": [{"name": "a", "type": "vless"}, {"name": "b", "type": "vless"}],
"proxy-groups": [{"name": "Авто", "type": "url-test", "proxies": ["a", "b"]}],
"rules": ["MATCH,Авто"], "dns": {"enable": False}}).encode()
def write(text):
with open(ks.RULES_FILE, "w") as f:
f.write(text)
class Rules(unittest.TestCase):
def tearDown(self):
if os.path.exists(ks.RULES_FILE):
os.remove(ks.RULES_FILE)
def apply(self, text):
write(text)
loaded = ks.load_rules(report=lambda m: None)
self.assertIsNotNone(loaded)
return yaml.safe_load(ks.apply_rules(BASE, loaded))
def test_no_file(self):
self.assertIsNone(ks.load_rules())
def test_basic(self):
c = self.apply("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX, example.org \n - IP-CIDR,1.2.3.0/24\n")
r = c["rules"]
self.assertEqual(r[-1], "MATCH,DIRECT")
self.assertIn("GEOSITE,youtube,Авто", r)
self.assertIn("DOMAIN-SUFFIX,example.org,Авто", r)
self.assertIn("IP-CIDR,1.2.3.0/24,Авто,no-resolve", r) # адресные правила не разрешают имена
self.assertLess(r.index("IP-CIDR,192.168.0.0/16,DIRECT,no-resolve"), r.index("GEOSITE,youtube,Авто"))
self.assertEqual(c["mode"], "rule")
def test_dns_no_leak(self):
c = self.apply("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX,example.org\n - DOMAIN,api.example.com\n")
d = c["dns"]
self.assertEqual(d["enhanced-mode"], "fake-ip")
self.assertEqual(d["nameserver"], ["system"])
self.assertNotIn("fallback", d)
self.assertEqual(d["nameserver-policy"]["geosite:youtube"], ks.DOH_DEFAULT)
self.assertIn("+.example.org", d["nameserver-policy"])
self.assertIn("api.example.com", d["nameserver-policy"])
# DoH-серверы идут через VPN, а не напрямую
self.assertIn("IP-CIDR,1.1.1.1/32,Авто,no-resolve", c["rules"])
self.assertFalse(c["sniffer"]["override-destination"])
def test_direct_dns(self):
c = self.apply("direct_dns:\n - https://dns.example.net/dns-query\n - http://evil/x\n - 'https://a b/'\nvia_vpn:\n - GEOSITE,openai\n")
self.assertEqual(c["dns"]["nameserver"], ["https://dns.example.net/dns-query"])
def test_bad_rules_dropped(self):
write("via_vpn:\n - MATCH,DIRECT\n - PROCESS-NAME,x\n - SCRIPT,abc\n - 'DOMAIN,a.com,DIRECT'\n - 'DOMAIN,a b'\n - GEOSITE,youtube\n - 'DOMAIN-SUFFIX,x.org\\nMATCH,Авто'\n")
loaded = ks.load_rules(report=lambda m: None)
self.assertEqual(loaded[0], [("GEOSITE", "youtube")])
def test_broken_files_leave_subscription_alone(self):
for text in ("", "via_vpn: 5", "via_vpn: []", "- a\n- b", ":\n - [", "via_vpn:\n - MATCH,DIRECT"):
write(text)
self.assertIsNone(ks.load_rules(report=lambda m: None), text)
with open(ks.RULES_FILE, "wb") as f:
f.write(b"via_vpn:\n" + b" - DOMAIN,a.com\n" * 6000)
self.assertIsNone(ks.load_rules(report=lambda m: None)) # слишком большой
def test_limits_and_duplicates(self):
write("via_vpn:\n" + "".join(f" - DOMAIN,h{i}.example.org\n" for i in range(400)) + " - DOMAIN,h1.example.org\n")
rules, _ = ks.load_rules(report=lambda m: None)
self.assertEqual(len(rules), ks.RULES_MAX_COUNT)
self.assertEqual(len(set(rules)), len(rules))
def test_no_proxies_untouched(self):
write("via_vpn:\n - GEOSITE,youtube\n")
loaded = ks.load_rules(report=lambda m: None)
self.assertEqual(ks.apply_rules(b"proxies: []\n", loaded), b"proxies: []\n")
def test_forgiving_input(self):
# Что если: строчные типы, BOM и CRLF из Windows-редактора, лишние пробелы
with open(ks.RULES_FILE, "wb") as f:
f.write("\ufeffvia_vpn:\r\n - geosite , youtube\r\n - domain-suffix,Example.ORG\r\n".encode("utf-8"))
rules, _ = ks.load_rules(report=lambda m: None)
self.assertEqual(rules, [("GEOSITE", "youtube"), ("DOMAIN-SUFFIX", "Example.ORG")])
def test_bad_cidr_and_short_keyword(self):
write("via_vpn:\n - IP-CIDR,999.1.1.1/8\n - IP-CIDR,1.2.3.4/99\n - IP-CIDR6,1.2.3.0/24\n - IP-CIDR,2001:db8::/32\n"
" - IP-CIDR,203.0.113.0/24\n - IP-CIDR6,2001:db8::/32\n - DOMAIN-KEYWORD,a\n - DOMAIN-KEYWORD,video\n")
rules, _ = ks.load_rules(report=lambda m: None)
self.assertEqual(rules, [("IP-CIDR", "203.0.113.0/24"), ("IP-CIDR6", "2001:db8::/32"), ("DOMAIN-KEYWORD", "video")])
def test_template_from_kit_sh_is_valid(self):
# Что если: шаблон kit net split on сам окажется с ошибкой
import re
t = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
body = t[t.index("split_template() {"):]
body = body[body.index("<<'YAML'\n") + 9: body.index("\nYAML\n")]
write(body)
rules, _ = ks.load_rules(report=lambda m: self.fail(m))
self.assertGreater(len(rules), 40)
self.assertIn(("GEOIP", "telegram"), rules)
self.assertNotIn(("DOMAIN-SUFFIX", "example.org"), rules) # пример закомментирован
def test_rules_only_for_mihomo_apps(self):
# Что если: Karing/Hiddify просят формат Clash – правила им не нужны (у них своя маршрутизация)
def gets_rules(ua):
clash = bool(ks.CLASH_UA.search(ua))
return clash and not ks.NO_AWG_UA.search(ua)
for ua in ("FlClash/0.8.80", "Clash.Meta/1.18", "mihomo/1.19", "clash-verge/v2.0", "ClashVerge/2.0", "Mihomo Party/1.5"):
self.assertTrue(gets_rules(ua), ua)
for ua in ("Karing/1.2.5", "Karing/1.2.5 clash.meta", "Hiddify/2.0 clash", "sing-box/1.9", "Happ/1.0", "Stash/2.7", "v2rayN/6.0", "Mozilla/5.0"):
self.assertFalse(gets_rules(ua), ua)
def test_routing_payloads_for_panel(self):
import base64
write("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX,example.org\n - DOMAIN,api.example.com\n - GEOIP,telegram\n - IP-CIDR,203.0.113.0/24\n")
loaded = ks.load_rules(report=lambda m: None)
link = ks.routing_payload("happ", loaded)
self.assertTrue(link.startswith("happ://routing/onadd/"))
h = json.loads(base64.b64decode(link.split("/onadd/")[1]))
self.assertEqual(h["Name"], "3X-UI_autoVPS")
self.assertEqual(h["GlobalProxy"], "false")
self.assertEqual(h["ProxySites"], ["geosite:youtube", "domain:example.org", "full:api.example.com"])
self.assertEqual(h["ProxyIp"], ["geoip:telegram", "203.0.113.0/24"])
j = json.loads(ks.routing_payload("json", loaded))
self.assertEqual(j["RouteOrder"], "block-proxy-direct")
self.assertEqual(j["DirectSites"], ["regexp:.*"]) # остальное напрямую: панель сама добавляет «всё через прокси» в конец
self.assertIn("0.0.0.0/0", j["DirectIp"])
self.assertEqual(j["ProxyIp"][:2], ["1.1.1.1", "8.8.8.8"])
self.assertEqual(j["DomesticDNSDomain"], "localhost")
self.assertLess(len(link), 8192) # предел панели на значение заголовка Routing
def test_full_default_template_fits_panel_limit(self):
t = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
body = t[t.index("split_template() {"):]
body = body[body.index("<<'YAML'\n") + 9: body.index("\nYAML\n")]
write(body)
loaded = ks.load_rules(report=lambda m: self.fail(m))
self.assertLess(len(ks.routing_payload("happ", loaded)), 8192)
if __name__ == "__main__":
unittest.main(verbosity=1)
+100
View File
@@ -0,0 +1,100 @@
"""Проверки сайта-заглушки (stub_site в 3x-ui.sh): python3 tools/test/stub_test.py"""
import html.parser, os, re, subprocess, unittest
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
src = open(os.path.join(root, "scripts", "3x-ui.sh"), encoding="utf-8").read()
func = re.search(r"^stub_site\(\) \{.*?^\}\n", src, re.S | re.M).group(0)
N = 300
# rnd на сервере – через shuf, здесь – через /dev/urandom (на Mac нет shuf; $RANDOM в подоболочках повторяется).
script = ('set -Eeuo pipefail\nrnd() { echo $(( $1 + $(od -An -N2 -tu2 /dev/urandom) % ($2 - $1 + 1) )); }\n' + func
+ 'for i in $(seq %d); do stub_site; echo "@@END@@"; done\n' % N)
raw = subprocess.run(["bash", "-c", script], capture_output=True, check=True).stdout
PAGES = [p.strip() for p in raw.decode("utf-8").split("@@END@@") if p.strip()] # strict: только UTF-8
forbidden = []
fp = os.path.join(root, ".git", "hooks", "forbidden.txt")
if os.path.exists(fp):
forbidden = [w.strip().lower() for w in open(fp, encoding="utf-8") if w.strip() and not w.startswith("#")]
PUBLIC = re.compile(r"vpn|proxy|xray|x-ui|cumulo|hysteria|reality|v2ray|clash|\bkit\b|tunnel|censor|block|bypass|server", re.I)
EXTERNAL = re.compile(r"https?:|//|\bsrc=|\bhref=|<script|<link|<iframe|<img|@import|url\(|\bon[a-z]+=", re.I)
VOID = {"meta", "br", "hr", "input", "img"}
class Balance(html.parser.HTMLParser):
def __init__(self):
super().__init__(); self.stack = []; self.bad = []
def handle_starttag(self, tag, attrs):
if tag not in VOID:
self.stack.append(tag)
def handle_endtag(self, tag):
if not self.stack or self.stack.pop() != tag:
self.bad.append(tag)
class Stub(unittest.TestCase):
def test_count(self):
self.assertEqual(len(PAGES), N)
def test_valid_html(self):
for p in PAGES:
self.assertTrue(p.startswith('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'), p[:80])
self.assertTrue(p.endswith("</body></html>"), p[-40:])
b = Balance(); b.feed(p); b.close()
self.assertEqual((b.stack, b.bad), ([], []), p)
self.assertEqual(p.count("<title>"), 1)
def test_no_external_and_no_leftovers(self):
for p in PAGES:
self.assertIsNone(EXTERNAL.search(p), p)
text = re.sub(r"<style>.*?</style>", "", p)
self.assertIsNone(re.search(r"[$|^~`\\]", text), p)
self.assertNotIn("\n", p)
def test_words(self):
for p in PAGES:
low = p.lower()
self.assertIsNone(PUBLIC.search(re.sub(r"<style>.*?</style>", "", p)), p) # в стилях есть display:block
for w in forbidden:
self.assertNotIn(w, low)
def test_variety(self):
titles = {re.search(r"<title>(.*?)</title>", p).group(1) for p in PAGES}
self.assertGreater(len(titles), N * 0.8)
layouts = {k for p in PAGES for k, m in (("hero", "place-items:center;text-align"), ("cards", "header{background"),
("nav", "nav{width"), ("notes", "article{"), ("band", ".t{background")) if m in p}
self.assertEqual(len(layouts), 5)
themes = {re.search(r"<title>.*? (\S+)</title>", p).group(1) for p in PAGES}
self.assertGreaterEqual(len(themes), 25) # последние слова названий: Coffee, Bakery, Photography…
# Ни одно окончание названия не встречается заметно чаще других (было: «Studio» у каждого пятого).
ends = [re.search(r"<title>.*? (\S+)</title>", p).group(1) for p in PAGES]
top = max(set(ends), key=ends.count)
self.assertLess(ends.count(top), N * 0.08, (top, ends.count(top)))
class SamePage(unittest.TestCase):
"""Hysteria2 (masquerade) отдаёт заглушку байт в байт как nginx – с последним переводом строки."""
def read(self, line, var):
import tempfile
d = tempfile.mkdtemp()
f = os.path.join(d, "index.html")
with open(f, "wb") as fh:
fh.write(b"<html>\n<p>x</p>\n\n")
line = line.replace("/var/www/kit/index.html", f)
r = subprocess.run(["bash", "-c", line + '\nprintf %s "$' + var + '"'], capture_output=True)
return r.stdout
def test_installer(self):
line = re.search(r"^ STUB_HTML=\$\(cat /var/www/kit/index\.html.*$", src, re.M).group(0)
self.assertEqual(self.read(line, "STUB_HTML"), b"<html>\n<p>x</p>\n\n")
def test_kit_masq(self):
kit = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
line = re.search(r"^ page=\$\(cat /var/www/kit/index\.html.*$", kit, re.M).group(0)
self.assertEqual(self.read(line, "page"), b"<html>\n<p>x</p>\n\n")
if __name__ == "__main__":
unittest.main()