Compare commits

..
34 Commits
Author SHA1 Message Date
hacker07 31dd4d575b подпись релиза 1.2.1.9
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 18:59:16 +03:00
hacker07 c4f7c4f710 v1.2.1.9: kit fix снова выполняется при установке; убрано только описание команды 2026-10-14 18:57:00 +03:00
hacker07 19eb77be68 подпись релиза 1.2.1.8
Синхронизация с автором / sync (push) Failing after 2s
2026-10-14 18:30:20 +03:00
hacker07 333b8edc9a release.sh: проверка меню через python из PATH (python3 есть не везде) 2026-10-14 18:27:05 +03:00
hacker07 13dfb44f3f v1.2.1.8: подсказка меню 0-11, тест меню в гейте, без kit fix и лишних строк в установке 2026-10-14 18:25:33 +03:00
hacker07 3a8518651c подпись релиза 1.2.1.7
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 18:05:59 +03:00
hacker07 915dd35f42 v1.2.1.7: переставлен порядок — сначала качаем все файлы релиза, потом проверяем установщик 2026-10-14 18:03:57 +03:00
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
14 changed files with 1921 additions and 510 deletions
+187
View File
@@ -1,3 +1,190 @@
## v1.2.1.9: kit fix снова выполняется при установке
* В 1.2.1.8 я понял просьбу слишком широко и **выключил сам `kit fix`** — вместе с ним пропала и
синхронизация времени. Возвращено: `kit fix` при установке выполняется как раньше (время, службы, права),
ключ `--no-time` его пропускает.
* Убрана только та строка, о которой шла речь: описание команды «kit fix – безопасные исправления
(службы, права, автообновление, сертификат, время)» больше не печатается — результат («Чинить нечего»
или список найденного) остаётся.
* Наша строка шага сокращена: «kit fix: безопасные починки (время, службы, права)».
## v1.2.1.8: подсказка меню исправлена, установка стала тише
* **«Выбор [0-10]» при 11 пунктах** — подсказка главного меню отстала, когда мы добавили пункт «Доступ к
панели, ссылки и заглушка». Теперь «Выбор [0-11]». Чтобы это не повторилось, добавлен тест
`tools/check-menus.py`: он сверяет подсказку каждого меню с числом пунктов и запускается в релизном
гейте — если мы снова добавим пункт и забудем подсказку, выпуск остановится.
* **`kit fix` больше не выполняется при установке** (в выводе это выглядело как «Чинить нечего: всё в
порядке»). Нужен — запускается из меню или ключом `--time`.
* **Вывод про автообновления безопасности сокращён до одной строки** («автообновления безопасности
включены…»): три строки про ядро и перезагрузку убраны из установки.
## v1.2.1.7: исправлена установка (моя регрессия в 1.2.1.6)
* В 1.2.1.6 я переписал загрузку файлов релиза и оставил проверку SHA256 установщика **раньше**
скачивания — установка обрывалась на первой же строке: `sha256sum: .../scripts/3x-ui.sh: No such file
or directory`. Теперь сначала скачиваются все файлы релиза (каждый сверяется с подписанным списком),
и только потом проверяется установщик.
* Проверено не на глаз: воспроизвёл ошибку у себя тем же путём (`--verify-only`) — красное, затем после
правки зелёное. Этот прогон добавлен в процедуру выпуска: перед подписью я запускаю установщик в режиме
`--verify-only` и убеждаюсь, что путь «скачать и проверить релиз» проходит целиком.
## 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 открывается прямо перед выпуском сертификата
* Настоящая причина сбоя сертификата (проверено на живом сервере): установщик открывает список
+20
View File
@@ -190,6 +190,26 @@ bash tools/fork-sync.sh # подтянуть автора, перен
# при конфликте — разобрать вручную: инструмент сам ничего не публикует
bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key # SHA256SUMS + подпись нашим ключом
**Перед подписью — обязательный прогон.** `bash scripts/3x-ui-local.sh --verify-only` проверяет ровно тот
путь установки, которым пойдут серверы: скачивание всех файлов релиза и сверка каждого с подписанным
списком. На Windows запускать с `TMPDIR=C:/…` (нативные утилиты не понимают MSYS-пути).
**Нумерация версий.** Основа — версия автора, наша надстройка добавляется через точку с единицы:
у автора 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
```
+9 -9
View File
@@ -1,9 +1,9 @@
# 3X-UI KIT 1.2.22
# 3X-UI_autoVPS 1.2.22
8d090a042d1d243203b6c0f726ee2f81b00aa1c553924ae7fe239e6d324e420d scripts/3x-ui.sh
4d046772692ee7007bc47d202317623e2524fb04a0b4037aae10bfaa2fffc72c scripts/hysteria2.sh
04aed8fd115159269fe45e79b31614778a6b23b53d45b5ab6db17e729ffabf24 scripts/kit.sh
4027ea2c6dbc9fd176356acac3f676445419c2f021146ed25dfe5a304c3ac5fb scripts/kit-sub.py
c38e5fe05bec66e747bb3feee2af4444d3a53d66e74ef1acb59861fbafa65e4c scripts/kit-local.sh
c219de310f9b0af6d83995fbe708f0f04b9884f924aa7d0095b7a7555c3b7bf7 scripts/3x-ui-local.sh
552d1b0bcdbecc5cc39f8b9d98564f34edfaefeda6478e91eb54176935bde53a VERSION
# 3X-UI KIT 1.2.1.9
# 3X-UI_autoVPS 1.2.1.9
840b814246e0676a474511a26466b7b3406a8210c2b1e7097579b578d7c71257 scripts/3x-ui.sh
a85f01f285156131e0a51a9de5fb2bc8ea76d5b49763bf0d544ca294b69173ed scripts/hysteria2.sh
89b6c8635d77558dd361cf64c5a01cde08fbe2964ad6f63ba22c17599f1e620c scripts/kit.sh
f214671464e1c2e174e5af9df42c95567a9b4a9141f31a39f7df2e971e89d240 scripts/kit-sub.py
5d07c87452057e1c73e0f3af008074f94f5be6175817b2d6f94a93f69b0533b7 scripts/kit-local.sh
bfaac8949c1e9f92fe92f1f0f6ff59ac7f37fad74cbf62439f327bc3144a8a06 scripts/3x-ui-local.sh
bb43cbd01c2071d4aacdce23a00da7d86a126799e511a521c8a3c6818001a670 VERSION
+2 -2
View File
@@ -1,6 +1,6 @@
-----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV
FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
AAALc3NoLWVkMjU1MTkAAABAhKwW7tjjJQf7d0Dr+17/JrDW93JXMDNPAn4+88utnpBq5a
lHiMvRPEFnp6UOXR98Ee+TDIyVT3NExbdyOAhqDg==
AAALc3NoLWVkMjU1MTkAAABA+3t9MmBZ+iwMvnKIfkihiqb0dX/qo8Ca5pTjBFec4v5ulG
L4RtQ+cG1mBiv66u5h6Lh+yI/JgNzd/0Yccr5NBw==
-----END SSH SIGNATURE-----
+1 -1
View File
@@ -1 +1 @@
1.2.22
1.2.1.9
+105 -68
View File
@@ -32,7 +32,6 @@
# --no-harden не трогать ping и автообновления
# --ssh-port N добавить порт SSH N (прежний порт продолжает работать)
# --yes ни о чём не спрашивать (ping закрою, автообновления включу, порт и пароль — нет)
# --no-time не запускать kit fix
# --no-check не запускать в конце kit check
# --clean убрать прошлую установку и поставить заново (SSH не трогает)
# --auto-sni не предлагать выбор сайта маскировки (иначе покажем меню по скану подсети)
@@ -44,7 +43,7 @@
set -Eeuo pipefail
KIT_VERSION="1.2.22" # версия сборки форка; должна совпадать с VERSION релиза
KIT_VERSION="1.2.1.9" # версия сборки форка; должна совпадать с VERSION релиза
GITEA="${KIT_FORK_BASE:-https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS}"
NS="3x-ui-kit-release"
SIG_ID="releases@3x-ui-kit"
@@ -83,7 +82,7 @@ usage() {
--no-sub не показывать в конце подписку, доступ к панели и QR-код
--clean снести прошлую установку и поставить заново (если прошлый запуск оборвался)
--auto-sni не предлагать выбор сайта маскировки (автоподбор автора)
--no-time не запускать kit fix (время и безопасные починки)
--no-time не запускать kit fix при установке (время, службы, права)
--no-check не запускать в конце kit check
--verify-only только проверить релиз (подпись и SHA256) и выйти
--dry-run показать план и ничего не делать
@@ -162,7 +161,7 @@ if [[ $dry == yes ]]; then
random) pre "заглушка: случайный ИТ-сайт из нашего набора (10 штук)" ;;
*) pre "заглушка: сайт №$site из нашего набора (список: kit local site --list)" ;;
esac
[[ $fix_time == yes ]] && pre "после: kit fix (время, безопасные починки)"
[[ $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
@@ -179,7 +178,7 @@ 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 scripts/3x-ui.sh; do
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
@@ -188,9 +187,21 @@ ssh-keygen -Y verify -f "$tmp/allowed_signers" -I "$SIG_ID" -n "$NS" -s "$tmp/SH
|| 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 нет установщика — релиз собран неверно"
# Качаем ВСЕ файлы релиза и сверяем каждый с подписанным списком. Так у нас есть и scripts/3x-ui.sh
# (установщик), и scripts/kit-local.sh (наша страница-заглушка), и всё остальное. Раньше брали только
# установщик: заглушка не готовилась («kl_site_html: command not found»), а проверка целостности была неполной.
while read -r sum2 name2; do
case $sum2 in \#* | "") continue ;; esac
case $name2 in SHA256SUMS | SHA256SUMS.sig) continue ;; esac
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"
got=$(sha256sum "$tmp/scripts/3x-ui.sh" | awk '{print $1}')
[[ $got == "$sum" ]] || die "SHA256 установщика не совпал с подписанным — не запускаю"
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
@@ -276,11 +287,11 @@ ask_protocols() { # печатает итоговый список через з
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
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» — нужны номера из списка через запятую или «все»"
warn "не понял «$ans» — нужны номера из списка через пробел или «все»"
done
warn "оставляю набор по умолчанию"
printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"
@@ -446,6 +457,28 @@ else
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
@@ -462,7 +495,9 @@ fi
# Сайт-заглушка: у автора это случайные «кофейни и пекарни», у нас — правдоподобный ИТ-сайт.
# Меняем не файл вручную, а командой форка: она же обновляет копию страницы в ответе Hysteria2,
# иначе 443/tcp и UDP-порт начнут отвечать разными страницами (это видно снаружи).
case $site in
case ${stub_staged:-no} in
yes) say "заглушка уже стоит (поставлена при установке, Hysteria2 синхронизирован тем же файлом)" ;;
*) case $site in
off) say "заглушку не трогаю (--no-site): остаётся страница автора" ;;
random)
say "ставлю сайт-заглушку: случайный ИТ-сайт из нашего набора"
@@ -472,10 +507,11 @@ case $site in
say "ставлю сайт-заглушку №$site"
kit local site "$site" || warn "не удалось поставить заглушку — позже: kit local site $site"
;;
esac ;;
esac
if [[ $fix_time == yes ]]; then
say "kit fix: синхронизация времени и безопасные починки (авторская команда, ничего необратимого)"
say "kit fix: безопасные починки (время, службы, права)"
kit fix || warn "kit fix завершился с замечаниями — посмотрите вывод"
fi
@@ -500,67 +536,68 @@ if [[ -n $ssh_port ]]; then
kit local ssh "$ssh_port" || warn "порт не добавился — сделайте отдельно: kit local ssh $ssh_port"
fi
# ------------------------------------------------------------------ проверка
# ------------------------------------------------------------------ итог: коротко и по делу
if [[ $do_check == yes ]]; then
say "проверяю, что получилось"
kit check || warn "kit check нашёл замечания — читайте выше; безопасные починки: kit fix"
kit local hardening
fi
cat <<EOF
${G}${B}Готово.${N} Сервер поставлен из нашей сборки (3X-UI_autoVPS $KIT_VERSION, канал: наш Gitea).
подписка и ссылки: kit user link admin (или страница подписки из вывода установщика)
если что-то не так: kit check --deep, затем kit fix
вернуться к авторскому каналу: положить авторский kit поверх и выполнить kit update --force
EOF
# ------------------------------------------------------------------ SSH: как закрыть прежние порты
if [[ -n ${ssh_port:-} ]]; then
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
${Y}${B}SSH переведён на порт $ssh_port.${N} Прежние порты (${ssh_ports_before:-22}) пока работают — это
сделано специально, чтобы вы не потеряли доступ. Порядок обязательный:
1) во ВТОРОМ окне проверьте вход: ssh -p $ssh_port root@${HOST:-<IP сервера>}
2) вход работает — закройте прежние порты: ${B}kit local ssh --close-old $ssh_port${N}
3) поправьте порт у себя: ~/.ssh/config, терминалы, мониторинг
${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
# ------------------------------------------------------------------ доступ к панели (перед подпиской)
if [[ $show_sub == yes ]]; then
say "панель 3X-UI: адрес, логин и пароль"
if [[ -s /root/3x-ui.txt ]]; then
printf ' файл: /root/3x-ui.txt (виден только root). Сохраните данные себе:\n'
sed 's/^/ /' /root/3x-ui.txt
else
warn "файла /root/3x-ui.txt нет — адрес панели и логин были в выводе установщика выше"
fi
fi
# ------------------------------------------------------------------ подписка, QR-код и доступы
# Показываем в самом конце, после всех проверок: это то, что человек уносит с собой.
# Печатает авторская команда (подписка + QR-код через qrencode + ссылки по протоколам),
# поэтому вывод не расходится с тем, что показывает `kit` потом.
if [[ $show_sub == yes ]]; then
sub_user=admin
for ((_i = 0; _i < ${#INSTALLER_ARGS[@]}; _i++)); do
[[ ${INSTALLER_ARGS[_i]} == --user ]] && sub_user=${INSTALLER_ARGS[_i + 1]:-admin}
done
say "подписка и доступы — это то, что стоит сохранить"
printf ' подписка — одна ссылка на все устройства; QR-код ниже наводите камерой в приложении\n'
printf ' приложения: Happ, Hiddify, Karing, v2rayN, Clash/Mihomo\n'
printf ' отдельные ссылки по протоколам — ниже же (если приложение не умеет подписку)\n'
printf ' %sпозже то же самое: kit user link %s --all (или меню: kit)%s\n' "$D" "$sub_user" "$N"
[[ -n ${ssh_port:-} ]] && printf ' SSH: новый порт %s, прежние (%s) ещё открыты — закройте их после проверки входа: kit local ssh --close-old %s\n' "$ssh_port" "${ssh_ports_before:-22}" "$ssh_port"
if ! kit user link "$sub_user" --all; then
warn "не смог показать ссылки для «$sub_user» — список пользователей: kit user list"
fi
else
say "ссылки и QR-код не показываю (--no-sub). Посмотреть: kit user link admin --all"
fi
+395 -111
View File
@@ -16,6 +16,15 @@ set -Eeuo pipefail
# На свежем VPS в фоне идут автообновления системы и держат замок dpkg: ждём его, а не падаем.
apt-get() { command apt-get -o DPkg::Lock::Timeout=900 "$@"; }
# Обновление списков пакетов. Чужой сломанный репозиторий (например, speedtest-cli без файла Release для этой версии системы)
# не должен ронять установку: предупреждаем и идём дальше, а если нужных пакетов не окажется – об этом скажет следующий шаг.
apt_refresh() {
local out
out=$(apt-get update -qq 2>&1) && return 0
warn "apt update завершился с ошибкой (чаще всего виноват посторонний репозиторий на сервере, не наш). Продолжаю."
{ grep -E '^(E|W):' <<<"$out" | head -5 | sed 's/^/ /'; } >&2 || true
return 0
}
wait_apt_idle() {
local i
pgrep -f '/usr/bin/unattended-upgrade$|apt\.systemd\.daily' >/dev/null || return 0
@@ -30,7 +39,7 @@ wait_apt_idle() {
XUI_VERSION="v3.9.0"
# SHA256 установщика 3X-UI этой версии: тег могут передвинуть, а хеш – нет (проверено 2026-09-30).
XUI_INSTALL_SHA256="18616fe26c8f6c92db6daa2dcd7cd53c5143ee69ecfd26d8ea6dc9b2c78607a6"
KIT_VERSION="1.2.22"
KIT_VERSION="1.2.1.9"
# kit и kit-sub берём из того же релиза, что и этот скрипт, а не из меняющейся ветки main.
KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$KIT_VERSION"
# Ядро Xray для панели. С 26.7.x клиенты на Mihomo и sing-box (Hiddify, FlClash,
@@ -54,8 +63,7 @@ XUI_ENV=/etc/x-ui/install-result.env
SNI_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)
ALL_PROTOS=(reality reality2 hy2 xhttp ws trojan vmess ss tuic wg awg awg3 mtproto)
# Обычный WireGuard легко распознаётся сетевым оборудованием и работает нестабильно
# (проверено 2026-09-27: рукопожатие доходит до сервера, ответ – нет). По умолчанию не ставим.
# Стандартный набор; остальные протоколы (например, wg) отмечаются в «На выбор» или через --protocols.
DEFAULT_PROTOS=(reality hy2 xhttp ws trojan vmess ss tuic awg awg3 mtproto)
declare -A PORTS=([xhttp]=8443 [ws]=2053 [trojan]=2083 [vmess]=2087 [ss]=8388 [tuic]=8444 [wg]=51820 [awg]=51821 [awg3]=51822 [mtproto]=8445)
PROTOS=(); CREATED=(); OPEN=(); NB_ALL=()
@@ -79,9 +87,61 @@ WARNINGS=(); STEP=0; STEPS=6
say() { printf ' %s\n' "$*"; } # подробность под шагом
ok() { printf ' %s\n' "${G}✓${N} $*"; }
step() { STEP=$((STEP + 1)); printf '\n%s\n' "${B}[$STEP/$STEPS]${N} $1"; }
warn() { printf '%s\n' "${Y}!${N} $*" >&2; } # сразу (диалоги, ошибки перед остановкой)
warn() { spin_stop; printf '%s\n' "${Y}!${N} $*" >&2; } # сразу (диалоги, ошибки перед остановкой)
# A-записи домена. Сначала публичный DNS (DoH): локальный резолвер сервера может помнить «записи нет» с тех пор, как её не было,
# и kit тогда спорит с тем, что вы видите у регистратора. Нет ответа от публичного DNS – берём системный.
dns_a() { # домен → IPv4 по одному в строке
local r
r=$(curl -fsS -m 6 -H 'accept: application/dns-json' "https://1.1.1.1/dns-query?name=$1&type=A" 2>/dev/null \
| grep -oE '"data":"[0-9]{1,3}(\.[0-9]{1,3}){3}"' | cut -d'"' -f4 | sort -u || true)
[[ -n $r ]] || r=$(getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u || true)
printf '%s' "$r"
}
later() { WARNINGS+=("$*"); } # в блок «Внимание» в конце
die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
die() { spin_stop; printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
# --- Оформление вывода: ожидание и советы ---------------------------------------------------------
# Анимация ожидания: пока скрипт занят долгой работой без вывода (опрос соседей по подсети, установка пакетов),
# крутится индикатор и идут секунды, чтобы не казалось, что всё зависло. Без терминала (лог, pipe) – одна строка.
SPIN_PID=""
spin_start() { # текст
spin_stop
if [[ ! -t 2 ]]; then printf '%s…\n' "$1" >&2; return 0; fi
local msg=$1 start=$SECONDS parent=$$
(
local -a fr=('|' '/' '-' '\\') i=0
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in *[Uu][Tt][Ff]-8* | *[Uu][Tt][Ff]8*) fr=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) ;; esac
while kill -0 "$parent" 2>/dev/null; do
printf '\r %s %s %s с ' "${fr[i % ${#fr[@]}]}" "$msg" "$((SECONDS - start))" >&2
i=$((i + 1)); sleep 0.1
done
) &
SPIN_PID=$!
}
spin_stop() {
[[ -n $SPIN_PID ]] || return 0
kill "$SPIN_PID" 2>/dev/null || true
wait "$SPIN_PID" 2>/dev/null || true
SPIN_PID=""
[[ -t 2 ]] && printf '\r\033[K' >&2
return 0
}
# Блок «Что дальше»: советы и рекомендации отделены от хода работы рамкой и нумерацией. Пункты – обычные фразы,
# без команд: пользователя ведём в меню kit.
tips() { # заголовок, затем пункты
local title=$1 n=0 item row first
shift
printf '\n%s┌─%s %s%s%s\n' "$D" "$N" "$B" "$title" "$N"
for item in "$@"; do
n=$((n + 1)); first=yes
while IFS= read -r row; do
if [[ $first == yes ]]; then printf '%s│%s %s%d.%s %s\n' "$D" "$N" "$B" "$n" "$N" "$row"; first=no
else printf '%s│%s %s\n' "$D" "$N" "$row"; fi
done < <(if command -v python3 >/dev/null 2>&1; then python3 -c 'import sys,textwrap; t=sys.argv[1].replace(" → ","\u00a0→\u00a0"); print("\n".join(textwrap.wrap(t, 74, break_on_hyphens=False, break_long_words=False)).replace("\u00a0"," "))' "$item"; else printf '%s\n' "$item"; fi)
done
printf '%s└─%s\n' "$D" "$N"
}
trap 'die "Ошибка в строке $LINENO. Исправьте причину и запустите скрипт ещё раз."' ERR
rand_str() { openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$1"; }
@@ -201,7 +261,7 @@ nearby_sites() {
domain_points_here() { # домен
local me=$HOST ips
[[ $me =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || me=$(public_ip)
ips=$(getent ahostsv4 "$1" 2>/dev/null | awk '{print $1}' | sort -u)
ips=$(dns_a "$1")
if [[ -z $ips ]]; then
warn "У домена $1 нет A-записи. Добавьте её у регистратора: тип A, значение $me."
elif ! grep -qx "$me" <<<"$ips"; then
@@ -224,22 +284,75 @@ panel_on_choice() {
echo "${B}Где открывать панель и подписку?${N}"
echo " ${B}1${N} По IP https://$HOST/… как раньше ${G}[по умолчанию]${N}"
echo " ${B}2${N} По домену https://$DOMAIN/… сертификат домена, ссылка не зависит от IP"
echo " ${D}Менять можно потом: kit net panel domain | ip${N}"
echo " ${D}Менять можно потом: kit → Протоколы, порты и маскировка → Панель и подписка: по IP или по домену${N}"
ask_tty "Выбор [1]: " || return 0
REPLY=${REPLY//[[:space:]]/}
[[ ${REPLY%.} == 2 ]] && PANEL_ON=domain
return 0
}
# Вопрос пользователю: свой домен, сосед по подсети (по умолчанию) или свой сайт.
# Вопрос пользователю: все протоколы (стандарт) или только нужные. Меньше протоколов – меньше памяти и портов.
choose_protocols() {
local -a names=(
"reality|REALITY (TCP 443)|основной, самый устойчивый"
"reality2|REALITY №2|запасной, со своим сайтом маскировки"
"hy2|Hysteria2 (UDP)|быстрый на плохих каналах"
"xhttp|XHTTP + REALITY|если обычный REALITY у вас не подключается"
"ws|VLESS WebSocket|через CDN"
"trojan|Trojan|"
"vmess|VMess|"
"ss|Shadowsocks|"
"tuic|TUIC (UDP)|"
"awg|AmneziaWG|"
"awg3|AmneziaWG 3.1|"
"mtproto|MTProto для Telegram|"
"wg|WireGuard|"
)
echo
echo "${B}Какие протоколы ставить?${N}"
echo
echo " ${B}1${N} Стандарт основной набор ${G}[по умолчанию]${N}"
echo " ${B}2${N} На выбор только нужные: сервер слабее нагружен, портов меньше"
echo " ${D}Потом можно включить или выключить: kit → Протоколы, порты и маскировка → Выключить или включить протокол${N}"
ask_tty "Выбор [1]: " || return 0
REPLY=${REPLY//[[:space:]]/}
[[ ${REPLY%.} == 2 ]] || return 0
echo
local i=0 e
for e in "${names[@]}"; do
i=$((i + 1)); IFS='|' read -r _ title note <<<"$e"
printf ' %s%2d%s %s%s\n' "$B" "$i" "$N" "$title" "${note:+ $D– $note$N}"
done
while :; do
ask_tty "Номера через запятую, например 1,3,12 (Enter – стандарт): " || return 0
REPLY=${REPLY//[[:space:]]/}
[[ -n $REPLY ]] || return 0
local -a pick=() chosen=(); local bad="" n key
IFS=, read -ra pick <<<"$REPLY"
for n in "${pick[@]}"; do
if [[ $n =~ ^[0-9]+$ ]] && (( n >= 1 && n <= ${#names[@]} )); then
key=${names[n-1]%%|*}
[[ " ${chosen[*]:-} " == *" $key "* ]] || chosen+=("$key")
else bad=$n; break; fi
done
[[ -z $bad ]] || { warn "«$bad» – нет такого номера. Нужны числа от 1 до ${#names[@]}."; continue; }
((${#chosen[@]})) || { warn "Выберите хотя бы один протокол: например 1,3."; continue; }
PROTOS=("${chosen[@]}")
echo " Ставлю: ${PROTOS[*]}"
return 0
done
}
# Вопрос пользователю: свой домен, сосед по подсети (по умолчанию) или другой сайт.
choose_masking() {
echo
echo "${B}Под какой сайт маскировать сервер?${N}"
echo "Со стороны он будет выглядеть как обычный сайт."
echo
echo " ${B}1${N} Свой домен самый надёжный: нужен домен и свободный порт 80 ${G}[рекомендуем]${N}"
echo " ${B}1${N} Свой домен ваш домен на этот сервер: самый надёжный ${G}[рекомендуем]${N}"
echo " ${D}(нужна A-запись домена и свободный порт 80)${N}"
echo " ${B}2${N} Сосед по подсети подберу сам, домен не нужен ${G}[рекомендуем]${N}"
echo " ${B}3${N} Свой сайт вы вводите адрес сами"
echo " ${B}3${N} Другой сайт вы сами вводите адрес любого сайта (подходит хуже соседа)"
echo " ${D}Сертификат своего домена виден в публичных журналах сертификатов: связь «домен – сервер» не скрыта.${N}"
echo
local d="" prev=""
@@ -278,6 +391,7 @@ choose_masking() {
# Продлевает его тот же cron, а после продления nginx перечитывает сертификат.
issue_domain_cert() {
local acme=/root/.acme.sh/acme.sh rc=0 log=/var/log/kit-domain-cert.log
ufw_open_80
if [[ -s $DOMAIN_CERT_DIR/fullchain.pem && -s $DOMAIN_CERT_DIR/privkey.pem ]] \
&& openssl x509 -in "$DOMAIN_CERT_DIR/fullchain.pem" -noout -checkhost "$DOMAIN" 2>/dev/null | grep -q 'does match' \
&& openssl x509 -in "$DOMAIN_CERT_DIR/fullchain.pem" -noout -issuer 2>/dev/null | grep -q "Let's Encrypt" \
@@ -286,11 +400,21 @@ issue_domain_cert() {
return 0
fi
[[ -x $acme ]] || { warn "Не нашёл acme.sh, которым установщик 3X-UI получает сертификаты: не могу выпустить сертификат для $DOMAIN."; return 1; }
# Наш шов: лимит Let's Encrypt считается на точный набор имён (5 сертификатов за 7 суток). Если у acme.sh
# уже лежит годный сертификат на этот домен — берём его и не заказываем новый, лишний заказ упирается в лимит.
local need_issue=yes
if [[ -s /root/.acme.sh/${DOMAIN}_ecc/fullchain.cer ]] \
&& openssl x509 -in "/root/.acme.sh/${DOMAIN}_ecc/fullchain.cer" -noout -checkhost "$DOMAIN" 2>/dev/null | grep -q 'does match' \
&& openssl x509 -in "/root/.acme.sh/${DOMAIN}_ecc/fullchain.cer" -noout -checkend 86400 >/dev/null 2>&1; then
ok "у acme.sh уже есть годный сертификат для ${B}$DOMAIN${N} — беру его, новый не заказываю"
need_issue=no
fi
port_busy 80 tcp && { warn "Порт 80/tcp занят: Let's Encrypt не сможет проверить домен $DOMAIN."; return 1; }
say "Получаю сертификат Let's Encrypt для ${B}$DOMAIN${N}"
install -m 600 /dev/null "$log"
# Сбои бывают временными (например, ошибка TLS при скачивании готового сертификата): до трёх попыток.
local try
spin_start "Жду ответа Let's Encrypt"
for try in 1 2 3; do
rc=0
"$acme" --issue -d "$DOMAIN" --standalone --httpport 80 --server letsencrypt --keylength ec-256 ${rc_force:-} >>"$log" 2>&1 || rc=$?
@@ -298,8 +422,19 @@ issue_domain_cert() {
local rc_force="--force"
sleep 10
done
spin_stop
# 2 – сертификат уже свежий, выпускать заново не нужно.
[[ $rc == 0 || $rc == 2 ]] || { warn "Let's Encrypt не выдал сертификат для $DOMAIN. Обычно дело в одном из трёх: A-запись ещё не обновилась, порт 80 закрыт у хостера или домен за проксированием Cloudflare. Лог: $log"; return 1; }
[[ $rc == 0 || $rc == 2 ]] || {
# Наш шов: лимит Let's Encrypt — частая причина, и она не про сервер. Говорим прямо и с датой повтора.
if grep -qE 'rateLimited|too many certificates' "$log"; then
local till
till=$(grep -oE 'retry after [0-9-]+ [0-9:]+ UTC' "$log" | tail -1 | sed 's/^retry after //')
warn "Лимит Let's Encrypt: на имя ${B}$DOMAIN${N} за 7 суток уже выпущено 5 сертификатов${till:+ — повтор возможен после $till}. С вашим сервером всё в порядке: DNS, порты и ufw не при чём. Подождите до этой даты либо возьмите другое имя (например node3.…): лимит считается на точный набор имён."
else
warn "Let's Encrypt не выдал сертификат для $DOMAIN. Обычно дело в одном из трёх: A-запись ещё не обновилась, порт 80 закрыт у хостера или домен за проксированием Cloudflare. Лог: $log"
fi
return 1
}
# Каталог и ключ – только для root (nginx читает их от root).
install -d -m 700 "$DOMAIN_CERT_DIR"
(umask 077; "$acme" --install-cert -d "$DOMAIN" --ecc --fullchain-file "$DOMAIN_CERT_DIR/fullchain.pem" \
@@ -316,11 +451,21 @@ issue_domain_cert() {
ip_cert_self_signed_fallback() {
local san="IP:$HOST"
[[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$HOST"
# Наш шов: если причина — лимит Let's Encrypt, говорим это прямо и с датой повтора, как для домена.
# Иначе человек читает «частые причины» и ищет проблему у хостера, которой нет.
local till
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 //')
if grep -rqsE 'rateLimited|too many certificates' /var/log/3x-ui-install.log 2>/dev/null; then
warn "Причина: лимит Let's Encrypt — на этот IP за 7 суток уже выпущено 5 сертификатов${till:+; повтор возможен после $till}. Это ограничение Let's Encrypt, а не вашего сервера: порт 80 и файрвол тут не при чём."
else
warn "Let's Encrypt не выдал сертификат на IP $HOST. Частые причины:"
{
echo " – лимит Let's Encrypt: 5 сертификатов в неделю на один IP;"
echo " – входящий порт 80 закрыт (у хостера или в файрволе);"
echo " – сбой у самого Let's Encrypt (лог: /var/log/3x-ui-install.log)."
} >&2
fi
{
echo " Можно продолжить с самоподписанным сертификатом: ссылки на подключения работают, браузер покажет"
echo " предупреждение, а подписка в приложениях может не открыться."
} >&2
@@ -345,6 +490,16 @@ ip_cert_self_signed_fallback() {
return 0
}
# Сайт default в sites-enabled – нетронутый файл из пакета nginx (md5 сверяем с тем, что записал dpkg)?
# Нет файла – тоже да: прошлый запуск kit его уже убрал. Правленый default – чужой сайт.
nginx_default_stock() {
local f=/etc/nginx/sites-enabled/default want got
[[ -e $f ]] || return 0
want=$(dpkg-query -W -f='${Conffiles}\n' nginx-common 2>/dev/null | awk '$1 == "/etc/nginx/sites-available/default" {print $2}') || true
got=$(md5sum <"$f" 2>/dev/null | awk '{print $1}') || true
[[ -n $want && $got == "$want" ]]
}
# Куда REALITY отправляет чужих гостей: для своего домена – на nginx этого сервера.
reality_target() { # сайт
if [[ -n $DOMAIN && $1 == "$DOMAIN" ]]; then echo "127.0.0.1:${INNER[selfweb]}"; else echo "$1:443"; fi
@@ -355,9 +510,9 @@ reality_target() { # сайт
api() { # METHOD path [json]
local url="$API/$2" out
if [[ $1 == GET ]]; then
out=$(curl -fsSk -m 20 -H "Authorization: Bearer $TOKEN" "$url")
out=$(curl -fsSk -m 20 -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") "$url")
else
out=$(curl -fsSk -m 20 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -X "$1" -d "$3" "$url")
out=$(curl -fsSk -m 20 -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") -H 'Content-Type: application/json' -X "$1" --data-binary @<(printf '%s' "$3") "$url")
fi
[[ $(jq -r '.success' <<<"$out") == true ]] || die "Панель ответила ошибкой на $2: $(jq -r '.msg // .' <<<"$out" | head -c 300)"
jq -c '.obj' <<<"$out"
@@ -366,7 +521,7 @@ api() { # METHOD path [json]
wait_panel() {
local i
for i in $(seq 1 60); do
curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $TOKEN" "$API/server/getNewUUID" 2>/dev/null && return 0
curl -fsk -m 5 -o /dev/null -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") "$API/server/getNewUUID" 2>/dev/null && return 0
sleep 2
done
die "Панель не отвечает. Лог: journalctl -u x-ui -n 50"
@@ -426,7 +581,7 @@ main() {
die "3X-UI уже установлена другим способом – не трогаю её. Удалите её (x-ui uninstall) или добавьте REALITY в панели вручную."
fi
local PORT=443 SNI="" PANEL_SSL=auto HOST="" UFW=yes NAME="admin" yes=no protos=all ucert="" ukey="" multi=no restore=""
local PORT=443 SNI="" PANEL_SSL=auto HOST="" UFW=yes NAME="admin" yes=no protos=all protos_given=no ucert="" ukey="" multi=no restore=""
while [[ $# -gt 0 ]]; do
case $1 in
--port | --sni | --panel-ssl | --host | --user | --protocols | --domain | --cert | --key | --restore | --panel-on)
@@ -438,7 +593,7 @@ main() {
--panel-ssl) PANEL_SSL=$2; shift 2 ;;
--host) HOST=$2; shift 2 ;;
--user) NAME=$2; shift 2 ;;
--protocols) protos=$2; shift 2 ;;
--protocols) protos=$2; protos_given=yes; shift 2 ;;
--domain) DOMAIN=$2; shift 2 ;;
--cert) ucert=$2; shift 2 ;;
--multi-port) multi=yes; shift ;;
@@ -476,7 +631,7 @@ main() {
[[ $PANEL_ON == ip || $PANEL_ON == domain ]] || die "--panel-on: ip или domain"
[[ $PANEL_ON == ip || -n $DOMAIN || $yes == no ]] || die "--panel-on domain работает только со своим доменом (--domain vpn.example.com)."
[[ -z $HOST || $HOST =~ $re_host || $HOST =~ ^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$ ]] || die "--host: нужен IP (например 1.2.3.4) или домен"
[[ $NAME =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "Имя: латиница, цифры, _ . - (до 32 символов)."
[[ $NAME =~ ^[A-Za-z0-9_.-]{1,32}$ && $NAME =~ [^.] ]] || die "Имя: латиница, цифры, _ . - (до 32 символов, не только точки)."
[[ $PANEL_SSL =~ ^(auto|ip|none)$ ]] || die "--panel-ssl: auto, ip или none"
if [[ -n $ucert || -n $ukey ]]; then
[[ -s $ucert && -s $ukey ]] || die "Нужны оба файла: --cert fullchain.pem --key privkey.pem"
@@ -497,6 +652,24 @@ main() {
die "Порт $PORT/tcp уже занят. REALITY нужен свободный порт – укажите другой: --port 8443"
fi
# Прошлый запуск мог упасть после установки nginx: его сайт по умолчанию держит порт 80 и мешает выпустить сертификат.
# $XUI_ENV без $RESULT (иначе выше остановились бы) или наш kit.conf – след незавершённого запуска. kit.conf порт 80
# не слушает и остаётся после сбоя на nginx -t, поэтому чужими считаем всё, кроме него и нетронутого default из пакета;
# свои server прямо в nginx.conf – тоже чужой сайт.
if [[ -f $XUI_ENV || -f /etc/nginx/conf.d/kit.conf ]] && ss -H -ltnp "sport = :80" 2>/dev/null | grep -q nginx \
&& [[ -z $(find /etc/nginx/conf.d -mindepth 1 -name '*.conf' ! -name kit.conf 2>/dev/null) ]] \
&& [[ -z $(find /etc/nginx/sites-enabled -mindepth 1 ! -name default 2>/dev/null) ]] && nginx_default_stock \
&& ! grep -qE '^[[:space:]]*server[[:space:]]*\{' /etc/nginx/nginx.conf 2>/dev/null; then
say "Остановил стандартный сайт nginx от прошлого запуска: он занимал порт 80"
systemctl stop nginx >/dev/null 2>&1 || true
fi
# Повтор после сбоя без --cert: ваш сертификат из прошлого запуска лежит в /root/cert/custom – берём его,
# а не просим Let's Encrypt (повторный запуск его не выпускает: это делает установщик 3X-UI один раз).
if [[ $PANEL_SSL == auto && -f $XUI_ENV && -s /root/cert/custom/fullchain.pem && -s /root/cert/custom/privkey.pem ]] \
&& openssl x509 -in /root/cert/custom/fullchain.pem -noout -checkend 0 >/dev/null 2>&1; then
ucert=/root/cert/custom/fullchain.pem; ukey=/root/cert/custom/privkey.pem; PANEL_SSL=custom
say "Беру ваш сертификат из прошлого запуска: /root/cert/custom"
fi
if [[ $PANEL_SSL == auto ]]; then
if port_busy 80 tcp; then
PANEL_SSL=none
@@ -511,8 +684,9 @@ main() {
[[ $PANEL_SSL == ip || $PANEL_SSL == custom ]] && TRUSTED=yes
if [[ $PANEL_SSL == custom ]]; then
mkdir -p /root/cert/custom
install -m 644 "$ucert" /root/cert/custom/fullchain.pem
install -m 600 "$ukey" /root/cert/custom/privkey.pem
# Те же файлы (повторный запуск) install не копирует сам в себя – только права.
if [[ $ucert -ef /root/cert/custom/fullchain.pem ]]; then chmod 644 "$ucert"; else install -m 644 "$ucert" /root/cert/custom/fullchain.pem; fi
if [[ $ukey -ef /root/cert/custom/privkey.pem ]]; then chmod 600 "$ukey"; else install -m 600 "$ukey" /root/cert/custom/privkey.pem; fi
fi
kit_banner
@@ -520,7 +694,7 @@ main() {
echo "Поставлю панель 3X-UI и протоколы на этот сервер. Займёт 3–5 минут."
if ! command -v curl >/dev/null || ! command -v openssl >/dev/null; then
export DEBIAN_FRONTEND=noninteractive; wait_apt_idle
apt-get update -qq; apt-get install -y -qq curl openssl ca-certificates iproute2 >/dev/null
apt_refresh; apt-get install -y -qq curl openssl ca-certificates iproute2 >/dev/null
fi
local host_given=${HOST:+yes}
@@ -531,6 +705,7 @@ main() {
mem_gb=$(awk '/MemTotal/ {printf "%.1f", $2 / 1048576}' /proc/meminfo 2>/dev/null || echo "?")
echo
echo "Сервер: ${B}$HOST${N} · $os_name · ${mem_gb} ГБ памяти"
[[ -f $XUI_ENV && ! -f $RESULT ]] && echo "${D}Нашёл следы прерванной установки: продолжу с ними. Ответы на вопросы ниже нужны заново, прошлые выборы не сохраняются.${N}"
# IP определился снаружи, а ссылки получат именно его: у части хостеров это не адрес самого сервера.
if [[ -z $host_given && $yes == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then
ask_tty "Адрес в ссылках: $HOST. Верно? Enter – да, или введите другой IPv4: " || true
@@ -581,6 +756,8 @@ kl_sni_menu() {
if ((i == known_idx)); then
printf '\nОбщедоступные сайты:\n' >&2
for k in "${!known[@]}"; do printf ' %d - %s\n' "$((k + 1))" "${known[k]}" >&2; done
# Наш шов: последним пунктом — автовыбор из этого списка (раньше выбор был только по номеру).
printf ' %d - автовыбор из списка\n' "$((${#known[@]} + 1))" >&2
printf ' 0 - назад\nВаш выбор: ' >&2
ans=""
read -r -t 300 ans <"$src" 2>/dev/null || true
@@ -588,6 +765,11 @@ kl_sni_menu() {
if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans >= 1 && 10#$ans <= ${#known[@]})); then
printf 'sni:%s' "${known[10#$ans - 1]}"; return 0
fi
if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans == ${#known[@]} + 1)); then
local autopick="${known[RANDOM % ${#known[@]}]}"
printf ' автовыбор: %s\n' "$autopick" >&2
printf 'sni:%s' "$autopick"; return 0
fi
printf ' ничего не выбрано — оставляю автовыбор\n' >&2
return 0
fi
@@ -605,13 +787,18 @@ kl_sni_menu() {
return 0
}
# Протоколы: из флага --protocols, по вопросу пользователю или стандартный набор.
if [[ $protos_given == no && $yes == no && -z $restore && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then
choose_protocols
fi
# Маскировка: из флагов, по вопросу пользователю или автоматически (сосед по подсети).
if [[ -n $DOMAIN ]]; then
[[ $PANEL_SSL == ip ]] || die "Свой домен работает с сертификатом панели Let's Encrypt на IP: освободите порт 80 и не указывайте --cert, --key и --panel-ssl none (сертификат для домена установщик получит сам)."
domain_points_here "$DOMAIN" || die "Исправьте A-запись домена и запустите скрипт снова (DNS обновляется от нескольких минут до нескольких часов)."
# Наш шов: когда выбор сайта делает наше меню (KL_PICK_SNI=1), меню автора не показываем — иначе
# человек отвечает на «Свой домен / Сосед / Свой сайт», и до скана подсети дело не доходит.
elif [[ -z $SNI && $yes == no && $PANEL_SSL == ip && $multi == no && ! -f $XUI_ENV && -t 1 && ${KL_PICK_SNI:-0} != 1 ]] && { : </dev/tty; } 2>/dev/null; then
elif [[ -z $SNI && $yes == no && $PANEL_SSL == ip && $multi == no && -t 1 && ${KL_PICK_SNI:-0} != 1 ]] && { : </dev/tty; } 2>/dev/null; then
choose_masking
fi
if [[ -n $DOMAIN ]]; then
@@ -621,7 +808,9 @@ kl_sni_menu() {
echo "Сканирую подсеть: около 250 коротких обращений к порту 443 соседних адресов (около минуты)."
echo "По результатам предложу сайты для маскировки. Не нужно – запустите с --sni или --domain."
local -a nb=()
spin_start "Опрашиваю соседние адреса"
mapfile -t nb < <(nearby_sites | shuf)
spin_stop
NB_ALL=("${nb[@]}")
# Наш шов: показываем найденное и даём выбрать — отдельно для REALITY и для XHTTP/MTProto.
if [[ ${KL_PICK_SNI:-} == 1 && $yes == no ]] && { : </dev/tty; } 2>/dev/null; then
@@ -631,11 +820,32 @@ kl_sni_menu() {
own:*)
own_sni=yes
DOMAIN=${pick#own:}
if domain_points_here "$DOMAIN" 2>/dev/null; then
SNI=$DOMAIN; ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let's Encrypt выпустит установщик)"
else
SNI=$DOMAIN; later "домен $DOMAIN не указывает на этот сервер — маскируюсь под него как под чужой сайт"
fi
# Наш шов: домен вводится заново, пока он не указывает на этот сервер. Раньше при промахе
# установка молча маскировалась под чужой сайт, а при домене из флага — обрывалась.
# A-запись у регистратора расходится не мгновенно, поэтому первым пунктом — «проверить снова».
while :; do
if domain_points_here "$DOMAIN"; then
SNI=$DOMAIN
ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let's Encrypt выпустит установщик)"
# Наш шов: спрашиваем, где открывать панель и подписку, и при интерактивном вводе домена тоже.
# У автора этот вопрос задавался только при домене из флага --domain, а при выборе «ввести свой
# домен» в меню маскировки — нет, и панель молча оставалась по IP с самоподписанным сертификатом.
panel_on_choice
break
fi
echo " Домен ${B}$DOMAIN${N} пока не указывает на этот сервер." >&2
echo " ${B}1${N} проверить снова A-запись расходится по DNS от нескольких минут до нескольких часов" >&2
echo " ${B}2${N} ввести другой домен" >&2
echo " ${B}3${N} без своего домена: маскировка соседом по подсети" >&2
ask_tty " Выбор [1]: " || break
case ${REPLY//[[:space:]]/} in
2) ask_tty " адрес сайта (например vpn.example.com): " || break
local d=${REPLY//[[:space:]]/}
[[ -n $d ]] && DOMAIN=$d ;;
3) own_sni=no; DOMAIN=""; break ;;
*) sleep 5 ;;
esac
done
;;
sni:*) SNI=${pick#sni:} ;;
esac
@@ -668,7 +878,7 @@ kl_sni_menu() {
ask_tty " Продолжить с запасным сайтом из списка? [y/N] " || true
[[ $REPLY =~ ^[yYдД]$ ]] || die "Остановился по вашей просьбе. Ставить можно снова в любой момент."
else
later "Сайт для маскировки взят из общего списка (в подсети сервера подходящего не нашлось). Надёжнее свой домен или kit net site."
later "Сайт для маскировки взят из общего списка (в подсети сервера подходящего не нашлось). Надёжнее свой домен или kit → Протоколы, порты и маскировка → Сменить сайт маскировки."
fi
local -a pool=()
mapfile -t pool < <(printf '%s\n' "${SNI_CANDIDATES[@]}" | shuf)
@@ -694,7 +904,9 @@ kl_sni_menu() {
nb2=("${NB_ALL[@]}")
else
echo "Подбираю сайты для XHTTP и MTProto… (около 250 коротких обращений к порту 443 соседних адресов вашей подсети)"
spin_start "Опрашиваю соседние адреса"
mapfile -t nb2 < <(nearby_sites | shuf)
spin_stop
fi
for s in "${nb2[@]}"; do
[[ $s == "$SNI" || $s == "$SNI2" || $s == "$SNI3" ]] && continue
@@ -710,15 +922,14 @@ kl_sni_menu() {
[[ -z $SNI3 && -n $SNI2 ]] && { SNI3=$s; break; }
done
SNI2=${SNI2:-$SNI}; SNI3=${SNI3:-www.cloudflare.com}
if [[ $TRUSTED == yes && $multi == no && ( $SNI2 =~ $SNI_BRAND_RE || $SNI3 =~ $SNI_BRAND_RE ) ]]; then
later "XHTTP и MTProto используют известные сайты из общего списка (достаточно соседей по подсети не нашлось). Заменить: kit net site"
fi
step "Пакеты"
export DEBIAN_FRONTEND=noninteractive
wait_apt_idle
apt-get update -qq
apt_refresh
spin_start "Ставлю пакеты"
apt-get install -y -qq curl jq openssl qrencode ca-certificates iproute2 ufw socat cron unzip >/dev/null
spin_stop
ok "curl, jq, openssl, qrencode, ufw и другое"
# --- официальный установщик 3X-UI с закреплённой версией ---
@@ -728,7 +939,9 @@ kl_sni_menu() {
panel_path=$(rand_str 18)
panel_user=$(rand_str 10)
panel_pass=$(rand_str 20)
local rerun=no
if [[ -f $XUI_ENV ]]; then
rerun=yes
say "3X-UI уже стоит после прошлого запуска – продолжаю с создания подключений"
else
install_xui "$panel_port" "$panel_path" "$panel_user" "$panel_pass"
@@ -754,7 +967,12 @@ kl_sni_menu() {
if [[ -n $DOMAIN ]]; then
ip_cert_self_signed_fallback || die "Остановил установку по вашему выбору. Запустите скрипт снова без --domain (панель будет доступна через SSH-туннель) или позже, когда сертификат на IP снова можно будет получить."
else
later "Let's Encrypt не выдал сертификат на IP $HOST (порт 80 закрыт у хостера, лимит выпусков или сбой). Ставлю без него: панель будет доступна только через SSH-туннель."
if [[ $rerun == yes ]]; then
# Сертификат на IP выпускает установщик 3X-UI, а при повторном запуске он не работает.
later "Сертификата на IP нет: в прошлый запуск его не получили, а повторный запуск его не выпускает. Ставлю без него: панель будет доступна только через SSH-туннель."
else
later "Let's Encrypt не выдал сертификат на IP $HOST (порт 80 закрыт у хостера, лимит выпусков или сбой). Ставлю без него: панель будет доступна только через SSH-туннель."
fi
PANEL_SSL=none
TRUSTED=no
fi
@@ -777,6 +995,9 @@ kl_sni_menu() {
# --- сертификат для протоколов с TLS ---
setup_tls_cert
ok "ядро закреплено, сертификат для протоколов с TLS готов"
say "${D}Ядро Xray ${XRAY_CORE#v} – не самое новое, и это намеренно.${N}"
say "${D}На новых версиях клиенты на sing-box (Karing, Hiddify) перестают подключаться${N}"
say "${D}по REALITY и ещё нескольким протоколам. Перейдём на новое, когда это исправят.${N}"
# --- подключения: все выбранные протоколы, один subId на пользователя ---
step "Подключения"
@@ -791,9 +1012,45 @@ kl_sni_menu() {
SINGLE=yes
fi
fi
# Повторный запуск после сбоя: REALITY уже за nginx (слушает localhost). С доверенным сертификатом режим «всё на 443»
# включился выше, и nginx с картой имён соберётся заново. Без сертификата (или с --multi-port) так нельзя: на 443 никого,
# подписку не выдать, а ссылки через nginx клиенты не примут. Честно останавливаемся с подсказкой.
if [[ $SINGLE != yes ]] && jq -e 'any(.[]; .remark == "REALITY" and .listen == "127.0.0.1")' <<<"$EXISTING" >/dev/null; then
local how="запустите установщик со своим сертификатом: --cert fullchain.pem --key privkey.pem"
[[ -s /root/cert/custom/fullchain.pem ]] && how="запустите установщик ещё раз без --panel-ssl none: ваш сертификат из прошлого запуска (/root/cert/custom) возьму сам"
[[ -s /root/cert/ip/fullchain.pem ]] && how="освободите порт 80/tcp и запустите установщик ещё раз (без --panel-ssl none): сертификат Let's Encrypt от прошлого запуска на месте"
[[ $multi == yes && $TRUSTED == yes ]] && how="запустите установщик ещё раз без --multi-port"
die "Прошлый запуск собрал подключения для режима «всё на 443» (за nginx), а ему нужен доверенный сертификат: Let's Encrypt на IP или ваш. Дальше не ставлю – иначе на 443 никого, а подписка не откроется. Что сделать: $how. Или начать с чистого листа: x-ui uninstall, затем установщик заново."
fi
if [[ -n $DOMAIN ]]; then
[[ $SINGLE == yes ]] || die "Свой домен работает только в режиме «всё на 443», а эта установка уже работает с отдельными портами."
issue_domain_cert || die "Без сертификата для $DOMAIN продолжать нельзя. Исправьте причину и запустите скрипт снова."
# Наш шов: провал сертификата не обрывает установку. Человек может повторить попытку (DNS доехал,
# лимит Let's Encrypt кончился) или ввести другое имя — установка продолжится с него же.
while ! issue_domain_cert; do
echo
echo " ${B}Что делать дальше?${N}"
echo " ${B}1${N} повторить попытку если ждали обновления A-записи или лимита Let's Encrypt"
echo " ${B}2${N} ввести другой домен проверим, что он указывает на этот сервер, и повторим"
echo " ${B}3${N} остановиться поставить без своего домена: перезапустите и выберите соседа по подсети"
ask_tty " Выбор [1]: " || die "Сертификат для $DOMAIN не выпущен. Подробности: /var/log/kit-domain-cert.log"
case ${REPLY//[[:space:]]/} in
2) ask_tty " адрес сайта (например vpn.example.com): " || continue
local d=${REPLY//[[:space:]]/}
[[ -n $d ]] || continue
if domain_points_here "$d"; then DOMAIN=$d; SNI=$DOMAIN
else warn "оставляю прежний домен $DOMAIN — новый не указывает на этот сервер"; fi ;;
3) # Наш шов: продолжаем без своего домена прямо здесь — маскировка соседом по подсети
# (или известным сайтом), панель и подписка по IP. Перезапускать установку не нужно.
local nb2
nb2=$(nearby_sites 2>/dev/null | shuf | head -1)
[[ -n $nb2 ]] || nb2=dl.google.com
DOMAIN=""; PANEL_ON=ip; LINK_HOST=$HOST
SNI=$nb2; SNI2=${SNI2:-$nb2}
ok "продолжаю без своего домена: маскировка ${B}$SNI${N}, панель и подписка — по IP"
break ;;
*) sleep 5 ;;
esac
done
OPEN+=("80/tcp")
fi
# Одна заглушка на сервер: её показывают nginx на 443 и Hysteria2 (masquerade), её же берёт kit net masq.
@@ -811,12 +1068,24 @@ kl_sni_menu() {
setup_subscription
[[ $SINGLE == yes ]] && setup_nginx
ok "${#CREATED[@]} подключений, пользователь $NAME"
# Сайты XHTTP и MTProto – из самих подключений: при повторном запуске они остаются от прошлого раза, а не выбранные сейчас.
if [[ $SINGLE == yes ]]; then
local sites
sites=$(api GET inbounds/list | jq -r '.[] | select(.listen == "127.0.0.1")
| if .remark == "XHTTP" then (.streamSettings | if type == "string" then fromjson else . end).realitySettings.serverNames[0]
elif .protocol == "mtproto" then (.settings | if type == "string" then fromjson else . end).fakeTlsDomain else empty end // empty')
if grep -qE "$SNI_BRAND_RE" <<<"$sites"; then
later "XHTTP или MTProto используют известный сайт из общего списка (достаточно соседей по подсети не нашлось). Заменить: kit → Протоколы, порты и маскировка → Сменить сайт маскировки"
fi
fi
step "Защита и обновления"
install_kit_cli
if [[ $TRUSTED == yes ]]; then
/usr/local/bin/kit __subextra >/dev/null 2>&1 || later "Адреса Clash и JSON на странице подписки не подключились: kit update --force"
fi
# Ссылка MTProto в панели: адрес сервера и порт 443 (панель берёт их из таблицы hosts).
[[ $SINGLE == yes ]] && { /usr/local/bin/kit __mtproto >/dev/null 2>&1 || later "Ссылка MTProto в панели осталась с внутренним портом: берите её из kit user link имя --telegram"; }
if [[ $PANEL_ON == domain ]]; then
/usr/local/bin/kit net panel domain >/dev/null 2>&1 || { PANEL_ON=ip; later "Панель и подписку по домену включить не удалось: оставил по IP. Попробуйте позже: kit net panel domain"; }
fi
@@ -841,11 +1110,11 @@ kl_sni_menu() {
chk=$(/usr/local/bin/kit check --deep 2>&1) && { bad=no; break; }
done
if [[ $bad == no ]]; then
ok "подключения работают: REALITY, XHTTP и Hysteria2 проверены клиентом с самого сервера"
ok "подключения работают (проверено клиентом с самого сервера): ${CREATED[*]:-нет}"
else
install -m 600 /dev/null /var/log/kit-install-check.log
printf '%s\n' "$chk" >/var/log/kit-install-check.log
later "kit check нашёл: $(sed 's/\x1b\[[0-9;]*m//g' <<<"$chk" | grep -a -m1 '❌' | cut -c1-110). Подробности: kit check --deep"
later "Проверка сервера нашла: $(sed 's/\x1b\[[0-9;]*m//g' <<<"$chk" | grep -a -m1 '❌' | cut -c1-110). Подробности: kit → Проверка сервера → Глубокая"
fi
# --- итог ---
@@ -883,69 +1152,41 @@ kl_sni_menu() {
[[ $TRUSTED == yes ]] && { echo "Подписка ($NAME) – все протоколы одной ссылкой:"; echo "$SUB_URL"; echo; }
echo "Отдельные подключения ($NAME):"
echo "$links"
if [[ $SINGLE == yes ]] && grep -q '^tg://' <<<"$links"; then
echo
echo "Telegram (tg://): порт 443 – верный, ${INNER[mtproto]} – внутренний порт сервера, снаружи закрыт."
fi
} >"$RESULT"
echo
echo "${G}${B}Готово! Сервер работает.${N}"
[[ -n $DOMAIN ]] && echo "Маскировка: свой домен ${B}$DOMAIN${N}, сертификат Let's Encrypt продлевается сам."
[[ $SELF_IP_CERT == yes ]] && later "Сертификат на IP самоподписанный: пользуйтесь ссылками на отдельные подключения, подписка в приложениях может не открыться."
echo
echo "${B}ПАНЕЛЬ${N}"
if [[ $TRUSTED == yes ]]; then
echo " $panel_url"
else
echo " Только через SSH-туннель. На вашем компьютере:"
echo " ssh -L $XUI_PANEL_PORT:127.0.0.1:$XUI_PANEL_PORT root@$HOST"
echo " Затем в браузере: http://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH"
fi
echo " Логин: ${B}$XUI_USERNAME${N} Пароль: ${B}$XUI_PASSWORD${N}"
echo " ${D}(сохранено в $RESULT, файл виден только root)${N}"
echo
if [[ $TRUSTED == yes ]]; then
echo "${B}ПОДПИСКА${N} – одна ссылка для всех протоколов"
echo " ${B}$SUB_URL${N}"
echo " Вставьте в Happ, Hiddify, Karing, v2rayN, Clash Verge или FlClash."
qrencode -t ANSIUTF8 -m 1 "$SUB_URL" || true
echo
KIT_NO_QR=1 /usr/local/bin/kit __links "$NAME" "$SUBID" || true
else
/usr/local/bin/kit __links "$NAME" "$SUBID" || true
fi
echo
echo "${D}Порты, выключение и включение протоколов: ${N}${B}kit net${N}"
echo "${G}${B}Установка завершена.${N} Доступы к панели, подписка и QR-код — в самом конце вывода."
if ((${#WARNINGS[@]})); then
echo
echo "${B}ВНИМАНИЕ${N}"
local w n=0
for w in "${WARNINGS[@]}"; do
n=$((n + 1)); ((n > 3)) && break
echo " ${Y}!${N} $w"
done
((${#WARNINGS[@]} > 3)) && echo " … и ещё $((${#WARNINGS[@]} - 3)): kit check"
local -a wn=("${WARNINGS[@]:0:3}")
((${#WARNINGS[@]} > 3)) && wn+=("И ещё $((${#WARNINGS[@]} - 3)): kit → Проверка сервера → Быстрая проверка.")
tips "На что обратить внимание" "${wn[@]}"
fi
echo
echo "${B}ДАЛЬШЕ${N}"
echo " ${B}kit${N} меню управления"
echo " ${B}kit user add имя --gb 50 --days 30${N} добавить пользователя"
echo " ${B}kit check --deep${N} проверить, что всё работает"
echo " ${B}kit backup${N} копия сервера (держите в надёжном месте)"
}
# Официальный установщик 3X-UI закреплённой версии, сверенный по SHA256.
install_xui() { # порт путь логин пароль
local tmp
[[ ${PANEL_SSL:-} == ip ]] && ufw_open_80
tmp=$(mktemp)
say "Ставлю 3X-UI $XUI_VERSION официальным установщиком (пара минут)"
curl -fsSL --retry 3 -o "$tmp" "https://raw.githubusercontent.com/$XUI_REPO/$XUI_VERSION/install.sh"
[[ $(sha256sum "$tmp" | awk '{print $1}') == "$XUI_INSTALL_SHA256" ]] \
|| die "Установщик 3X-UI $XUI_VERSION не совпал с проверенным (SHA256) – не запускаю. Сообщите нам: github.com/itsnotkubrick/3X-UI_KIT/issues"
spin_start "Устанавливаю панель 3X-UI"
if ! XUI_NONINTERACTIVE=1 XUI_SSL_MODE="${PANEL_SSL/custom/none}" XUI_SERVER_IP="$HOST" \
XUI_PANEL_PORT="$1" XUI_WEB_BASE_PATH="$2" \
XUI_USERNAME="$3" XUI_PASSWORD="$4" \
bash "$tmp" "$XUI_VERSION" </dev/null >/var/log/3x-ui-install.log 2>&1; then
spin_stop
tail -20 /var/log/3x-ui-install.log >&2
die "Установщик 3X-UI завершился с ошибкой. Полный лог: /var/log/3x-ui-install.log"
fi
spin_stop
rm -f "$tmp"
}
@@ -960,7 +1201,7 @@ connect_panel() {
for i in $(seq 1 60); do
for scheme in https http; do
API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api"
curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null && return 0
curl -fsk -m 5 -o /dev/null -H @<(printf 'Authorization: Bearer %s\n' "$XUI_API_TOKEN") "$API/server/getNewUUID" 2>/dev/null && return 0
done
sleep 2
done
@@ -1011,12 +1252,14 @@ set_xray_core() {
say "Ставлю ядро Xray $XRAY_CORE (совместимо с Hiddify, Mihomo и другими клиентами)"
# 3X-UI 3.9 бывает отвечает ошибкой «GitHub API error», хотя ядро уже заменено: ответ не считаем
# проверкой, смотрим на версию ядра ниже.
spin_start "Ставлю ядро Xray"
(api POST "server/installXray/$XRAY_CORE" '{}' >/dev/null) 2>/dev/null || warn "Панель не подтвердила смену ядра – проверяю версию сам."
for _ in $(seq 1 30); do
cur_core=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}')
[[ $cur_core == "$XRAY_CORE" ]] && break
sleep 2
done
spin_stop
if [[ $cur_core != "$XRAY_CORE" ]]; then
warn "Панель не смогла скачать ядро с GitHub – пробую запасной путь: зеркало с проверкой SHA256."
xray_install_fallback
@@ -1035,6 +1278,19 @@ ssh_ports() {
} | grep -E '^[0-9]{1,5}$' | sort -un || true
}
# Let's Encrypt проверяет сервер по порту 80. Если ufw уже включён (остался после прошлой установки или включён на образе
# хостера), он этот порт закрыл бы, и сертификат не выдастся («Timeout during connect»). Открываем порт заранее;
# нужен ли он потом, решает setup_ufw.
UFW_OPENED_80=no
ufw_open_80() {
[[ ${UFW:-yes} == yes ]] || return 0
command -v ufw >/dev/null 2>&1 || return 0
ufw status 2>/dev/null | grep -q '^Status: active' || return 0
ufw status 2>/dev/null | grep -qE '^80(/tcp)? +ALLOW' && return 0
ufw allow 80/tcp >/dev/null 2>&1 && UFW_OPENED_80=yes
return 0
}
setup_ufw() {
local ssh_port o
ssh_port=$(ssh_ports)
@@ -1042,6 +1298,7 @@ setup_ufw() {
ok "ufw: открыто ${#OPEN[@]} портов (${OPEN[*]}), панель не торчит наружу"
for o in "${OPEN[@]}"; do ufw allow "$o" >/dev/null; done
ufw --force enable >/dev/null || later "ufw не включился (так бывает в контейнерах) – откройте порты у хостера вручную."
if [[ $UFW_OPENED_80 == yes && " ${OPEN[*]} " != *" 80/tcp "* ]]; then ufw delete allow 80/tcp >/dev/null 2>&1 || true; fi
}
# ---------- сертификат ----------
@@ -1113,11 +1370,11 @@ add_inbound() {
sniffing: "{\"enabled\":true,\"destOverride\":[\"http\",\"tls\",\"quic\"],\"metadataOnly\":false,\"routeOnly\":false}",
expiryTime: 0, total: 0}')
local out
out=$(curl -sSk -m 20 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -X POST -d "$body" "$API/inbounds/add")
out=$(curl -sSk -m 20 -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") -H 'Content-Type: application/json' -X POST --data-binary @<(printf '%s' "$body") "$API/inbounds/add")
if [[ $(jq -r '.success' <<<"$out") != true ]] && grep -q 'Duplicate email' <<<"$out"; then
# Клиент с таким именем остался от удалённого подключения – берём уникальное имя.
body=$(jq -c --arg sfx "-$(openssl rand -hex 2)" '.settings |= (fromjson | .clients[0].email += $sfx | tojson)' <<<"$body")
out=$(curl -sSk -m 20 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -X POST -d "$body" "$API/inbounds/add")
out=$(curl -sSk -m 20 -H @<(printf 'Authorization: Bearer %s\n' "$TOKEN") -H 'Content-Type: application/json' -X POST --data-binary @<(printf '%s' "$body") "$API/inbounds/add")
fi
[[ $(jq -r '.success' <<<"$out") == true ]] || die "Панель не создала $remark: $(jq -r '.msg // .' <<<"$out" | head -c 300)"
CREATED+=("$remark"); open_port "$port" "$net"
@@ -1522,9 +1779,19 @@ stub_site() {
setup_nginx() {
say "Настраиваю nginx: всё TCP через порт 443"
spin_start "Ставлю nginx"
apt-get install -y -qq nginx libnginx-mod-stream >/dev/null
spin_stop
# Порт 80 нужен Let's Encrypt для продления сертификата – сайт nginx по умолчанию убираем.
rm -f /etc/nginx/sites-enabled/default
# apt уже запустил nginx со стандартным сайтом на :80. Если установка упадёт ниже, он не должен остаться и занимать порт.
[[ -f /etc/nginx/kit-stream.conf ]] || systemctl stop nginx >/dev/null 2>&1 || true
# С nginx 1.25.1 «listen … http2» устарело: пишем «http2 on;».
local ngx_ver h2="http2" h2on=""
ngx_ver=$(nginx -v 2>&1 | sed -n 's|.*nginx/\([0-9][0-9.]*\).*|\1|p')
if [[ -n $ngx_ver && $(printf '%s\n1.25.1\n' "$ngx_ver" | sort -V | head -1) == 1.25.1 ]]; then
h2=""; h2on=$'\n http2 on;'
fi
# Панель – только через nginx.
local all
@@ -1595,6 +1862,8 @@ setup_nginx() {
{
echo "# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске."
echo "stream {"
echo " # Длинные имена сайтов (до 253 знаков) не влезают в хеш-таблицу nginx по умолчанию."
echo " map_hash_bucket_size 512;"
echo " map \$ssl_preread_server_name \$kit_upstream {"
[[ -n $reality_sni ]] && echo " $reality_sni 127.0.0.1:${INNER[reality]};"
[[ -n $xhttp_sni && $xhttp_sni != "$reality_sni" ]] && echo " $xhttp_sni 127.0.0.1:${INNER[xhttp]};"
@@ -1615,8 +1884,8 @@ setup_nginx() {
cat >/etc/nginx/conf.d/kit.conf <<NGX
# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске.
server {
listen 127.0.0.1:${INNER[web]} ssl http2 proxy_protocol;
server_name _;
listen 127.0.0.1:${INNER[web]} ssl${h2:+ $h2} proxy_protocol;
server_name _;${h2on}
ssl_certificate $CERT;
ssl_certificate_key $KEY;
ssl_protocols TLSv1.2 TLSv1.3;
@@ -1654,8 +1923,8 @@ NGX
# Свой домен (self-steal): сюда REALITY отправляет всех, кто не подключается как клиент.
# Только заглушка, без панели и подписки; порт слушает localhost.
server {
listen 127.0.0.1:${INNER[selfweb]} ssl http2;
server_name $steal_domain;
listen 127.0.0.1:${INNER[selfweb]} ssl${h2:+ $h2};
server_name $steal_domain;${h2on}
ssl_certificate $DOMAIN_CERT_DIR/fullchain.pem;
ssl_certificate_key $DOMAIN_CERT_DIR/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
@@ -1777,7 +2046,9 @@ install_kit_sub_file() {
install_kit_sub() {
say "Ставлю подписку с учётом приложения (kit-sub)"
spin_start "Ставлю python3 для подписки"
apt-get install -y -qq python3 python3-yaml >/dev/null
spin_stop
install_kit_sub_file
if [[ $SINGLE == yes ]]; then
# За nginx: слушаем только localhost, TLS снимает nginx на 443.
@@ -1828,12 +2099,14 @@ sub_links() {
# Сначала – только печатные ASCII и перевод строки: файл с нулевым байтом GNU grep считает двоичным и молчит
# (проверка «прошла» бы), а bash при «.» нулевые байты выкидывает и выполняет остальное. Поэтому и grep -a.
safe_env() { # файл имя...
local f=$1 n re; shift
[[ -r $f ]] || die "В копии нет файла ${f##*/}."
local f=$1 n re rc=0; shift
[[ -f $f && -r $f ]] || die "В копии нет файла ${f##*/}."
[[ $(LC_ALL=C tr -d '\n -~' <"$f" | wc -c) -eq 0 ]] \
|| die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."
LC_ALL=C grep -aqvE "^([A-Z][A-Z0-9_]*=([A-Za-z0-9._:/@%+,=_-]*|''))?$" "$f" \
&& die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."
# grep: 0 – есть недопустимая строка, 1 – все строки хорошие, 2 – не смог прочитать: и 0, и 2 считаем отказом.
LC_ALL=C grep -aqvE "^([A-Z][A-Z0-9_]*=([A-Za-z0-9._:/@%+,=_-]*|''))?$" "$f" || rc=$?
[[ $rc -eq 1 ]] \
|| die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."
while IFS= read -r n; do
[[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял."
case $n in
@@ -1946,7 +2219,7 @@ restore_self_cert() { # путь-к-fullchain.pem
if [[ $dir == /root/cert/ip ]]; then
printf '%s\n' "${R}!${N} Let's Encrypt не выдал сертификат на IP $HOST (лимит: 5 сертификатов в неделю на один IP, закрытый порт 80 или сбой; лог: /var/log/3x-ui-install.log)." >&2
echo " Ставлю самоподписанный: ссылки на подключения работают (отпечаток в них), браузер покажет предупреждение, а подписка в приложениях может не открыться." >&2
later "Сертификат на IP самоподписанный (Let's Encrypt не выдал): пользуйтесь ссылками на отдельные подключения (kit user link имя), подписка в приложениях может не открыться."
later "Сертификат на IP самоподписанный (Let's Encrypt не выдал): пользуйтесь ссылками на отдельные подключения (kit → Пользователи → Показать ссылки), подписка в приложениях может не открыться."
SELF_IP_CERT=yes
fi
install -d -m 700 "$dir"
@@ -1979,7 +2252,7 @@ restore_main() { # файл
say "Ставлю пакеты: curl, jq, openssl, qrencode, ufw, python3"
export DEBIAN_FRONTEND=noninteractive
wait_apt_idle
apt-get update -qq
apt_refresh
apt-get install -y -qq curl jq openssl qrencode ca-certificates iproute2 ufw socat cron python3 python3-yaml >/dev/null
# Сначала проверяем архив целиком: только наши пути, только файлы и папки, без ссылок
@@ -1988,7 +2261,7 @@ restore_main() { # файл
# shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет
trap "rm -rf -- '$tmp'" EXIT
python3 - "$file" "$tmp" <<'PY' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена. Ничего не менял."
import os, sys, tarfile
import os, sys, tarfile, unicodedata
allowed = ("etc/x-ui/x-ui.db", "etc/x-ui/install-result.env", "etc/kit/kit.env", "etc/kit-sub/config.json", "etc/kit-sub/rules.yaml",
"etc/nginx/kit-stream.conf", "etc/nginx/conf.d/kit.conf", "var/www/kit", "root/cert/self",
"root/cert/custom", "root/3x-ui.txt", "kit-backup.env")
@@ -2016,7 +2289,7 @@ with tarfile.open(sys.argv[1], "r:gz") as t:
sys.exit("нет " + need)
# Файлы настроек потом подключаются от root: в них только печатные ASCII и перевод строки. Нулевой байт,
# \r или не-ASCII ломают построчную проверку (GNU grep считает такой файл двоичным). Данные для входа
# (3x-ui.txt) показываются в терминале: только UTF-8 без управляющих символов, кроме \n и \t.
# (3x-ui.txt) показываются в терминале: только UTF-8 без управляющих и невидимых символов (Cc, Cf: U+202E, U+200B, U+FEFF), кроме \n и \t.
for m in ms:
if not m.isfile():
continue
@@ -2030,7 +2303,7 @@ with tarfile.open(sys.argv[1], "r:gz") as t:
text = data.decode("utf-8")
except UnicodeDecodeError:
sys.exit("байты " + m.name)
if len(data) > 1048576 or any((c < " " and c not in "\n\t") or "\x7f" <= c <= "\x9f" for c in text):
if len(data) > 1048576 or any(c not in "\n\t" and unicodedata.category(c) in ("Cc", "Cf") for c in text):
sys.exit("байты " + m.name)
# Каталог сертификата есть – в нём оба файла, иначе установка споткнётся на полпути.
files = {m.name for m in ms if m.isfile() and m.size > 0}
@@ -2054,6 +2327,21 @@ PY
fi
python3 -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); assert c.execute('PRAGMA integrity_check').fetchone()[0]=='ok'; c.execute('SELECT count(*) FROM inbounds')" \
"$tmp/etc/x-ui/x-ui.db" 2>/dev/null || die "База панели в копии повреждена. Ничего не менял."
# Прежние имена сайта (льготный срок после kit net site) из копии не берём: у REALITY и XHTTP остаётся только нынешнее имя.
python3 - "$tmp/etc/x-ui/x-ui.db" <<'PY' || die "Не удалось подготовить базу из копии. Ничего не менял."
import json, sqlite3, sys
db = sqlite3.connect(sys.argv[1])
for i, s in db.execute("SELECT id, stream_settings FROM inbounds").fetchall():
try:
st = json.loads(s)
names = st["realitySettings"]["serverNames"]
except Exception:
continue
if isinstance(names, list) and len(names) > 1:
st["realitySettings"]["serverNames"] = names[:1]
db.execute("UPDATE inbounds SET stream_settings = ? WHERE id = ?", (json.dumps(st, ensure_ascii=False, indent=2), i))
db.commit(); db.close()
PY
local BACKUP_HOST BACKUP_SSL BACKUP_DATE BACKUP_KIT_VERSION
BACKUP_HOST=$(env_get "$tmp/kit-backup.env" BACKUP_HOST)
@@ -2118,6 +2406,7 @@ PY
systemctl stop x-ui
# Служебные файлы WAL от базы, созданной установщиком, к базе из копии не подходят: с ними она «битая».
rm -f /etc/x-ui/x-ui.db-wal /etc/x-ui/x-ui.db-shm
rm -f /etc/kit/sni-grace.json /etc/kit-sub/sni-grace.json
install -m 600 "$tmp/etc/x-ui/x-ui.db" /etc/x-ui/x-ui.db
install -m 600 "$tmp/etc/x-ui/install-result.env" "$XUI_ENV"
local c
@@ -2164,7 +2453,7 @@ PY
# позже (после сборки nginx), поэтому на свежем сервере проверка Let's Encrypt доходила до сервера и
# «проглатывалась» — в логе acme это «Timeout during connect (likely firewall problem)».
command -v ufw >/dev/null 2>&1 && ufw allow 80/tcp >/dev/null 2>&1 || true
[[ -n $DOMAIN ]] && { issue_domain_cert || die "Не удалось получить сертификат для $DOMAIN: нужен свободный порт 80, открытый в ufw (и у хостера), и A-запись домена на этот сервер. Лог: /var/log/kit-domain-cert.log"; }
[[ -n $DOMAIN ]] && { issue_domain_cert || die "Без сертификата для $DOMAIN продолжать нельзя — причина выше, подробности в /var/log/kit-domain-cert.log"; }
setup_nginx
fi
@@ -2175,6 +2464,8 @@ PY
sed -i 's/^PANEL_ON=.*/PANEL_ON=ip/' /etc/kit/kit.env
/usr/local/bin/kit net panel domain >/dev/null 2>&1 || { /usr/local/bin/kit __xver 0 >/dev/null 2>&1 || true; warn "Панель по домену не включилась (домен должен указывать на этот сервер): kit net panel domain"; }
fi
# MTProto: ссылка в панели – на этот сервер и 443 (чужие адреса из копии убираем), секреты – под нынешний сайт.
/usr/local/bin/kit __mtproto restore >/dev/null 2>&1 || later "Ссылку MTProto в панели поправить не удалось: kit fix"
brand_xui_menu
/usr/local/bin/kit update --auto >/dev/null 2>&1 || later "Автообновление не включилось – включите позже: kit update --auto"
/usr/local/bin/kit __limit-timer on >/dev/null 2>&1 || later "Проверка общего лимита трафика не включилась – включите позже: kit fix"
@@ -2202,25 +2493,21 @@ PY
echo "${G}${B}Готово! Сервер восстановлен из копии: $n_in подключений, $n_cl клиентских записей.${N}"
else
echo "${R}${B}Сервер восстановлен из копии ($n_in подключений, $n_cl клиентских записей), но ядро Xray не запустилось: подключения не работают.${N}"
echo "Причина – в журнале: ${B}tail -n 30 /var/log/x-ui/3xui.log${N}, проверка: ${B}kit check${N}"
echo "Причина – в журнале ${B}/var/log/x-ui/3xui.log${N}, проверка: ${B}kit → Проверка сервера${N}"
fi
echo "Логин и пароль панели прежние, адрес панели и подписки: ${B}cat $RESULT${N}"
echo
echo "Логин и пароль панели прежние. Адрес панели и подписки: файл ${B}$RESULT${N} (виден только root)."
local -a tp=()
if [[ -n $old_ip ]]; then
warn "IP сервера сменился: $old_ip → $HOST."
echo "Старые подписки указывают на старый IP, поэтому клиентам нужно один раз добавить"
echo "подписку заново: ${B}kit user list${N}, затем ${B}kit user link имя${N}."
echo "${D}Чтобы в следующий раз переезд прошёл без хлопот для клиентов, ставьте сервер на домен (--cert, --key, --host).${N}"
tp+=("Старые подписки указывают на старый IP, поэтому клиентам нужно один раз добавить подписку заново. Новую ссылку покажет kit → Пользователи → Показать ссылки.")
tp+=("Чтобы в следующий раз переезд прошёл без хлопот для клиентов, ставьте сервер на домен (--cert, --key, --host).")
else
echo "Клиентам ничего менять не нужно: ключи, ссылки и подписки те же."
fi
[[ $PANEL_SSL == custom ]] && echo "Направьте A-запись домена ${B}$HOST${N} на IP этого сервера, если ещё не сделали."
if ((${#WARNINGS[@]})); then
echo
echo "${B}ВНИМАНИЕ${N}"
local w
for w in "${WARNINGS[@]}"; do echo " ${Y}!${N} $w"; done
tp+=("Клиентам ничего менять не нужно: ключи, ссылки и подписки те же.")
fi
[[ $PANEL_SSL == custom ]] && tp+=("Направьте A-запись домена $HOST на IP этого сервера, если ещё не сделали.")
tp+=("Управление сервером: введите команду ${B}kit${N}, откроется меню.")
tips "Что дальше" "${tp[@]}"
if ((${#WARNINGS[@]})); then tips "На что обратить внимание" "${WARNINGS[@]}"; fi
[[ $core_ok == yes ]] || exit 1
return 0
}
@@ -2229,11 +2516,8 @@ usage() {
cat <<EOF
3X-UI со всеми протоколами одной командой
--protocols all all (по умолчанию – всё, кроме WireGuard), minimal (только REALITY)
или список через запятую: reality,reality2,hy2,xhttp,ws,trojan,vmess,ss,tuic,wg,awg,awg3,mtproto
(обычный WireGuard работает нестабильно – включайте его, только если сервер и
пользователи за границей)
--port 443 порт REALITY (TCP) и Hysteria2 (UDP), по умолчанию 443
--protocols all all (по умолчанию – основной набор), minimal (только REALITY)
\1 --port 443 порт REALITY (TCP) и Hysteria2 (UDP), по умолчанию 443
--sni сайт чужой сайт для маскировки (по умолчанию подбирается сам)
--domain домен свой домен для маскировки (надёжнее): его A-запись должна вести на этот
сервер, порт 80 свободен; сертификат Let's Encrypt установщик получит сам.
+1 -1
View File
@@ -8,7 +8,7 @@
# Код выхода 1 при любом запуске (и при `__update_binary`) – старый `hy2 update` на нём
# останавливается и не заменяет команду hy2.
KIT_VERSION="1.2.22"
KIT_VERSION="1.2.1.9"
cat >&2 <<'MSG'
Отдельный установщик Hysteria2 больше не поддерживается.
+107 -167
View File
@@ -73,7 +73,10 @@ kit_local_help() {
kit local seams какие места форк правит в файлах автора
kit local masking состояние маскировки (у REALITY должен быть свой домен)
kit local hardening сводка по защите сервера и что ещё можно включить
kit local uninstall снести всё, что поставила установка (SSH не трогает)
kit local access доступ к панели, сертификат, ссылки и заглушка — одним экраном
kit local links подписка, QR-код и ссылки по протоколам (как в меню kit)
kit local ports открытые порты: что нужно, что лишнее и как закрыть
kit local uninstall то же, что kit uninstall: снести всё, включая сам kit (SSH не трогает)
Заглушка (сайт на 443 и ответ Hysteria2 — одна и та же страница)
kit local site поставить случайный сайт из нашего набора (10 штук)
@@ -420,9 +423,8 @@ Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
EOF
systemctl enable --now unattended-upgrades >/dev/null 2>&1 || kl_warn "служба не включилась — проверьте: systemctl status unattended-upgrades"
kl_ok "включено: обновления безопасности ставятся сами"
kl_ok "панель и ядро это не затрагивает — они ставятся не из apt, а из своих релизов"
kl_warn "обновление ядра применится только после перезагрузки, сам сервер не перезагружается"
# Одна строка: подробности нужны редко, а в выводе установки они превращались в шум.
kl_ok "автообновления безопасности включены (панель и ядро это не затрагивает, сервер сам не перезагружается)"
return 0
}
@@ -443,12 +445,14 @@ kit_local_hardening() {
cat <<'EOF'
Чего здесь НЕТ и почему (в отличие от vps-setup):
Что сделано намеренно МЯГЧЕ, чем в популярных скриптах первичной настройки:
* apt full-upgrade — тянет систему вперёд и может сломать закреплённые панель и ядро;
вместо него включены обновления безопасности;
* автоматическая перезагрузка после правок — сервер не должен вставать без вашего решения;
* общесистемный icmp_echo_ignore_all — правило ufw видно в `ufw status` и отменяется одной
командой, не глушит диагностику внутренних интерфейсов.
* общесистемный запрет ICMP для всех интерфейсов — здесь он выключен на уровне ufw: правило видно
в `ufw status` и отменяется одной командой, а внутренняя диагностика не глушится.
Причина одна: перечисленное может увести систему вперёд или выключить сервер, а панель и ядро у нас
закреплённых версий.
Что включить при желании:
kit local ssh 2222 добавить порт SSH
@@ -898,6 +902,9 @@ kit_local_cmd() {
upgrades | auto) shift; kit_local_upgrades "$@" ;;
hardening | hard) shift; kit_local_hardening "$@" ;;
uninstall | remove) shift; kit_local_uninstall "$@" ;;
links | link) shift; kit_local_links "$@" ;;
access) shift; kit_local_access "$@" ;;
ports) shift; kit_local_ports "$@" ;;
site | stub | stub-site) shift; kit_local_site "$@" ;;
*)
printf 'Неизвестная команда форка: %s\n' "$1" >&2
@@ -920,169 +927,99 @@ kit_local_cmd() {
#
# kit local uninstall спросит подтверждение словом «удалить»
# kit local uninstall --dry-run только покажет, что будет удалено
kit_local_uninstall() {
kl_args "$@"
local arg=${KL_REST[0]:-} ans found
local reset_ssh=no i=0
case $arg in
"") ;;
--dry-run) KL_DRY=yes ;;
--yes) KIT_LOCAL_UNINSTALL_CONFIRMED=yes ;;
--reset-ssh) reset_ssh=yes ;;
*) kl_die "kit local uninstall [--dry-run] [--yes] [--reset-ssh]"; return 1 ;;
esac
# Службы: и явный список, и всё, что найдётся по нашим шаблонам (чтобы новые юниты kit не оставались).
# kit запускает наши команды с `set -e`: при уборке это недопустимо — любая «не найденная» служба
# или пустой grep обрывали бы снос на середине (так и было: файлы оставались). Убираем errexit.
set +e
local -a svc=(x-ui.service kit-sub.service kit-update.timer kit-update.service kit-limit.timer kit-limit.service)
local u
for u in $(systemctl list-unit-files --no-legend 2>/dev/null | awk '{print $1}' \
| grep -E '^(x-ui|kit-|kit\.)' | grep -E '\.(service|timer)$'); do
[[ " ${svc[*]} " == *" $u "* ]] || svc+=("$u")
kit_local_ports() { # [--close-extra] — что открыто в ufw, что реально нужно и как закрыть лишнее
local close=no a
for a in "$@"; do
case $a in
--close-extra) close=yes ;;
--help | -h) printf 'kit local ports [--close-extra] — открытые порты, нужные и лишние\n'; return 0 ;;
*) kl_die "kit local ports [--close-extra]"; return 1 ;;
esac
done
# Список собран по коду установщика (scripts/3x-ui.sh) и нашего слоя: убираем всё, что они создают.
local -a paths=(
/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 /root/.acme.sh /etc/letsencrypt /var/log/kit-install-check.log
/etc/sysctl.d/99-kit-local-icmp.conf /etc/apt/apt.conf.d/52-kit-local-upgrades
/etc/apt/apt.conf.d/20auto-upgrades
)
local -a masks=(/root/kit-local-backup-* /root/kit-backup-* /root/x-ui-before-sni-* /root/x-ui-before-update-*)
local -a extra=()
local m
for m in "${masks[@]}"; do for f in $m; do [[ -e $f ]] && extra+=("$f"); done; done
kl_step "Будет удалено"
printf ' службы: %s\n' "${svc[*]}"
printf ' файлы и каталоги: %s\n' "${paths[*]}"
[[ ${#extra[@]} -eq 0 ]] || printf ' копии и журналы: %s\n' "${extra[*]}"
printf ' пользователь kit-sub (если создавался)\n'
printf ' правила ufw для 80/tcp, 443/tcp и 443/udp; ответ на ping возвращается\n'
kl_ok "SSH не трогаем: ни порт, ни конфиги, ни правила — иначе можно потерять доступ"
if [[ $reset_ssh == yes ]]; then
kl_warn "--reset-ssh: снесу и наш ssh-дроп-ин (порт вернётся к системному). Убедитесь, что вход по нему работает!"
fi
if kl_dry; then
printf '\n (dry-run) ничего не делаю. Запустить по-настоящему: kit local uninstall\n'
return 0
fi
kl_need_root || return 1
if [[ -z ${KIT_LOCAL_UNINSTALL_CONFIRMED:-} ]]; then
printf '\n%sЧтобы удалить всё перечисленное, напишите слово «удалить»: %s' "${B:-}" "${N:-}"
read -r ans </dev/tty 2>/dev/null || ans=""
[[ ${ans,,} == "удалить" ]] || { kl_warn "не подтверждено — ничего не удаляю"; return 1; }
fi
kl_step "Останавливаю и отключаю службы"
local s
for s in "${svc[@]}"; do
if systemctl list-unit-files 2>/dev/null | grep -q "^$s"; then
systemctl disable --now "$s" >/dev/null 2>&1 && kl_ok "остановлено и отключено: $s" || kl_warn "не смог остановить: $s"
command -v ufw >/dev/null 2>&1 || { kl_warn "ufw не установлен — порты смотреть нечем"; return 0; }
local -a opened=() needed=() extra=()
mapfile -t opened < <(ufw status 2>/dev/null | awk '$2 == "ALLOW" { print $1 }' | sort -u)
# Нужные порты: те, что слушают наши подключения (x-ui и ядро Xray), плюс SSH.
mapfile -t needed < <({ ss -tlnp 2>/dev/null; ss -ulnp 2>/dev/null; } \
| awk '/x-ui|xray/ { print $4 }' | sed 's/.*://' | sort -un; ss -tlnp 2>/dev/null | awk '/sshd/ { print $4 }' | sed 's/.*://' | sort -un)
local p q keep
for p in "${opened[@]}"; do
keep=no
case $p in 80/tcp | 443/tcp) keep=yes ;; esac # 443 — работа, 80 — продление сертификата
for q in "${needed[@]}"; do [[ $p == "$q/tcp" || $p == "$q/udp" || $p == "$q" ]] && keep=yes; done
[[ $keep == yes ]] && continue
extra+=("$p")
done
printf ' открыто в ufw: %s\n' "$(printf '%s ' "${opened[@]}")"
if ((${#needed[@]})); then printf ' нужны: %s\n' "$(printf '%s/tcp %s/udp ' "${needed[@]}" "${needed[@]}")"
else printf ' нужны: (не определил — смотрите kit check)\n'; fi
if ((${#extra[@]})); then
printf ' лишние: %s\n' "$(printf '%s ' "${extra[@]}")"
printf ' закрыть лишние: kit local ports --close-extra\n'
if [[ $close == yes ]]; then
for p in "${extra[@]}"; do ufw --force delete allow "$p" >/dev/null 2>&1 && printf ' закрыл %s\n' "$p"; done
fi
done
if kl_have nginx; then
local others
others=$( { find /etc/nginx/conf.d /etc/nginx/sites-enabled /etc/nginx/streams-enabled -mindepth 1 2>/dev/null || true; } \
| { grep -vE 'kit\.conf$|(^|/)default$' || true; } | wc -l)
if [[ $others -eq 0 ]]; then
systemctl disable --now nginx >/dev/null 2>&1 && kl_ok "nginx остановлен (чужих сайтов в нём не было)"
KL_NGINX_PURGE=yes
else
kl_ok "nginx оставляю: в нём есть другие сайты — уберу только наши строки и файлы"
KL_NGINX_PURGE=no
fi
fi
kl_step "Убираю файлы"
local f
for f in "${paths[@]}"; do
[[ -e $f ]] || continue
rm -rf "$f" && kl_ok "удалено: $f" || kl_warn "не удалось удалить: $f"
done
for f in ${extra[@]+"${extra[@]}"}; do rm -rf "$f" && kl_ok "удалено: $f"; done
# Наши строки в чужом nginx: include stream-конфига и симлинки, если были.
if [[ -f /etc/nginx/nginx.conf ]] && grep -q 'kit-stream.conf' /etc/nginx/nginx.conf; then
kl_backup /etc/nginx/nginx.conf
sed -i '/kit-stream\.conf/d' /etc/nginx/nginx.conf && kl_ok "из nginx.conf убрана наша строка включения"
fi
local lnk
for lnk in /etc/nginx/sites-enabled/*kit* /etc/nginx/streams-enabled/*kit*; do
[[ -e $lnk || -L $lnk ]] && rm -f "$lnk" && kl_ok "удалена ссылка: $lnk"
done
if [[ ${KL_NGINX_PURGE:-no} == yes ]]; then
apt-get remove -y --purge nginx nginx-common >/dev/null 2>&1 && kl_ok "пакет nginx удалён"
fi
# Пользователь подписки (работает не от root).
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 && kl_ok "пользователь kit-sub удалён" || kl_warn "не смог удалить пользователя kit-sub"
fi
kl_step "Возвращаю систему"
systemctl daemon-reload
if [[ $reset_ssh == yes && -f /etc/systemd/system/ssh.socket.d/99-kit-local-port.conf ]]; then
rm -f /etc/systemd/system/ssh.socket.d/99-kit-local-port.conf
rm -f /etc/ssh/sshd_config.d/99-kit-local-port.conf
systemctl daemon-reload
kl_run systemctl restart ssh.socket 2>/dev/null || kl_run systemctl restart ssh 2>/dev/null || true
kl_warn "ssh-дроп-ин удалён: порт вернулся к системному. Проверьте вход в НОВОМ окне!"
fi
if kl_have ufw && kl_ufw_active; then
local p
for p in 80/tcp 443/tcp 443/udp; do ufw delete allow "$p" >/dev/null 2>&1 && kl_ok "правило ufw снято: $p"; done
if [[ -f $KL_UFW_RULES ]]; then
kl_backup "$KL_UFW_RULES"; kl_backup "$KL_UFW6_RULES"
sed -i 's/^# -A ufw-before-input -p icmp --icmp-type echo-request/-A ufw-before-input -p icmp --icmp-type echo-request/' "$KL_UFW_RULES"
[[ -f $KL_UFW6_RULES ]] && sed -i 's/^# -A ufw6-before-input -p icmpv6 --icmpv6-type echo-request/-A ufw6-before-input -p icmpv6 --icmpv6-type echo-request/' "$KL_UFW6_RULES"
ufw reload >/dev/null 2>&1 && kl_ok "ответ на ping возвращён"
fi
fi
sysctl -q -w net.ipv4.icmp_echo_ignore_all=0 >/dev/null 2>&1 || true
kl_step "Проверка (теми же условиями, что и установщик)"
local left=0
if [[ -f /root/3x-ui.txt && -x /usr/local/x-ui/x-ui ]]; then
kl_warn "установщик всё ещё считает панель установленной"; left=1
fi
for s in $(systemctl list-unit-files --no-legend 2>/dev/null | awk '{print $1}' \
| grep -E '^(x-ui|kit-|kit\.)' | grep -E '\.(service|timer)$'); do
kl_warn "осталась наша служба: $s"; left=1
done
# Добивка: убираем всё, что осталось и подходит под наши шаблоны (вдруг автор добавил новый файл
# или что-то не удалилось с первого раза). Затем проверяем ещё раз.
for f in /etc/kit* /usr/local/lib/kit* /usr/local/bin/kit /etc/cron.d/kit-* /var/www/kit \
/etc/systemd/system/kit-*.service /etc/systemd/system/kit-*.timer \
/etc/systemd/system/x-ui*.service /usr/lib/systemd/system/kit-*.service \
/etc/x-ui /usr/local/x-ui /usr/bin/x-ui /usr/bin/x-ui.new /root/3x-ui.txt /root/cert; do
[[ -e $f ]] || continue
rm -rf "$f" && { kl_ok "добито: $f"; i=1; } || kl_warn "не удалось удалить: $f"
done
((i)) && systemctl daemon-reload
found=$( { ls -d /etc/kit* /usr/local/lib/kit* /usr/local/bin/kit /etc/cron.d/kit-* /var/www/kit \
/etc/systemd/system/*kit* /etc/x-ui /usr/local/x-ui /root/3x-ui.txt /root/cert 2>/dev/null || true; } | head -20)
[[ -z $found ]] || { kl_warn "осталось на диске:"; printf ' %s\n' $found; left=1; }
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE '[:.]443$' && { kl_warn "порт 443 всё ещё кем-то слушается"; left=1; }
if ((left)); then
kl_warn "осталось перечисленное выше — пришлите этот вывод, поправим список в kit local uninstall"
else
kl_ok "чисто: от установки не осталось ни файлов, ни служб, порт 443 свободен"
kl_ok "проверка установщика пройдена: он снова поставит сервер с нуля"
printf ' лишних нет\n'
fi
kl_ok "SSH не тронут (если не указан --reset-ssh)"
kl_step "Готово: сервер в состоянии до установки, можно ставить заново"
}
kit_local_access() { # доступ к панели, ссылки на подключения, заглушка и сертификат — одним экраном
local u=${1:-} till2
printf '%s\n' "${B:-}Доступ к панели 3X-UI${N:-} (файл /root/3x-ui.txt, виден только root)"
if grep -aqE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt 2>/dev/null; then
grep -aE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt | sed 's/^[[:space:]]*/ /'
else
kl_warn "данных панели в /root/3x-ui.txt нет"
fi
printf '\n%s\n' "${B:-}Сертификат панели${N:-}"
if [[ -s /root/cert/ip/fullchain.pem ]] && openssl x509 -in /root/cert/ip/fullchain.pem -noout -issuer 2>/dev/null | grep -q "Let's Encrypt"; then
printf ' Let'"'"'s Encrypt на IP — доверенный, браузер не ругается\n'
elif [[ -s /root/cert/ip/fullchain.pem ]]; then
printf ' самоподписанный — браузер предупредит, подписка в приложениях может не открыться\n'
else
printf ' сертификата на IP нет: панель открывается только через SSH-туннель\n'
fi
till2=$(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 $till2 ]] && printf ' лимит Let'"'"'s Encrypt: повтор выпуска возможен после %s (затем запустите установщик снова)\n' "$till2"
printf '\n%s\n' "${B:-}Ссылки на подключения и QR-код${N:-}"
if [[ -z $u ]]; then u=$(kit user list 2>/dev/null | awk 'NR > 1 && NF { print $1; exit }'); u=${u:-admin}; fi
local now; now=$(tr '\n' ' ' </etc/kit/kl-protos 2>/dev/null || true)
if [[ $(wc -w <<<"$now") -gt 1 ]]; then kit user link "$u" --all; else kit user link "$u"; fi
printf '\n%s\n' "${B:-}Заглушка (что видит посторонний, открыв адрес сервера)${N:-}"
kit local site --show
printf ' посмотреть снаружи: откройте https://<IP сервера>/ в браузере или curl -k https://<IP>/
'
}
kit_local_links() { # [имя] — подписка, QR-код и ссылки по протоколам (то же, что «kit → Пользователи → Показать ссылки»)
local u=${1:-} now
if [[ -z $u ]]; then
u=$(kit user list 2>/dev/null | awk 'NR > 1 && NF { print $1; exit }')
u=${u:-admin}
fi
printf ' пользователь: %s\n' "$u"
now=$(tr '\n' ' ' </etc/kit/kl-protos 2>/dev/null || true)
# Ссылки на каждый протокол — только если протоколов больше одного; при одном достаточно подписки.
if [[ $(wc -w <<<"$now") -gt 1 ]]; then kit user link "$u" --all; else kit user link "$u"; fi
}
kit_local_uninstall() {
# Наш шов: логику удаления отдаём авторской команде «kit uninstall». Она снимает и сам kit, чтобы на
# сервере не осталось следов, поэтому своя реализация здесь больше не нужна — команда оставлена
# синонимом, чтобы привычное «kit local uninstall» продолжало работать.
local a=() x
for x in "$@"; do
case $x in
--dry-run | --yes | --no-backup) a+=("$x") ;;
--help | -h)
printf 'kit local uninstall [--dry-run] [--yes] [--no-backup] — то же, что kit uninstall\n'
return 0 ;;
*) kl_die "kit local uninstall [--dry-run] [--yes] [--no-backup]"; return 1 ;;
esac
done
[[ -x /usr/local/bin/kit ]] || { kl_die "не нашёл /usr/local/bin/kit — удалить вручную: kit uninstall"; return 1; }
printf ' удаляю по авторской логике: /usr/local/bin/kit uninstall %s\n' "${a[*]}"
exec /usr/local/bin/kit uninstall "${a[@]}"
}
# ---------------------------------------------------------------- полная справка `kit help`
@@ -1131,7 +1068,10 @@ ${B:-}3X-UI_autoVPS ${KIT_VERSION:-?} — все команды${N:-} (то ж
kit local site [--list | N | --show] сайт-заглушка: случайный ИТ-сайт, список, конкретный, какой стоит
Проверка и починка
kit local uninstall снести установку целиком (панель, туннель, службы kit, сайт, сертификаты); SSH не трогает
kit local access [имя] доступ к панели, сертификат, ссылки и заглушка одним экраном
kit local links [имя] подписка, QR-код и ссылки по протоколам (по умолчанию — первый пользователь)
kit local ports [--close-extra] открытые порты: нужные, лишние; --close-extra закрывает лишние
kit local uninstall то же, что kit uninstall: снести установку целиком и сам kit; SSH не трогает
kit check [--deep | --fix] проверка сервера (--deep — ходит наружу как клиент)
kit fix [--dry-run] безопасные починки: время, службы, конфиги
kit local hardening сводка по защите и что ещё можно включить
+52 -10
View File
@@ -39,6 +39,16 @@ CLASH_UA = re.compile(r"clash|mihomo|flclash|stash|nyanpasu|meta", re.I)
# AmneziaWG добавляем только приложениям на ядре Mihomo. Karing, Hiddify и другие на sing-box
# тоже могут просить формат 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)
# 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}$")
# Страница подписки для браузера (React от 3X-UI) подгружает скрипты и стили из «<путь>/assets/…»: пропускаем только такие файлы.
ASSET = re.compile(r"^assets/[A-Za-z0-9_.-]{1,128}\.(js|css|woff2?|svg|png|ico|map)$")
@@ -65,6 +75,11 @@ 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):
@@ -94,10 +109,32 @@ def fix_userinfo(value):
JSON_FIX_MAX = 2 * 1024 * 1024
def fix_vless_encryption(body):
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) не трогаем. Не разобралось, слишком большое или менять нечего – тело как пришло."""
(например, mlkem) не трогаем. Прежнее (льготное) имя сайта REALITY – на нынешнее (grace).
Не разобралось, слишком большое или менять нечего – тело как пришло."""
if len(body) > JSON_FIX_MAX:
return body
try:
@@ -116,6 +153,11 @@ def fix_vless_encryption(body):
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
@@ -127,9 +169,9 @@ def fix_vless_encryption(body):
return json.dumps(data, ensure_ascii=False).encode() if changed else body
def strip_links(body):
def strip_links(body, grace=None):
"""Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram."""
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram. Прежнее имя сайта REALITY – на нынешнее."""
text = body.decode("utf-8", "replace").strip()
encoded = "://" not in text
if encoded:
@@ -137,7 +179,7 @@ def strip_links(body):
text = base64.b64decode(text + "=" * (-len(text) % 4)).decode("utf-8", "replace")
except ValueError:
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)
return base64.b64encode(out.encode()).decode().encode() if encoded else out.encode()
@@ -416,7 +458,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
ua = self.headers.get("User-Agent", "")
host = CONF.get("link_host") or self.headers.get("Host", CONF.get("host", ""))
accept = self.headers.get("Accept", "")
code, headers, body = upstream(sub_id, ua, host, accept, prefix)
code, headers, body = upstream(sub_id, upstream_ua(ua), host, accept, prefix)
if code is None:
return self.send_html_error(502)
@@ -435,9 +477,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
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)
body = fix_vless_encryption(body, load_grace())
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):
@@ -546,7 +588,7 @@ def main():
cert, key = os.path.join(CREDS, "cert.pem"), os.path.join(CREDS, "key.pem")
if not cert:
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()
return
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -571,7 +613,7 @@ def main():
threading.Thread(target=reload_cert, daemon=True).start()
srv = Server((CONF.get("listen", "0.0.0.0"), int(CONF["port"])), Handler)
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()
+897 -117
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Проверка: подсказка «Выбор [0-N]» в каждом меню kit должна совпадать с числом пунктов.
Пункты считаются по строкам box (с учётом переносов через «\\»). Смысл проверки: мы добавляем в меню
свои пункты — однажды подсказка осталась «0-10» при 11 пунктах, и человек не мог выбрать последний.
"""
import re
import sys
import pathlib
path = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "scripts/kit.sh")
text = path.read_text(encoding="utf-8").replace("\\\n", "")
lines = text.splitlines()
bad = 0
for i, line in enumerate(lines):
m = re.search(r'\bbox\s+"([^"]+)"(.*)$', line)
if not m:
continue
items = re.findall(r'"(\d+)\.\s', m.group(2))
maxn = max((int(x) for x in items), default=0)
prompt = None
for j in range(i + 1, min(i + 120, len(lines))):
pm = re.search(r'ask_num\s+"Выбор \[0-(\d+)\]', lines[j])
if pm:
prompt = int(pm.group(1))
break
if prompt != maxn:
print(f"! меню «{m.group(1)}»: пунктов {maxn}, а подсказка «Выбор [0-{prompt}]»", file=sys.stderr)
bad += 1
print(f"меню проверены: {len(lines) and sum(1 for l in lines if 'box \"' in l)} · рассинхронов: {bad}")
sys.exit(1 if bad else 0)
+89 -19
View File
@@ -116,9 +116,9 @@ def main():
),
(
'scripts/kit.sh',
' # SHA256:VDuGgJ8dOeCNXB4nBZf3+kRlWthw3+vh8rfMxzMSRIM (itsnotkubrick, 2026-09-30)\n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrTGCDhhnm8XO1ekpPJuSWRVCJiFiupEspfQxcbEBmz 3X-UI KIT releases"\n',
' # SHA256:bNjI10eGVGjdFeDJnAHvUFi73rlrXVy6vKFf8lmNyxU (itsnotkubrick, 2026-10-14; прежний ключ SHA256:VDuGgJ8d… утерян, снят)\n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEWDFGq2Wno/ADRS2E6UKsgFbUSLvaiPIfRBvSEGJr81 3X-UI KIT releases"\n',
False,
' # Ключ подписи форка (владелец: hacker07). Закрытая часть — только на рабочей станции.\n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)"\n',
' # Наш шов: здесь только ключ форка (hacker07). Авторские ключи сняты намеренно — сервер обязан\n # принимать обновления лишь из нашего канала (git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS).\n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)"\n',
'авто-шов: replace, строк 2',
),
(
@@ -191,6 +191,13 @@ def main():
' echo "${G}✓ Готово: 3X-UI_autoVPS $latest.${N} Пользователи, ссылки и подписки не менялись."\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' echo "${B}kit fix${N} – безопасные исправления (службы, права, автообновление, сертификат, время)"\n echo\n',
False,
' # Наш шов: строку-описание команды не печатаем — при установке это лишний шум,\n # а смысл виден по результату (подробности — в kit help).\n',
'авто-шов: replace, строк 2',
),
(
'scripts/kit.sh',
'split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/<base64> с Name "3X-UI KIT"\n',
@@ -214,25 +221,32 @@ def main():
),
(
'scripts/kit.sh',
' "7. Резервная копия" "8. Версии" "" "9. Панель 3X-UI (меню x-ui)" "0. Выход"\n',
' "7. Резервная копия" "8. Версии" "" "9. Панель 3X-UI (меню x-ui)" "10. Удалить KIT с сервера" "0. Выход"\n',
False,
' "7. Резервная копия" "8. Версии" "" "9. Удалить всё установленное" "10. Выход" \\\n "11. Панель 3X-UI (меню x-ui)"\n',
' "7. Резервная копия" "8. Версии" "" "9. Доступ к панели, ссылки и заглушка" "10. Панель x-ui" "11. Удалить KIT с сервера" "0. Выход"\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' ask_num "Выбор [0-9]: "; c=$REPLY\n',
' ask_num "Выбор [0-10]: "; c=$REPLY\n',
False,
' ask_num "Выбор [1-11]: "; c=$REPLY\n',
' ask_num "Выбор [0-11]: "; c=$REPLY\n',
'авто-шов: replace, строк 1',
),
(
'scripts/kit.sh',
' 9) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 0 | "") return 0 ;;\n',
' 9) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 10) run_action cmd_uninstall; pause ;;\n',
False,
' 9) kit_local_uninstall && return 0 ;; # снос — и сразу выход: показывать меню после удаления незачем\n 10 | 0 | "") return 0 ;;\n 11) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n',
' # Наш шов: пункт 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',
@@ -289,6 +303,34 @@ def main():
' 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",
@@ -307,14 +349,14 @@ def main():
'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 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 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',
' 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 && ! -f $XUI_ENV && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
' 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 && ! -f $XUI_ENV && -t 1 && ${KL_PICK_SNI:-0} != 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
' # Наш шов: когда выбор сайта делает наше меню (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',
),
(
@@ -328,7 +370,7 @@ def main():
'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 if domain_points_here "$DOMAIN" 2>/dev/null; then\n SNI=$DOMAIN; ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let\'s Encrypt выпустит установщик)"\n else\n SNI=$DOMAIN; later "домен $DOMAIN не указывает на этот сервер — маскируюсь под него как под чужой сайт"\n fi\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',
' # Наш шов: показываем найденное и даём выбрать — отдельно для 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',
),
(
@@ -352,6 +394,20 @@ def main():
' # Наш шов: если 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',
@@ -366,6 +422,20 @@ def main():
' 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',
@@ -424,17 +494,17 @@ def main():
),
(
'scripts/3x-ui.sh',
' || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
' [[ $(LC_ALL=C tr -d \'\\n -~\' <"$f" | wc -c) -eq 0 ]] \\\n || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
False,
' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 1',
' [[ $(LC_ALL=C tr -d \'\\n -~\' <"$f" | wc -c) -eq 0 ]] \\\n || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 2',
),
(
'scripts/3x-ui.sh',
' && die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
' [[ $rc -eq 1 ]] \\\n || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
False,
' && die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 1',
' [[ $rc -eq 1 ]] \\\n || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 2',
),
(
'scripts/3x-ui.sh',
@@ -489,7 +559,7 @@ def main():
'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: нужен свободный порт 80, открытый в ufw (и у хостера), и A-запись домена на этот сервер. Лог: /var/log/kit-domain-cert.log"; }\n',
' # Наш шов: открываем 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',
),
(
+25 -5
View File
@@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
die() { printf '✗ %s\n' "$*" >&2; exit 1; }
ok() { printf '✓ %s\n' "$*"; }
warn() { printf '! %s\n' "$*" >&2; } # сеть до GitHub отвалилась: проверку пропускаем, релиз не блокируем
sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; }
key=${1:-$HOME/.ssh/3xui_kit_release}
@@ -58,13 +59,26 @@ for fn in ask_yes ask_ssh_port ssh_ports_now ask_protocols proto_parse proto_men
[[ -z $c || $c -gt $d ]] || die "scripts/3x-ui-local.sh: $fn вызывается (строка $c) раньше, чем определена ($d)"
done
ok "Все наши функции определены раньше своих вызовов"
# python3 есть не везде (на Windows обычно только python) — берём тот, что есть.
py=$(command -v python3 || command -v python || true)
[[ -n $py ]] || die "нет python — им проверяются меню (tools/check-menus.py)"
"$py" tools/check-menus.py >/dev/null || die "подсказка «Выбор [0-N]» не совпадает с числом пунктов меню (tools/check-menus.py)"
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.
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)
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. Проверьте, что поменялось, прежде чем выпускать."
ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256"
@@ -72,7 +86,8 @@ ok "Установщик 3X-UI $xv совпадает с закреплённы
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=$(curl -fsSL "https://github.com/XTLS/Xray-core/releases/download/$xc/Xray-linux-$a.zip.dgst" | awk '/^SHA2-256=/ {print $2}')
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 совпадают с официальными"
@@ -85,11 +100,13 @@ 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=$(curl -fsSL "https://github.com/MHSanaei/3x-ui/releases/download/$kp/x-ui-linux-$a.tar.gz.sha256" | awk 'NR == 1 {print $1}')
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=$(curl -fsSL "https://raw.githubusercontent.com/MHSanaei/3x-ui/$kp/x-ui.sh" | sha | awk '{print $1}')
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 совпадают с установщиком и официальными суммами"
@@ -102,7 +119,10 @@ ok "Ключ подписи записан в kit"
{ echo "# 3X-UI KIT $v"; echo "# 3X-UI_autoVPS $v"; sha "${FILES[@]}" | awk '{ sub(/^\*/, "", $2); print $1" "$2 }'; } >SHA256SUMS
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)
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"