Compare commits

...
21 Commits
Author SHA1 Message Date
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
13 changed files with 1659 additions and 463 deletions
+119
View File
@@ -1,3 +1,122 @@
## 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 открывается прямо перед выпуском сертификата ## v1.2.22: 80 в ufw открывается прямо перед выпуском сертификата
* Настоящая причина сбоя сертификата (проверено на живом сервере): установщик открывает список * Настоящая причина сбоя сертификата (проверено на живом сервере): установщик открывает список
+16
View File
@@ -190,6 +190,22 @@ bash tools/fork-sync.sh # подтянуть автора, перен
# при конфликте — разобрать вручную: инструмент сам ничего не публикует # при конфликте — разобрать вручную: инструмент сам ничего не публикует
bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key # SHA256SUMS + подпись нашим ключом bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key # SHA256SUMS + подпись нашим ключом
**Нумерация версий.** Основа — версия автора, наша надстройка добавляется через точку с единицы:
у автора 1.2.1 → наша сборка 1.2.1.1; вышла его 1.2.2 → наша 1.2.2.1. Так на сервере всегда видно,
на чьей базе собрана сборка.
**Обновление базы автора.** Когда автор выпускает новую версию: перенести его файлы
(`git checkout upstream -- scripts/kit.sh scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit-sub.py`),
прогнать `python tools/fork_apply.py` и посмотреть, какие якоря не нашли себя. Те, чью работу автор уже
делает сам, — вычеркнуть; остальные перенастроить на его новый код и пересобрать таблицу швов
`git branch -f upstream <коммит автора>`.
Скрипт сам приводит переводы строк к LF, проверяет справку и то, что установщик не ругается на пустые
строки, сверяет панель и ядро Xray с официальными хешами GitHub (если GitHub недоступен — предупреждает и
пропускает эти проверки за секунды, а не висит; расхождение хешей при живом GitHub по-прежнему останавливает
выпуск). Далее скрипт ждёт **парольную фразу ключа** — её вводит человек, поэтому запускать надо в окне, где
можно набрать пароль (в PowerShell — да, в фоне — нет).
git add -A && git commit -m "Релиз v$(cat VERSION): …" && git tag v$(cat VERSION) git add -A && git commit -m "Релиз v$(cat VERSION): …" && git tag v$(cat VERSION)
git push origin dev v$(cat VERSION) upstream git push origin dev v$(cat VERSION) upstream
``` ```
+9 -9
View File
@@ -1,9 +1,9 @@
# 3X-UI KIT 1.2.22 # 3X-UI KIT 1.2.1.3
# 3X-UI_autoVPS 1.2.22 # 3X-UI_autoVPS 1.2.1.3
8d090a042d1d243203b6c0f726ee2f81b00aa1c553924ae7fe239e6d324e420d scripts/3x-ui.sh dc3668609015e68fe008026cd9e3456305f019cd04bffccf59c56987d4511e6c scripts/3x-ui.sh
4d046772692ee7007bc47d202317623e2524fb04a0b4037aae10bfaa2fffc72c scripts/hysteria2.sh 020884ccc78e07108b145975377b29213e2d0d04b21ece51c1bf5028071748ff scripts/hysteria2.sh
04aed8fd115159269fe45e79b31614778a6b23b53d45b5ab6db17e729ffabf24 scripts/kit.sh 5c50d0f6d192952ddf685f7834e5ff1ce4f23edab3721576319caacd23b7d25b scripts/kit.sh
4027ea2c6dbc9fd176356acac3f676445419c2f021146ed25dfe5a304c3ac5fb scripts/kit-sub.py f214671464e1c2e174e5af9df42c95567a9b4a9141f31a39f7df2e971e89d240 scripts/kit-sub.py
c38e5fe05bec66e747bb3feee2af4444d3a53d66e74ef1acb59861fbafa65e4c scripts/kit-local.sh 04855a17e360eefb025f4fd6d1e8e49cb8fa09287b9b8884a4cb53d4b96815a1 scripts/kit-local.sh
c219de310f9b0af6d83995fbe708f0f04b9884f924aa7d0095b7a7555c3b7bf7 scripts/3x-ui-local.sh 33c9dab6ce6f82a4a0ead93108b20e25c1030c9863e92a3fa6128cc674df187e scripts/3x-ui-local.sh
552d1b0bcdbecc5cc39f8b9d98564f34edfaefeda6478e91eb54176935bde53a VERSION 3030c01eabf883790e48256b43cf177b159b87f2f69a6c196c8d13bcf765cdd3 VERSION
+2 -2
View File
@@ -1,6 +1,6 @@
-----BEGIN SSH SIGNATURE----- -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV
FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
AAALc3NoLWVkMjU1MTkAAABAhKwW7tjjJQf7d0Dr+17/JrDW93JXMDNPAn4+88utnpBq5a AAALc3NoLWVkMjU1MTkAAABA14Sm1dcHury6A2+pEYc8Tj4tK3iEMVgp1DibjGQxLWdedu
lHiMvRPEFnp6UOXR98Ee+TDIyVT3NExbdyOAhqDg== kMFFfU3f+3pWyLm0yw/piCgRY+9NVxoFXap1ogDw==
-----END SSH SIGNATURE----- -----END SSH SIGNATURE-----
+1 -1
View File
@@ -1 +1 @@
1.2.22 1.2.1.3
+51 -15
View File
@@ -44,7 +44,7 @@
set -Eeuo pipefail set -Eeuo pipefail
KIT_VERSION="1.2.22" # версия сборки форка; должна совпадать с VERSION релиза KIT_VERSION="1.2.1.3" # версия сборки форка; должна совпадать с VERSION релиза
GITEA="${KIT_FORK_BASE:-https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS}" GITEA="${KIT_FORK_BASE:-https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS}"
NS="3x-ui-kit-release" NS="3x-ui-kit-release"
SIG_ID="releases@3x-ui-kit" SIG_ID="releases@3x-ui-kit"
@@ -276,11 +276,11 @@ ask_protocols() { # печатает итоговый список через з
interactive || { printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"; return 0; } interactive || { printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"; return 0; }
proto_menu proto_menu
for i in 1 2 3; do 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="" ans=""
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
if picked=$(proto_parse "$ans"); then printf '%s' "$picked"; return 0; fi if picked=$(proto_parse "$ans"); then printf '%s' "$picked"; return 0; fi
warn "не понял «$ans» — нужны номера из списка через запятую или «все»" warn "не понял «$ans» — нужны номера из списка через пробел или «все»"
done done
warn "оставляю набор по умолчанию" warn "оставляю набор по умолчанию"
printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")" printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"
@@ -446,6 +446,28 @@ else
fi fi
command -v kit >/dev/null || die "после установки нет команды kit — что-то пошло не так, смотрите вывод выше" 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>/dev/null \
&& [[ -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)"
fi
fi
fi
# Запоминаем, какие протоколы выбраны: наши команды (kit local site и др.) смотрят сюда и не # Запоминаем, какие протоколы выбраны: наши команды (kit local site и др.) смотрят сюда и не
# делают ничего для протоколов, которых в этой установке нет. # делают ничего для протоколов, которых в этой установке нет.
printf '%s\n' "${protos//,/ }" | tr ' ' '\n' | sed '/^$/d' > /etc/kit/kl-protos 2>/dev/null || true printf '%s\n' "${protos//,/ }" | tr ' ' '\n' | sed '/^$/d' > /etc/kit/kl-protos 2>/dev/null || true
@@ -462,7 +484,9 @@ fi
# Сайт-заглушка: у автора это случайные «кофейни и пекарни», у нас — правдоподобный ИТ-сайт. # Сайт-заглушка: у автора это случайные «кофейни и пекарни», у нас — правдоподобный ИТ-сайт.
# Меняем не файл вручную, а командой форка: она же обновляет копию страницы в ответе Hysteria2, # Меняем не файл вручную, а командой форка: она же обновляет копию страницы в ответе Hysteria2,
# иначе 443/tcp и UDP-порт начнут отвечать разными страницами (это видно снаружи). # иначе 443/tcp и UDP-порт начнут отвечать разными страницами (это видно снаружи).
case $site in case ${stub_staged:-no} in
yes) say "заглушка уже стоит (поставлена при установке, Hysteria2 синхронизирован тем же файлом)" ;;
*) case $site in
off) say "заглушку не трогаю (--no-site): остаётся страница автора" ;; off) say "заглушку не трогаю (--no-site): остаётся страница автора" ;;
random) random)
say "ставлю сайт-заглушку: случайный ИТ-сайт из нашего набора" say "ставлю сайт-заглушку: случайный ИТ-сайт из нашего набора"
@@ -472,6 +496,7 @@ case $site in
say "ставлю сайт-заглушку №$site" say "ставлю сайт-заглушку №$site"
kit local site "$site" || warn "не удалось поставить заглушку — позже: kit local site $site" kit local site "$site" || warn "не удалось поставить заглушку — позже: kit local site $site"
;; ;;
esac ;;
esac esac
if [[ $fix_time == yes ]]; then if [[ $fix_time == yes ]]; then
@@ -540,6 +565,22 @@ if [[ $show_sub == yes ]]; then
else else
warn "файла /root/3x-ui.txt нет — адрес панели и логин были в выводе установщика выше" warn "файла /root/3x-ui.txt нет — адрес панели и логин были в выводе установщика выше"
fi fi
# Наш шов: показываем состояние сертификата панели. Панель и подписка живут на IP, поэтому
# сертификат Let's Encrypt на IP — не мелочь: с ним браузер и приложения не ругаются.
cert=/root/cert/ip/fullchain.pem
if [[ -s $cert ]] && openssl x509 -in "$cert" -noout -issuer 2>/dev/null | grep -q "Let's Encrypt"; then
printf ' %sсертификат панели: Let'"'"'s Encrypt на IP — доверенный, браузер не ругается%s\n' "$G" "$N"
elif [[ -s $cert ]]; then
printf ' %sсертификат панели: самоподписанный — браузер предупредит, подписка в приложениях может не открыться%s\n' "$Y" "$N"
till2=
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 //')
if [[ -n $till2 ]]; then
printf ' %sLet'"'"'s Encrypt на IP: лимит выпусков, повтор возможен после %s — тогда запустите установщик снова, он возьмёт сертификат%s\n' "$D" "$till2" "$N"
fi
fi
if grep -qaE '^Панель: ?https?://[0-9]' /root/3x-ui.txt 2>/dev/null; then
printf ' Завести свой домен и перейти на него: kit net panel domain%s\n' "$N"
fi
fi fi
# ------------------------------------------------------------------ подписка, QR-код и доступы # ------------------------------------------------------------------ подписка, QR-код и доступы
@@ -552,15 +593,10 @@ if [[ $show_sub == yes ]]; then
for ((_i = 0; _i < ${#INSTALLER_ARGS[@]}; _i++)); do for ((_i = 0; _i < ${#INSTALLER_ARGS[@]}; _i++)); do
[[ ${INSTALLER_ARGS[_i]} == --user ]] && sub_user=${INSTALLER_ARGS[_i + 1]:-admin} [[ ${INSTALLER_ARGS[_i]} == --user ]] && sub_user=${INSTALLER_ARGS[_i + 1]:-admin}
done done
say "подписка и доступы — это то, что стоит сохранить" # Наш шов: ссылки для подключения и QR-код в выводе установки не печатаем — их смотрят потом,
printf ' подписка — одна ссылка на все устройства; QR-код ниже наводите камерой в приложении\n' # когда настраивают телефон, и они всегда доступны заново в меню kit. Здесь только путь к ним.
printf ' приложения: Happ, Hiddify, Karing, v2rayN, Clash/Mihomo\n' say "ссылки для подключения и QR-код — в меню kit"
printf ' отдельные ссылки по протоколам — ниже же (если приложение не умеет подписку)\n' printf ' %skit%s → Пользователи → Показать ссылки подписка, QR-код и ссылки по протоколам\n' "$B" "$N"
printf ' %sпозже то же самое: kit user link %s --all (или меню: kit)%s\n' "$D" "$sub_user" "$N" printf ' из командной строки: %s kit local links %s (подписка и QR) · kit user link %s --all\n' "$D" "$N" "$sub_user"
[[ -n ${ssh_port:-} ]] && printf ' SSH: новый порт %s, прежние (%s) ещё открыты — закройте их после проверки входа: kit local ssh --close-old %s\n' "$ssh_port" "${ssh_ports_before:-22}" "$ssh_port" [[ -n ${ssh_port:-} ]] && printf ' SSH: новый порт %s, прежние (%s) ещё открыты — закройте их, когда убедитесь, что вход работает: kit local ssh --close-old\n' "$ssh_port" "22" >&2
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 fi
+388 -111
View File
@@ -16,6 +16,15 @@ set -Eeuo pipefail
# На свежем VPS в фоне идут автообновления системы и держат замок dpkg: ждём его, а не падаем. # На свежем VPS в фоне идут автообновления системы и держат замок dpkg: ждём его, а не падаем.
apt-get() { command apt-get -o DPkg::Lock::Timeout=900 "$@"; } 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() { wait_apt_idle() {
local i local i
pgrep -f '/usr/bin/unattended-upgrade$|apt\.systemd\.daily' >/dev/null || return 0 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" XUI_VERSION="v3.9.0"
# SHA256 установщика 3X-UI этой версии: тег могут передвинуть, а хеш – нет (проверено 2026-09-30). # SHA256 установщика 3X-UI этой версии: тег могут передвинуть, а хеш – нет (проверено 2026-09-30).
XUI_INSTALL_SHA256="18616fe26c8f6c92db6daa2dcd7cd53c5143ee69ecfd26d8ea6dc9b2c78607a6" XUI_INSTALL_SHA256="18616fe26c8f6c92db6daa2dcd7cd53c5143ee69ecfd26d8ea6dc9b2c78607a6"
KIT_VERSION="1.2.22" KIT_VERSION="1.2.1.3"
# kit и kit-sub берём из того же релиза, что и этот скрипт, а не из меняющейся ветки main. # kit и kit-sub берём из того же релиза, что и этот скрипт, а не из меняющейся ветки main.
KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$KIT_VERSION" KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$KIT_VERSION"
# Ядро Xray для панели. С 26.7.x клиенты на Mihomo и sing-box (Hiddify, FlClash, # Ядро 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) 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) ALL_PROTOS=(reality reality2 hy2 xhttp ws trojan vmess ss tuic wg awg awg3 mtproto)
# Обычный WireGuard легко распознаётся сетевым оборудованием и работает нестабильно # Стандартный набор; остальные протоколы (например, wg) отмечаются в «На выбор» или через --protocols.
# (проверено 2026-09-27: рукопожатие доходит до сервера, ответ – нет). По умолчанию не ставим.
DEFAULT_PROTOS=(reality hy2 xhttp ws trojan vmess ss tuic awg awg3 mtproto) 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) 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=() PROTOS=(); CREATED=(); OPEN=(); NB_ALL=()
@@ -79,9 +87,61 @@ WARNINGS=(); STEP=0; STEPS=6
say() { printf ' %s\n' "$*"; } # подробность под шагом say() { printf ' %s\n' "$*"; } # подробность под шагом
ok() { printf ' %s\n' "${G}✓${N} $*"; } ok() { printf ' %s\n' "${G}✓${N} $*"; }
step() { STEP=$((STEP + 1)); printf '\n%s\n' "${B}[$STEP/$STEPS]${N} $1"; } 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+=("$*"); } # в блок «Внимание» в конце 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 trap 'die "Ошибка в строке $LINENO. Исправьте причину и запустите скрипт ещё раз."' ERR
rand_str() { openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$1"; } rand_str() { openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$1"; }
@@ -201,7 +261,7 @@ nearby_sites() {
domain_points_here() { # домен domain_points_here() { # домен
local me=$HOST ips local me=$HOST ips
[[ $me =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || me=$(public_ip) [[ $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 if [[ -z $ips ]]; then
warn "У домена $1 нет A-записи. Добавьте её у регистратора: тип A, значение $me." warn "У домена $1 нет A-записи. Добавьте её у регистратора: тип A, значение $me."
elif ! grep -qx "$me" <<<"$ips"; then elif ! grep -qx "$me" <<<"$ips"; then
@@ -224,22 +284,75 @@ panel_on_choice() {
echo "${B}Где открывать панель и подписку?${N}" echo "${B}Где открывать панель и подписку?${N}"
echo " ${B}1${N} По IP https://$HOST/… как раньше ${G}[по умолчанию]${N}" echo " ${B}1${N} По IP https://$HOST/… как раньше ${G}[по умолчанию]${N}"
echo " ${B}2${N} По домену https://$DOMAIN/… сертификат домена, ссылка не зависит от IP" 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 ask_tty "Выбор [1]: " || return 0
REPLY=${REPLY//[[:space:]]/} REPLY=${REPLY//[[:space:]]/}
[[ ${REPLY%.} == 2 ]] && PANEL_ON=domain [[ ${REPLY%.} == 2 ]] && PANEL_ON=domain
return 0 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() { choose_masking() {
echo echo
echo "${B}Под какой сайт маскировать сервер?${N}" echo "${B}Под какой сайт маскировать сервер?${N}"
echo "Со стороны он будет выглядеть как обычный сайт." echo "Со стороны он будет выглядеть как обычный сайт."
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}2${N} Сосед по подсети подберу сам, домен не нужен ${G}[рекомендуем]${N}"
echo " ${B}3${N} Свой сайт вы вводите адрес сами" echo " ${B}3${N} Другой сайт вы сами вводите адрес любого сайта (подходит хуже соседа)"
echo " ${D}Сертификат своего домена виден в публичных журналах сертификатов: связь «домен – сервер» не скрыта.${N}" echo " ${D}Сертификат своего домена виден в публичных журналах сертификатов: связь «домен – сервер» не скрыта.${N}"
echo echo
local d="" prev="" local d="" prev=""
@@ -278,6 +391,7 @@ choose_masking() {
# Продлевает его тот же cron, а после продления nginx перечитывает сертификат. # Продлевает его тот же cron, а после продления nginx перечитывает сертификат.
issue_domain_cert() { issue_domain_cert() {
local acme=/root/.acme.sh/acme.sh rc=0 log=/var/log/kit-domain-cert.log 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 ]] \ 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 -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" \ && 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 return 0
fi fi
[[ -x $acme ]] || { warn "Не нашёл acme.sh, которым установщик 3X-UI получает сертификаты: не могу выпустить сертификат для $DOMAIN."; return 1; } [[ -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; } port_busy 80 tcp && { warn "Порт 80/tcp занят: Let's Encrypt не сможет проверить домен $DOMAIN."; return 1; }
say "Получаю сертификат Let's Encrypt для ${B}$DOMAIN${N}" say "Получаю сертификат Let's Encrypt для ${B}$DOMAIN${N}"
install -m 600 /dev/null "$log" install -m 600 /dev/null "$log"
# Сбои бывают временными (например, ошибка TLS при скачивании готового сертификата): до трёх попыток. # Сбои бывают временными (например, ошибка TLS при скачивании готового сертификата): до трёх попыток.
local try local try
spin_start "Жду ответа Let's Encrypt"
for try in 1 2 3; do for try in 1 2 3; do
rc=0 rc=0
"$acme" --issue -d "$DOMAIN" --standalone --httpport 80 --server letsencrypt --keylength ec-256 ${rc_force:-} >>"$log" 2>&1 || rc=$? "$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" local rc_force="--force"
sleep 10 sleep 10
done done
spin_stop
# 2 – сертификат уже свежий, выпускать заново не нужно. # 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). # Каталог и ключ – только для root (nginx читает их от root).
install -d -m 700 "$DOMAIN_CERT_DIR" install -d -m 700 "$DOMAIN_CERT_DIR"
(umask 077; "$acme" --install-cert -d "$DOMAIN" --ecc --fullchain-file "$DOMAIN_CERT_DIR/fullchain.pem" \ (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() { ip_cert_self_signed_fallback() {
local san="IP:$HOST" local san="IP:$HOST"
[[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$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. Частые причины:" warn "Let's Encrypt не выдал сертификат на IP $HOST. Частые причины:"
{ {
echo " – лимит Let's Encrypt: 5 сертификатов в неделю на один IP;" echo " – лимит Let's Encrypt: 5 сертификатов в неделю на один IP;"
echo " – входящий порт 80 закрыт (у хостера или в файрволе);" echo " – входящий порт 80 закрыт (у хостера или в файрволе);"
echo " – сбой у самого Let's Encrypt (лог: /var/log/3x-ui-install.log)." echo " – сбой у самого Let's Encrypt (лог: /var/log/3x-ui-install.log)."
} >&2
fi
{
echo " Можно продолжить с самоподписанным сертификатом: ссылки на подключения работают, браузер покажет" echo " Можно продолжить с самоподписанным сертификатом: ссылки на подключения работают, браузер покажет"
echo " предупреждение, а подписка в приложениях может не открыться." echo " предупреждение, а подписка в приложениях может не открыться."
} >&2 } >&2
@@ -345,6 +490,16 @@ ip_cert_self_signed_fallback() {
return 0 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 отправляет чужих гостей: для своего домена – на nginx этого сервера.
reality_target() { # сайт reality_target() { # сайт
if [[ -n $DOMAIN && $1 == "$DOMAIN" ]]; then echo "127.0.0.1:${INNER[selfweb]}"; else echo "$1:443"; fi 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] api() { # METHOD path [json]
local url="$API/$2" out local url="$API/$2" out
if [[ $1 == GET ]]; then 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 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 fi
[[ $(jq -r '.success' <<<"$out") == true ]] || die "Панель ответила ошибкой на $2: $(jq -r '.msg // .' <<<"$out" | head -c 300)" [[ $(jq -r '.success' <<<"$out") == true ]] || die "Панель ответила ошибкой на $2: $(jq -r '.msg // .' <<<"$out" | head -c 300)"
jq -c '.obj' <<<"$out" jq -c '.obj' <<<"$out"
@@ -366,7 +521,7 @@ api() { # METHOD path [json]
wait_panel() { wait_panel() {
local i local i
for i in $(seq 1 60); do 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 sleep 2
done done
die "Панель не отвечает. Лог: journalctl -u x-ui -n 50" die "Панель не отвечает. Лог: journalctl -u x-ui -n 50"
@@ -426,7 +581,7 @@ main() {
die "3X-UI уже установлена другим способом – не трогаю её. Удалите её (x-ui uninstall) или добавьте REALITY в панели вручную." die "3X-UI уже установлена другим способом – не трогаю её. Удалите её (x-ui uninstall) или добавьте REALITY в панели вручную."
fi 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 while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--port | --sni | --panel-ssl | --host | --user | --protocols | --domain | --cert | --key | --restore | --panel-on) --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 ;; --panel-ssl) PANEL_SSL=$2; shift 2 ;;
--host) HOST=$2; shift 2 ;; --host) HOST=$2; shift 2 ;;
--user) NAME=$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 ;; --domain) DOMAIN=$2; shift 2 ;;
--cert) ucert=$2; shift 2 ;; --cert) ucert=$2; shift 2 ;;
--multi-port) multi=yes; shift ;; --multi-port) multi=yes; shift ;;
@@ -476,7 +631,7 @@ main() {
[[ $PANEL_ON == ip || $PANEL_ON == domain ]] || die "--panel-on: ip или domain" [[ $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)." [[ $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) или домен" [[ -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" [[ $PANEL_SSL =~ ^(auto|ip|none)$ ]] || die "--panel-ssl: auto, ip или none"
if [[ -n $ucert || -n $ukey ]]; then if [[ -n $ucert || -n $ukey ]]; then
[[ -s $ucert && -s $ukey ]] || die "Нужны оба файла: --cert fullchain.pem --key privkey.pem" [[ -s $ucert && -s $ukey ]] || die "Нужны оба файла: --cert fullchain.pem --key privkey.pem"
@@ -497,6 +652,24 @@ main() {
die "Порт $PORT/tcp уже занят. REALITY нужен свободный порт – укажите другой: --port 8443" die "Порт $PORT/tcp уже занят. REALITY нужен свободный порт – укажите другой: --port 8443"
fi 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 [[ $PANEL_SSL == auto ]]; then
if port_busy 80 tcp; then if port_busy 80 tcp; then
PANEL_SSL=none PANEL_SSL=none
@@ -511,8 +684,9 @@ main() {
[[ $PANEL_SSL == ip || $PANEL_SSL == custom ]] && TRUSTED=yes [[ $PANEL_SSL == ip || $PANEL_SSL == custom ]] && TRUSTED=yes
if [[ $PANEL_SSL == custom ]]; then if [[ $PANEL_SSL == custom ]]; then
mkdir -p /root/cert/custom mkdir -p /root/cert/custom
install -m 644 "$ucert" /root/cert/custom/fullchain.pem # Те же файлы (повторный запуск) install не копирует сам в себя – только права.
install -m 600 "$ukey" /root/cert/custom/privkey.pem 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 fi
kit_banner kit_banner
@@ -520,7 +694,7 @@ main() {
echo "Поставлю панель 3X-UI и протоколы на этот сервер. Займёт 3–5 минут." echo "Поставлю панель 3X-UI и протоколы на этот сервер. Займёт 3–5 минут."
if ! command -v curl >/dev/null || ! command -v openssl >/dev/null; then if ! command -v curl >/dev/null || ! command -v openssl >/dev/null; then
export DEBIAN_FRONTEND=noninteractive; wait_apt_idle 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 fi
local host_given=${HOST:+yes} local host_given=${HOST:+yes}
@@ -531,6 +705,7 @@ main() {
mem_gb=$(awk '/MemTotal/ {printf "%.1f", $2 / 1048576}' /proc/meminfo 2>/dev/null || echo "?") mem_gb=$(awk '/MemTotal/ {printf "%.1f", $2 / 1048576}' /proc/meminfo 2>/dev/null || echo "?")
echo echo
echo "Сервер: ${B}$HOST${N} · $os_name · ${mem_gb} ГБ памяти" echo "Сервер: ${B}$HOST${N} · $os_name · ${mem_gb} ГБ памяти"
[[ -f $XUI_ENV && ! -f $RESULT ]] && echo "${D}Нашёл следы прерванной установки: продолжу с ними. Ответы на вопросы ниже нужны заново, прошлые выборы не сохраняются.${N}"
# IP определился снаружи, а ссылки получат именно его: у части хостеров это не адрес самого сервера. # IP определился снаружи, а ссылки получат именно его: у части хостеров это не адрес самого сервера.
if [[ -z $host_given && $yes == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then if [[ -z $host_given && $yes == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then
ask_tty "Адрес в ссылках: $HOST. Верно? Enter – да, или введите другой IPv4: " || true ask_tty "Адрес в ссылках: $HOST. Верно? Enter – да, или введите другой IPv4: " || true
@@ -605,13 +780,18 @@ kl_sni_menu() {
return 0 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 if [[ -n $DOMAIN ]]; then
[[ $PANEL_SSL == ip ]] || die "Свой домен работает с сертификатом панели Let's Encrypt на IP: освободите порт 80 и не указывайте --cert, --key и --panel-ssl none (сертификат для домена установщик получит сам)." [[ $PANEL_SSL == ip ]] || die "Свой домен работает с сертификатом панели Let's Encrypt на IP: освободите порт 80 и не указывайте --cert, --key и --panel-ssl none (сертификат для домена установщик получит сам)."
domain_points_here "$DOMAIN" || die "Исправьте A-запись домена и запустите скрипт снова (DNS обновляется от нескольких минут до нескольких часов)." domain_points_here "$DOMAIN" || die "Исправьте A-запись домена и запустите скрипт снова (DNS обновляется от нескольких минут до нескольких часов)."
# Наш шов: когда выбор сайта делает наше меню (KL_PICK_SNI=1), меню автора не показываем — иначе # Наш шов: когда выбор сайта делает наше меню (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 choose_masking
fi fi
if [[ -n $DOMAIN ]]; then if [[ -n $DOMAIN ]]; then
@@ -621,7 +801,9 @@ kl_sni_menu() {
echo "Сканирую подсеть: около 250 коротких обращений к порту 443 соседних адресов (около минуты)." echo "Сканирую подсеть: около 250 коротких обращений к порту 443 соседних адресов (около минуты)."
echo "По результатам предложу сайты для маскировки. Не нужно – запустите с --sni или --domain." echo "По результатам предложу сайты для маскировки. Не нужно – запустите с --sni или --domain."
local -a nb=() local -a nb=()
spin_start "Опрашиваю соседние адреса"
mapfile -t nb < <(nearby_sites | shuf) mapfile -t nb < <(nearby_sites | shuf)
spin_stop
NB_ALL=("${nb[@]}") NB_ALL=("${nb[@]}")
# Наш шов: показываем найденное и даём выбрать — отдельно для REALITY и для XHTTP/MTProto. # Наш шов: показываем найденное и даём выбрать — отдельно для REALITY и для XHTTP/MTProto.
if [[ ${KL_PICK_SNI:-} == 1 && $yes == no ]] && { : </dev/tty; } 2>/dev/null; then if [[ ${KL_PICK_SNI:-} == 1 && $yes == no ]] && { : </dev/tty; } 2>/dev/null; then
@@ -631,11 +813,32 @@ kl_sni_menu() {
own:*) own:*)
own_sni=yes own_sni=yes
DOMAIN=${pick#own:} DOMAIN=${pick#own:}
if domain_points_here "$DOMAIN" 2>/dev/null; then # Наш шов: домен вводится заново, пока он не указывает на этот сервер. Раньше при промахе
SNI=$DOMAIN; ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let's Encrypt выпустит установщик)" # установка молча маскировалась под чужой сайт, а при домене из флага — обрывалась.
else # A-запись у регистратора расходится не мгновенно, поэтому первым пунктом — «проверить снова».
SNI=$DOMAIN; later "домен $DOMAIN не указывает на этот сервер — маскируюсь под него как под чужой сайт" while :; do
fi 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:} ;; sni:*) SNI=${pick#sni:} ;;
esac esac
@@ -668,7 +871,7 @@ kl_sni_menu() {
ask_tty " Продолжить с запасным сайтом из списка? [y/N] " || true ask_tty " Продолжить с запасным сайтом из списка? [y/N] " || true
[[ $REPLY =~ ^[yYдД]$ ]] || die "Остановился по вашей просьбе. Ставить можно снова в любой момент." [[ $REPLY =~ ^[yYдД]$ ]] || die "Остановился по вашей просьбе. Ставить можно снова в любой момент."
else else
later "Сайт для маскировки взят из общего списка (в подсети сервера подходящего не нашлось). Надёжнее свой домен или kit net site." later "Сайт для маскировки взят из общего списка (в подсети сервера подходящего не нашлось). Надёжнее свой домен или kit → Протоколы, порты и маскировка → Сменить сайт маскировки."
fi fi
local -a pool=() local -a pool=()
mapfile -t pool < <(printf '%s\n' "${SNI_CANDIDATES[@]}" | shuf) mapfile -t pool < <(printf '%s\n' "${SNI_CANDIDATES[@]}" | shuf)
@@ -694,7 +897,9 @@ kl_sni_menu() {
nb2=("${NB_ALL[@]}") nb2=("${NB_ALL[@]}")
else else
echo "Подбираю сайты для XHTTP и MTProto… (около 250 коротких обращений к порту 443 соседних адресов вашей подсети)" echo "Подбираю сайты для XHTTP и MTProto… (около 250 коротких обращений к порту 443 соседних адресов вашей подсети)"
spin_start "Опрашиваю соседние адреса"
mapfile -t nb2 < <(nearby_sites | shuf) mapfile -t nb2 < <(nearby_sites | shuf)
spin_stop
fi fi
for s in "${nb2[@]}"; do for s in "${nb2[@]}"; do
[[ $s == "$SNI" || $s == "$SNI2" || $s == "$SNI3" ]] && continue [[ $s == "$SNI" || $s == "$SNI2" || $s == "$SNI3" ]] && continue
@@ -710,15 +915,14 @@ kl_sni_menu() {
[[ -z $SNI3 && -n $SNI2 ]] && { SNI3=$s; break; } [[ -z $SNI3 && -n $SNI2 ]] && { SNI3=$s; break; }
done done
SNI2=${SNI2:-$SNI}; SNI3=${SNI3:-www.cloudflare.com} 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 "Пакеты" step "Пакеты"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
wait_apt_idle 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 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 и другое" ok "curl, jq, openssl, qrencode, ufw и другое"
# --- официальный установщик 3X-UI с закреплённой версией --- # --- официальный установщик 3X-UI с закреплённой версией ---
@@ -728,7 +932,9 @@ kl_sni_menu() {
panel_path=$(rand_str 18) panel_path=$(rand_str 18)
panel_user=$(rand_str 10) panel_user=$(rand_str 10)
panel_pass=$(rand_str 20) panel_pass=$(rand_str 20)
local rerun=no
if [[ -f $XUI_ENV ]]; then if [[ -f $XUI_ENV ]]; then
rerun=yes
say "3X-UI уже стоит после прошлого запуска – продолжаю с создания подключений" say "3X-UI уже стоит после прошлого запуска – продолжаю с создания подключений"
else else
install_xui "$panel_port" "$panel_path" "$panel_user" "$panel_pass" install_xui "$panel_port" "$panel_path" "$panel_user" "$panel_pass"
@@ -754,7 +960,12 @@ kl_sni_menu() {
if [[ -n $DOMAIN ]]; then if [[ -n $DOMAIN ]]; then
ip_cert_self_signed_fallback || die "Остановил установку по вашему выбору. Запустите скрипт снова без --domain (панель будет доступна через SSH-туннель) или позже, когда сертификат на IP снова можно будет получить." ip_cert_self_signed_fallback || die "Остановил установку по вашему выбору. Запустите скрипт снова без --domain (панель будет доступна через SSH-туннель) или позже, когда сертификат на IP снова можно будет получить."
else 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 PANEL_SSL=none
TRUSTED=no TRUSTED=no
fi fi
@@ -777,6 +988,9 @@ kl_sni_menu() {
# --- сертификат для протоколов с TLS --- # --- сертификат для протоколов с TLS ---
setup_tls_cert setup_tls_cert
ok "ядро закреплено, сертификат для протоколов с TLS готов" ok "ядро закреплено, сертификат для протоколов с TLS готов"
say "${D}Ядро Xray ${XRAY_CORE#v} – не самое новое, и это намеренно.${N}"
say "${D}На новых версиях клиенты на sing-box (Karing, Hiddify) перестают подключаться${N}"
say "${D}по REALITY и ещё нескольким протоколам. Перейдём на новое, когда это исправят.${N}"
# --- подключения: все выбранные протоколы, один subId на пользователя --- # --- подключения: все выбранные протоколы, один subId на пользователя ---
step "Подключения" step "Подключения"
@@ -791,9 +1005,45 @@ kl_sni_menu() {
SINGLE=yes SINGLE=yes
fi fi
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 if [[ -n $DOMAIN ]]; then
[[ $SINGLE == yes ]] || die "Свой домен работает только в режиме «всё на 443», а эта установка уже работает с отдельными портами." [[ $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") OPEN+=("80/tcp")
fi fi
# Одна заглушка на сервер: её показывают nginx на 443 и Hysteria2 (masquerade), её же берёт kit net masq. # Одна заглушка на сервер: её показывают nginx на 443 и Hysteria2 (masquerade), её же берёт kit net masq.
@@ -811,12 +1061,24 @@ kl_sni_menu() {
setup_subscription setup_subscription
[[ $SINGLE == yes ]] && setup_nginx [[ $SINGLE == yes ]] && setup_nginx
ok "${#CREATED[@]} подключений, пользователь $NAME" 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 "Защита и обновления" step "Защита и обновления"
install_kit_cli install_kit_cli
if [[ $TRUSTED == yes ]]; then if [[ $TRUSTED == yes ]]; then
/usr/local/bin/kit __subextra >/dev/null 2>&1 || later "Адреса Clash и JSON на странице подписки не подключились: kit update --force" /usr/local/bin/kit __subextra >/dev/null 2>&1 || later "Адреса Clash и JSON на странице подписки не подключились: kit update --force"
fi 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 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"; } /usr/local/bin/kit net panel domain >/dev/null 2>&1 || { PANEL_ON=ip; later "Панель и подписку по домену включить не удалось: оставил по IP. Попробуйте позже: kit net panel domain"; }
fi fi
@@ -841,11 +1103,11 @@ kl_sni_menu() {
chk=$(/usr/local/bin/kit check --deep 2>&1) && { bad=no; break; } chk=$(/usr/local/bin/kit check --deep 2>&1) && { bad=no; break; }
done done
if [[ $bad == no ]]; then if [[ $bad == no ]]; then
ok "подключения работают: REALITY, XHTTP и Hysteria2 проверены клиентом с самого сервера" ok "подключения работают (проверено клиентом с самого сервера): ${CREATED[*]:-нет}"
else else
install -m 600 /dev/null /var/log/kit-install-check.log install -m 600 /dev/null /var/log/kit-install-check.log
printf '%s\n' "$chk" >/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 fi
# --- итог --- # --- итог ---
@@ -883,69 +1145,41 @@ kl_sni_menu() {
[[ $TRUSTED == yes ]] && { echo "Подписка ($NAME) – все протоколы одной ссылкой:"; echo "$SUB_URL"; echo; } [[ $TRUSTED == yes ]] && { echo "Подписка ($NAME) – все протоколы одной ссылкой:"; echo "$SUB_URL"; echo; }
echo "Отдельные подключения ($NAME):" echo "Отдельные подключения ($NAME):"
echo "$links" echo "$links"
if [[ $SINGLE == yes ]] && grep -q '^tg://' <<<"$links"; then
echo
echo "Telegram (tg://): порт 443 – верный, ${INNER[mtproto]} – внутренний порт сервера, снаружи закрыт."
fi
} >"$RESULT" } >"$RESULT"
echo echo
echo "${G}${B}Готово! Сервер работает.${N}"
[[ -n $DOMAIN ]] && echo "Маскировка: свой домен ${B}$DOMAIN${N}, сертификат Let's Encrypt продлевается сам."
[[ $SELF_IP_CERT == yes ]] && later "Сертификат на IP самоподписанный: пользуйтесь ссылками на отдельные подключения, подписка в приложениях может не открыться."
echo echo
echo "${B}ПАНЕЛЬ${N}" echo "${G}${B}Установка завершена.${N} Доступы к панели, подписка и QR-код — в самом конце вывода."
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}"
if ((${#WARNINGS[@]})); then if ((${#WARNINGS[@]})); then
echo local -a wn=("${WARNINGS[@]:0:3}")
echo "${B}ВНИМАНИЕ${N}" ((${#WARNINGS[@]} > 3)) && wn+=("И ещё $((${#WARNINGS[@]} - 3)): kit → Проверка сервера → Быстрая проверка.")
local w n=0 tips "На что обратить внимание" "${wn[@]}"
for w in "${WARNINGS[@]}"; do
n=$((n + 1)); ((n > 3)) && break
echo " ${Y}!${N} $w"
done
((${#WARNINGS[@]} > 3)) && echo " … и ещё $((${#WARNINGS[@]} - 3)): kit check"
fi 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. # Официальный установщик 3X-UI закреплённой версии, сверенный по SHA256.
install_xui() { # порт путь логин пароль install_xui() { # порт путь логин пароль
local tmp local tmp
[[ ${PANEL_SSL:-} == ip ]] && ufw_open_80
tmp=$(mktemp) tmp=$(mktemp)
say "Ставлю 3X-UI $XUI_VERSION официальным установщиком (пара минут)" say "Ставлю 3X-UI $XUI_VERSION официальным установщиком (пара минут)"
curl -fsSL --retry 3 -o "$tmp" "https://raw.githubusercontent.com/$XUI_REPO/$XUI_VERSION/install.sh" curl -fsSL --retry 3 -o "$tmp" "https://raw.githubusercontent.com/$XUI_REPO/$XUI_VERSION/install.sh"
[[ $(sha256sum "$tmp" | awk '{print $1}') == "$XUI_INSTALL_SHA256" ]] \ [[ $(sha256sum "$tmp" | awk '{print $1}') == "$XUI_INSTALL_SHA256" ]] \
|| die "Установщик 3X-UI $XUI_VERSION не совпал с проверенным (SHA256) – не запускаю. Сообщите нам: github.com/itsnotkubrick/3X-UI_KIT/issues" || 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" \ 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_PANEL_PORT="$1" XUI_WEB_BASE_PATH="$2" \
XUI_USERNAME="$3" XUI_PASSWORD="$4" \ XUI_USERNAME="$3" XUI_PASSWORD="$4" \
bash "$tmp" "$XUI_VERSION" </dev/null >/var/log/3x-ui-install.log 2>&1; then 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 tail -20 /var/log/3x-ui-install.log >&2
die "Установщик 3X-UI завершился с ошибкой. Полный лог: /var/log/3x-ui-install.log" die "Установщик 3X-UI завершился с ошибкой. Полный лог: /var/log/3x-ui-install.log"
fi fi
spin_stop
rm -f "$tmp" rm -f "$tmp"
} }
@@ -960,7 +1194,7 @@ connect_panel() {
for i in $(seq 1 60); do for i in $(seq 1 60); do
for scheme in https http; do for scheme in https http; do
API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" 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 done
sleep 2 sleep 2
done done
@@ -1011,12 +1245,14 @@ set_xray_core() {
say "Ставлю ядро Xray $XRAY_CORE (совместимо с Hiddify, Mihomo и другими клиентами)" say "Ставлю ядро Xray $XRAY_CORE (совместимо с Hiddify, Mihomo и другими клиентами)"
# 3X-UI 3.9 бывает отвечает ошибкой «GitHub API error», хотя ядро уже заменено: ответ не считаем # 3X-UI 3.9 бывает отвечает ошибкой «GitHub API error», хотя ядро уже заменено: ответ не считаем
# проверкой, смотрим на версию ядра ниже. # проверкой, смотрим на версию ядра ниже.
spin_start "Ставлю ядро Xray"
(api POST "server/installXray/$XRAY_CORE" '{}' >/dev/null) 2>/dev/null || warn "Панель не подтвердила смену ядра – проверяю версию сам." (api POST "server/installXray/$XRAY_CORE" '{}' >/dev/null) 2>/dev/null || warn "Панель не подтвердила смену ядра – проверяю версию сам."
for _ in $(seq 1 30); do 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=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}')
[[ $cur_core == "$XRAY_CORE" ]] && break [[ $cur_core == "$XRAY_CORE" ]] && break
sleep 2 sleep 2
done done
spin_stop
if [[ $cur_core != "$XRAY_CORE" ]]; then if [[ $cur_core != "$XRAY_CORE" ]]; then
warn "Панель не смогла скачать ядро с GitHub – пробую запасной путь: зеркало с проверкой SHA256." warn "Панель не смогла скачать ядро с GitHub – пробую запасной путь: зеркало с проверкой SHA256."
xray_install_fallback xray_install_fallback
@@ -1035,6 +1271,19 @@ ssh_ports() {
} | grep -E '^[0-9]{1,5}$' | sort -un || true } | 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() { setup_ufw() {
local ssh_port o local ssh_port o
ssh_port=$(ssh_ports) ssh_port=$(ssh_ports)
@@ -1042,6 +1291,7 @@ setup_ufw() {
ok "ufw: открыто ${#OPEN[@]} портов (${OPEN[*]}), панель не торчит наружу" ok "ufw: открыто ${#OPEN[@]} портов (${OPEN[*]}), панель не торчит наружу"
for o in "${OPEN[@]}"; do ufw allow "$o" >/dev/null; done for o in "${OPEN[@]}"; do ufw allow "$o" >/dev/null; done
ufw --force enable >/dev/null || later "ufw не включился (так бывает в контейнерах) – откройте порты у хостера вручную." 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 +1363,11 @@ add_inbound() {
sniffing: "{\"enabled\":true,\"destOverride\":[\"http\",\"tls\",\"quic\"],\"metadataOnly\":false,\"routeOnly\":false}", sniffing: "{\"enabled\":true,\"destOverride\":[\"http\",\"tls\",\"quic\"],\"metadataOnly\":false,\"routeOnly\":false}",
expiryTime: 0, total: 0}') expiryTime: 0, total: 0}')
local out 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 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") 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 fi
[[ $(jq -r '.success' <<<"$out") == true ]] || die "Панель не создала $remark: $(jq -r '.msg // .' <<<"$out" | head -c 300)" [[ $(jq -r '.success' <<<"$out") == true ]] || die "Панель не создала $remark: $(jq -r '.msg // .' <<<"$out" | head -c 300)"
CREATED+=("$remark"); open_port "$port" "$net" CREATED+=("$remark"); open_port "$port" "$net"
@@ -1522,9 +1772,19 @@ stub_site() {
setup_nginx() { setup_nginx() {
say "Настраиваю nginx: всё TCP через порт 443" say "Настраиваю nginx: всё TCP через порт 443"
spin_start "Ставлю nginx"
apt-get install -y -qq nginx libnginx-mod-stream >/dev/null apt-get install -y -qq nginx libnginx-mod-stream >/dev/null
spin_stop
# Порт 80 нужен Let's Encrypt для продления сертификата – сайт nginx по умолчанию убираем. # Порт 80 нужен Let's Encrypt для продления сертификата – сайт nginx по умолчанию убираем.
rm -f /etc/nginx/sites-enabled/default 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. # Панель – только через nginx.
local all local all
@@ -1595,6 +1855,8 @@ setup_nginx() {
{ {
echo "# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске." echo "# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске."
echo "stream {" echo "stream {"
echo " # Длинные имена сайтов (до 253 знаков) не влезают в хеш-таблицу nginx по умолчанию."
echo " map_hash_bucket_size 512;"
echo " map \$ssl_preread_server_name \$kit_upstream {" echo " map \$ssl_preread_server_name \$kit_upstream {"
[[ -n $reality_sni ]] && echo " $reality_sni 127.0.0.1:${INNER[reality]};" [[ -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]};" [[ -n $xhttp_sni && $xhttp_sni != "$reality_sni" ]] && echo " $xhttp_sni 127.0.0.1:${INNER[xhttp]};"
@@ -1615,8 +1877,8 @@ setup_nginx() {
cat >/etc/nginx/conf.d/kit.conf <<NGX cat >/etc/nginx/conf.d/kit.conf <<NGX
# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске. # Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске.
server { server {
listen 127.0.0.1:${INNER[web]} ssl http2 proxy_protocol; listen 127.0.0.1:${INNER[web]} ssl${h2:+ $h2} proxy_protocol;
server_name _; server_name _;${h2on}
ssl_certificate $CERT; ssl_certificate $CERT;
ssl_certificate_key $KEY; ssl_certificate_key $KEY;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
@@ -1654,8 +1916,8 @@ NGX
# Свой домен (self-steal): сюда REALITY отправляет всех, кто не подключается как клиент. # Свой домен (self-steal): сюда REALITY отправляет всех, кто не подключается как клиент.
# Только заглушка, без панели и подписки; порт слушает localhost. # Только заглушка, без панели и подписки; порт слушает localhost.
server { server {
listen 127.0.0.1:${INNER[selfweb]} ssl http2; listen 127.0.0.1:${INNER[selfweb]} ssl${h2:+ $h2};
server_name $steal_domain; server_name $steal_domain;${h2on}
ssl_certificate $DOMAIN_CERT_DIR/fullchain.pem; ssl_certificate $DOMAIN_CERT_DIR/fullchain.pem;
ssl_certificate_key $DOMAIN_CERT_DIR/privkey.pem; ssl_certificate_key $DOMAIN_CERT_DIR/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
@@ -1777,7 +2039,9 @@ install_kit_sub_file() {
install_kit_sub() { install_kit_sub() {
say "Ставлю подписку с учётом приложения (kit-sub)" say "Ставлю подписку с учётом приложения (kit-sub)"
spin_start "Ставлю python3 для подписки"
apt-get install -y -qq python3 python3-yaml >/dev/null apt-get install -y -qq python3 python3-yaml >/dev/null
spin_stop
install_kit_sub_file install_kit_sub_file
if [[ $SINGLE == yes ]]; then if [[ $SINGLE == yes ]]; then
# За nginx: слушаем только localhost, TLS снимает nginx на 443. # За nginx: слушаем только localhost, TLS снимает nginx на 443.
@@ -1828,12 +2092,14 @@ sub_links() {
# Сначала – только печатные ASCII и перевод строки: файл с нулевым байтом GNU grep считает двоичным и молчит # Сначала – только печатные ASCII и перевод строки: файл с нулевым байтом GNU grep считает двоичным и молчит
# (проверка «прошла» бы), а bash при «.» нулевые байты выкидывает и выполняет остальное. Поэтому и grep -a. # (проверка «прошла» бы), а bash при «.» нулевые байты выкидывает и выполняет остальное. Поэтому и grep -a.
safe_env() { # файл имя... safe_env() { # файл имя...
local f=$1 n re; shift local f=$1 n re rc=0; shift
[[ -r $f ]] || die "В копии нет файла ${f##*/}." [[ -f $f && -r $f ]] || die "В копии нет файла ${f##*/}."
[[ $(LC_ALL=C tr -d '\n -~' <"$f" | wc -c) -eq 0 ]] \ [[ $(LC_ALL=C tr -d '\n -~' <"$f" | wc -c) -eq 0 ]] \
|| die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял." || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."
LC_ALL=C grep -aqvE "^([A-Z][A-Z0-9_]*=([A-Za-z0-9._:/@%+,=_-]*|''))?$" "$f" \ # grep: 0 – есть недопустимая строка, 1 – все строки хорошие, 2 – не смог прочитать: и 0, и 2 считаем отказом.
&& die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял." 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 while IFS= read -r n; do
[[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял." [[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял."
case $n in case $n in
@@ -1946,7 +2212,7 @@ restore_self_cert() { # путь-к-fullchain.pem
if [[ $dir == /root/cert/ip ]]; then 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 printf '%s\n' "${R}!${N} Let's Encrypt не выдал сертификат на IP $HOST (лимит: 5 сертификатов в неделю на один IP, закрытый порт 80 или сбой; лог: /var/log/3x-ui-install.log)." >&2
echo " Ставлю самоподписанный: ссылки на подключения работают (отпечаток в них), браузер покажет предупреждение, а подписка в приложениях может не открыться." >&2 echo " Ставлю самоподписанный: ссылки на подключения работают (отпечаток в них), браузер покажет предупреждение, а подписка в приложениях может не открыться." >&2
later "Сертификат на IP самоподписанный (Let's Encrypt не выдал): пользуйтесь ссылками на отдельные подключения (kit user link имя), подписка в приложениях может не открыться." later "Сертификат на IP самоподписанный (Let's Encrypt не выдал): пользуйтесь ссылками на отдельные подключения (kit → Пользователи → Показать ссылки), подписка в приложениях может не открыться."
SELF_IP_CERT=yes SELF_IP_CERT=yes
fi fi
install -d -m 700 "$dir" install -d -m 700 "$dir"
@@ -1979,7 +2245,7 @@ restore_main() { # файл
say "Ставлю пакеты: curl, jq, openssl, qrencode, ufw, python3" say "Ставлю пакеты: curl, jq, openssl, qrencode, ufw, python3"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
wait_apt_idle 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 apt-get install -y -qq curl jq openssl qrencode ca-certificates iproute2 ufw socat cron python3 python3-yaml >/dev/null
# Сначала проверяем архив целиком: только наши пути, только файлы и папки, без ссылок # Сначала проверяем архив целиком: только наши пути, только файлы и папки, без ссылок
@@ -1988,7 +2254,7 @@ restore_main() { # файл
# shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет # shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет
trap "rm -rf -- '$tmp'" EXIT trap "rm -rf -- '$tmp'" EXIT
python3 - "$file" "$tmp" <<'PY' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена. Ничего не менял." 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", 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", "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") "root/cert/custom", "root/3x-ui.txt", "kit-backup.env")
@@ -2016,7 +2282,7 @@ with tarfile.open(sys.argv[1], "r:gz") as t:
sys.exit("нет " + need) sys.exit("нет " + need)
# Файлы настроек потом подключаются от root: в них только печатные ASCII и перевод строки. Нулевой байт, # Файлы настроек потом подключаются от root: в них только печатные ASCII и перевод строки. Нулевой байт,
# \r или не-ASCII ломают построчную проверку (GNU grep считает такой файл двоичным). Данные для входа # \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: for m in ms:
if not m.isfile(): if not m.isfile():
continue continue
@@ -2030,7 +2296,7 @@ with tarfile.open(sys.argv[1], "r:gz") as t:
text = data.decode("utf-8") text = data.decode("utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
sys.exit("байты " + m.name) 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) sys.exit("байты " + m.name)
# Каталог сертификата есть – в нём оба файла, иначе установка споткнётся на полпути. # Каталог сертификата есть – в нём оба файла, иначе установка споткнётся на полпути.
files = {m.name for m in ms if m.isfile() and m.size > 0} files = {m.name for m in ms if m.isfile() and m.size > 0}
@@ -2054,6 +2320,21 @@ PY
fi 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')" \ 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 "База панели в копии повреждена. Ничего не менял." "$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 local BACKUP_HOST BACKUP_SSL BACKUP_DATE BACKUP_KIT_VERSION
BACKUP_HOST=$(env_get "$tmp/kit-backup.env" BACKUP_HOST) BACKUP_HOST=$(env_get "$tmp/kit-backup.env" BACKUP_HOST)
@@ -2118,6 +2399,7 @@ PY
systemctl stop x-ui systemctl stop x-ui
# Служебные файлы WAL от базы, созданной установщиком, к базе из копии не подходят: с ними она «битая». # Служебные файлы WAL от базы, созданной установщиком, к базе из копии не подходят: с ними она «битая».
rm -f /etc/x-ui/x-ui.db-wal /etc/x-ui/x-ui.db-shm 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/x-ui.db" /etc/x-ui/x-ui.db
install -m 600 "$tmp/etc/x-ui/install-result.env" "$XUI_ENV" install -m 600 "$tmp/etc/x-ui/install-result.env" "$XUI_ENV"
local c local c
@@ -2164,7 +2446,7 @@ PY
# позже (после сборки nginx), поэтому на свежем сервере проверка Let's Encrypt доходила до сервера и # позже (после сборки nginx), поэтому на свежем сервере проверка Let's Encrypt доходила до сервера и
# «проглатывалась» — в логе acme это «Timeout during connect (likely firewall problem)». # «проглатывалась» — в логе acme это «Timeout during connect (likely firewall problem)».
command -v ufw >/dev/null 2>&1 && ufw allow 80/tcp >/dev/null 2>&1 || true 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 setup_nginx
fi fi
@@ -2175,6 +2457,8 @@ PY
sed -i 's/^PANEL_ON=.*/PANEL_ON=ip/' /etc/kit/kit.env 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"; } /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 fi
# MTProto: ссылка в панели – на этот сервер и 443 (чужие адреса из копии убираем), секреты – под нынешний сайт.
/usr/local/bin/kit __mtproto restore >/dev/null 2>&1 || later "Ссылку MTProto в панели поправить не удалось: kit fix"
brand_xui_menu brand_xui_menu
/usr/local/bin/kit update --auto >/dev/null 2>&1 || later "Автообновление не включилось – включите позже: kit update --auto" /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" /usr/local/bin/kit __limit-timer on >/dev/null 2>&1 || later "Проверка общего лимита трафика не включилась – включите позже: kit fix"
@@ -2202,25 +2486,21 @@ PY
echo "${G}${B}Готово! Сервер восстановлен из копии: $n_in подключений, $n_cl клиентских записей.${N}" echo "${G}${B}Готово! Сервер восстановлен из копии: $n_in подключений, $n_cl клиентских записей.${N}"
else else
echo "${R}${B}Сервер восстановлен из копии ($n_in подключений, $n_cl клиентских записей), но ядро Xray не запустилось: подключения не работают.${N}" 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 fi
echo "Логин и пароль панели прежние, адрес панели и подписки: ${B}cat $RESULT${N}" echo "Логин и пароль панели прежние. Адрес панели и подписки: файл ${B}$RESULT${N} (виден только root)."
echo local -a tp=()
if [[ -n $old_ip ]]; then if [[ -n $old_ip ]]; then
warn "IP сервера сменился: $old_ip → $HOST." warn "IP сервера сменился: $old_ip → $HOST."
echo "Старые подписки указывают на старый IP, поэтому клиентам нужно один раз добавить" tp+=("Старые подписки указывают на старый IP, поэтому клиентам нужно один раз добавить подписку заново. Новую ссылку покажет kit → Пользователи → Показать ссылки.")
echo "подписку заново: ${B}kit user list${N}, затем ${B}kit user link имя${N}." tp+=("Чтобы в следующий раз переезд прошёл без хлопот для клиентов, ставьте сервер на домен (--cert, --key, --host).")
echo "${D}Чтобы в следующий раз переезд прошёл без хлопот для клиентов, ставьте сервер на домен (--cert, --key, --host).${N}"
else else
echo "Клиентам ничего менять не нужно: ключи, ссылки и подписки те же." tp+=("Клиентам ничего менять не нужно: ключи, ссылки и подписки те же.")
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
fi 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 [[ $core_ok == yes ]] || exit 1
return 0 return 0
} }
@@ -2229,11 +2509,8 @@ usage() {
cat <<EOF cat <<EOF
3X-UI со всеми протоколами одной командой 3X-UI со всеми протоколами одной командой
--protocols all all (по умолчанию – всё, кроме WireGuard), minimal (только REALITY) --protocols all all (по умолчанию – основной набор), minimal (только REALITY)
или список через запятую: reality,reality2,hy2,xhttp,ws,trojan,vmess,ss,tuic,wg,awg,awg3,mtproto \1 --port 443 порт REALITY (TCP) и Hysteria2 (UDP), по умолчанию 443
(обычный WireGuard работает нестабильно – включайте его, только если сервер и
пользователи за границей)
--port 443 порт REALITY (TCP) и Hysteria2 (UDP), по умолчанию 443
--sni сайт чужой сайт для маскировки (по умолчанию подбирается сам) --sni сайт чужой сайт для маскировки (по умолчанию подбирается сам)
--domain домен свой домен для маскировки (надёжнее): его A-запись должна вести на этот --domain домен свой домен для маскировки (надёжнее): его A-запись должна вести на этот
сервер, порт 80 свободен; сертификат Let's Encrypt установщик получит сам. сервер, порт 80 свободен; сертификат Let's Encrypt установщик получит сам.
+1 -1
View File
@@ -8,7 +8,7 @@
# Код выхода 1 при любом запуске (и при `__update_binary`) – старый `hy2 update` на нём # Код выхода 1 при любом запуске (и при `__update_binary`) – старый `hy2 update` на нём
# останавливается и не заменяет команду hy2. # останавливается и не заменяет команду hy2.
KIT_VERSION="1.2.22" KIT_VERSION="1.2.1.3"
cat >&2 <<'MSG' cat >&2 <<'MSG'
Отдельный установщик Hysteria2 больше не поддерживается. Отдельный установщик Hysteria2 больше не поддерживается.
+37 -166
View File
@@ -73,7 +73,8 @@ kit_local_help() {
kit local seams какие места форк правит в файлах автора kit local seams какие места форк правит в файлах автора
kit local masking состояние маскировки (у REALITY должен быть свой домен) kit local masking состояние маскировки (у REALITY должен быть свой домен)
kit local hardening сводка по защите сервера и что ещё можно включить kit local hardening сводка по защите сервера и что ещё можно включить
kit local uninstall снести всё, что поставила установка (SSH не трогает) kit local links подписка, QR-код и ссылки по протоколам (как в меню kit)
kit local uninstall то же, что kit uninstall: снести всё, включая сам kit (SSH не трогает)
Заглушка (сайт на 443 и ответ Hysteria2 — одна и та же страница) Заглушка (сайт на 443 и ответ Hysteria2 — одна и та же страница)
kit local site поставить случайный сайт из нашего набора (10 штук) kit local site поставить случайный сайт из нашего набора (10 штук)
@@ -443,12 +444,14 @@ kit_local_hardening() {
cat <<'EOF' cat <<'EOF'
Чего здесь НЕТ и почему (в отличие от vps-setup): Что сделано намеренно МЯГЧЕ, чем в популярных скриптах первичной настройки:
* apt full-upgrade — тянет систему вперёд и может сломать закреплённые панель и ядро; * apt full-upgrade — тянет систему вперёд и может сломать закреплённые панель и ядро;
вместо него включены обновления безопасности; вместо него включены обновления безопасности;
* автоматическая перезагрузка после правок — сервер не должен вставать без вашего решения; * автоматическая перезагрузка после правок — сервер не должен вставать без вашего решения;
* общесистемный icmp_echo_ignore_all — правило ufw видно в `ufw status` и отменяется одной * общесистемный запрет ICMP для всех интерфейсов — здесь он выключен на уровне ufw: правило видно
командой, не глушит диагностику внутренних интерфейсов. в `ufw status` и отменяется одной командой, а внутренняя диагностика не глушится.
Причина одна: перечисленное может увести систему вперёд или выключить сервер, а панель и ядро у нас
закреплённых версий.
Что включить при желании: Что включить при желании:
kit local ssh 2222 добавить порт SSH kit local ssh 2222 добавить порт SSH
@@ -898,6 +901,7 @@ kit_local_cmd() {
upgrades | auto) shift; kit_local_upgrades "$@" ;; upgrades | auto) shift; kit_local_upgrades "$@" ;;
hardening | hard) shift; kit_local_hardening "$@" ;; hardening | hard) shift; kit_local_hardening "$@" ;;
uninstall | remove) shift; kit_local_uninstall "$@" ;; uninstall | remove) shift; kit_local_uninstall "$@" ;;
links | link) shift; kit_local_links "$@" ;;
site | stub | stub-site) shift; kit_local_site "$@" ;; site | stub | stub-site) shift; kit_local_site "$@" ;;
*) *)
printf 'Неизвестная команда форка: %s\n' "$1" >&2 printf 'Неизвестная команда форка: %s\n' "$1" >&2
@@ -920,169 +924,35 @@ kit_local_cmd() {
# #
# kit local uninstall спросит подтверждение словом «удалить» # kit local uninstall спросит подтверждение словом «удалить»
# kit local uninstall --dry-run только покажет, что будет удалено # kit local uninstall --dry-run только покажет, что будет удалено
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_local_uninstall() {
kl_args "$@" # Наш шов: логику удаления отдаём авторской команде «kit uninstall». Она снимает и сам kit, чтобы на
local arg=${KL_REST[0]:-} ans found # сервере не осталось следов, поэтому своя реализация здесь больше не нужна — команда оставлена
local reset_ssh=no i=0 # синонимом, чтобы привычное «kit local uninstall» продолжало работать.
case $arg in local a=() x
"") ;; for x in "$@"; do
--dry-run) KL_DRY=yes ;; case $x in
--yes) KIT_LOCAL_UNINSTALL_CONFIRMED=yes ;; --dry-run | --yes | --no-backup) a+=("$x") ;;
--reset-ssh) reset_ssh=yes ;; --help | -h)
*) kl_die "kit local uninstall [--dry-run] [--yes] [--reset-ssh]"; return 1 ;; printf 'kit local uninstall [--dry-run] [--yes] [--no-backup] — то же, что kit uninstall\n'
esac return 0 ;;
*) kl_die "kit local uninstall [--dry-run] [--yes] [--no-backup]"; return 1 ;;
# Службы: и явный список, и всё, что найдётся по нашим шаблонам (чтобы новые юниты kit не оставались). esac
# 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")
done done
# Список собран по коду установщика (scripts/3x-ui.sh) и нашего слоя: убираем всё, что они создают. [[ -x /usr/local/bin/kit ]] || { kl_die "не нашёл /usr/local/bin/kit — удалить вручную: kit uninstall"; return 1; }
local -a paths=( printf ' удаляю по авторской логике: /usr/local/bin/kit uninstall %s\n' "${a[*]}"
/usr/local/x-ui /etc/x-ui /usr/bin/x-ui /usr/bin/x-ui.new /etc/systemd/system/x-ui.service exec /usr/local/bin/kit uninstall "${a[@]}"
/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"
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 "проверка установщика пройдена: он снова поставит сервер с нуля"
fi
kl_ok "SSH не тронут (если не указан --reset-ssh)"
kl_step "Готово: сервер в состоянии до установки, можно ставить заново"
} }
# ---------------------------------------------------------------- полная справка `kit help` # ---------------------------------------------------------------- полная справка `kit help`
@@ -1131,7 +1001,8 @@ ${B:-}3X-UI_autoVPS ${KIT_VERSION:-?} — все команды${N:-} (то ж
kit local site [--list | N | --show] сайт-заглушка: случайный ИТ-сайт, список, конкретный, какой стоит kit local site [--list | N | --show] сайт-заглушка: случайный ИТ-сайт, список, конкретный, какой стоит
Проверка и починка Проверка и починка
kit local uninstall снести установку целиком (панель, туннель, службы kit, сайт, сертификаты); SSH не трогает kit local links [имя] подписка, QR-код и ссылки по протоколам (по умолчанию — первый пользователь)
kit local uninstall то же, что kit uninstall: снести установку целиком и сам kit; SSH не трогает
kit check [--deep | --fix] проверка сервера (--deep — ходит наружу как клиент) kit check [--deep | --fix] проверка сервера (--deep — ходит наружу как клиент)
kit fix [--dry-run] безопасные починки: время, службы, конфиги kit fix [--dry-run] безопасные починки: время, службы, конфиги
kit local hardening сводка по защите и что ещё можно включить 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 # AmneziaWG добавляем только приложениям на ядре Mihomo. Karing, Hiddify и другие на sing-box
# тоже могут просить формат Clash (Karing так и делает), но AmneziaWG не умеют. # тоже могут просить формат Clash (Karing так и делает), но AmneziaWG не умеют.
NO_AWG_UA = re.compile(r"karing|hiddify|nekobox|sing-?box|husi|stash|shadowrocket|v2box|streisand|happ|loon|surge|quantumult", re.I) NO_AWG_UA = re.compile(r"karing|hiddify|nekobox|sing-?box|husi|stash|shadowrocket|v2box|streisand|happ|loon|surge|quantumult", re.I)
# Hiddify 4.x в строке приложения называет себя и Clash, поэтому панель отдаёт ему Clash-YAML, а он обрезает в нём пробелы и
# выдаёт «Unable to determine config format». Панели такой клиент представляем без слов про Clash: она отдаст обычный
# список ссылок (base64), который Hiddify читает без проблем.
HIDDIFY_UA = re.compile(r"hiddify", re.I)
def upstream_ua(ua):
return CLASH_UA.sub("", ua).strip() if HIDDIFY_UA.search(ua) else ua
SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$") SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$")
# Страница подписки для браузера (React от 3X-UI) подгружает скрипты и стили из «<путь>/assets/…»: пропускаем только такие файлы. # Страница подписки для браузера (React от 3X-UI) подгружает скрипты и стили из «<путь>/assets/…»: пропускаем только такие файлы.
ASSET = re.compile(r"^assets/[A-Za-z0-9_.-]{1,128}\.(js|css|woff2?|svg|png|ico|map)$") 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"] 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): обрабатываются так же, как основной. # Дополнительные пути, которые панель показывает на странице подписки (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)] EXTRA = ["/" + x.strip("/") + "/" for x in CONF.get("extra_paths", []) if isinstance(x, str) and re.fullmatch(r"/?[A-Za-z0-9_-]+/?", x)]
# После kit net site прежнее имя сайта REALITY ещё неделю принимается сервером (льготный срок), а панель берёт имя для ссылки
# случайно из списка. Карта «прежнее имя → нынешнее» от kit: в подписку уходит только нынешнее. Нет файла – ничего не меняем.
GRACE_FILE = os.environ.get("KIT_SUB_GRACE") or "/etc/kit-sub/sni-grace.json"
SITE_NAME = re.compile(r"[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+")
LINK_SNI = re.compile(r"([?&]sni=)([^&#\s]*)")
def log(msg): def log(msg):
@@ -94,10 +109,32 @@ def fix_userinfo(value):
JSON_FIX_MAX = 2 * 1024 * 1024 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 иначе """JSON-подписка панели: у vless-пользователя без encryption (нет поля или пусто) ставим "none" – Xray иначе
не принимает конфиг. Две формы: vnext[].users[] и плоская (settings.id/encryption). Непустое значение не принимает конфиг. Две формы: vnext[].users[] и плоская (settings.id/encryption). Непустое значение
(например, mlkem) не трогаем. Не разобралось, слишком большое или менять нечего – тело как пришло.""" (например, mlkem) не трогаем. Прежнее (льготное) имя сайта REALITY – на нынешнее (grace).
Не разобралось, слишком большое или менять нечего – тело как пришло."""
if len(body) > JSON_FIX_MAX: if len(body) > JSON_FIX_MAX:
return body return body
try: try:
@@ -116,6 +153,11 @@ def fix_vless_encryption(body):
for cfg in (data if isinstance(data, list) else [data]): for cfg in (data if isinstance(data, list) else [data]):
outs = cfg.get("outbounds") if isinstance(cfg, dict) else None outs = cfg.get("outbounds") if isinstance(cfg, dict) else None
for o in outs if isinstance(outs, list) else []: 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 st = o.get("settings") if isinstance(o, dict) and o.get("protocol") == "vless" else None
if not isinstance(st, dict): if not isinstance(st, dict):
continue continue
@@ -127,9 +169,9 @@ def fix_vless_encryption(body):
return json.dumps(data, ensure_ascii=False).encode() if changed else 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-приложение """Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram.""" со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram. Прежнее имя сайта REALITY – на нынешнее."""
text = body.decode("utf-8", "replace").strip() text = body.decode("utf-8", "replace").strip()
encoded = "://" not in text encoded = "://" not in text
if encoded: if encoded:
@@ -137,7 +179,7 @@ def strip_links(body):
text = base64.b64decode(text + "=" * (-len(text) % 4)).decode("utf-8", "replace") text = base64.b64decode(text + "=" * (-len(text) % 4)).decode("utf-8", "replace")
except ValueError: except ValueError:
return body return body
lines = [l for l in text.splitlines() if l.strip() and not l.startswith(("vpn://", "tg://"))] lines = [grace_link(l, grace) for l in text.splitlines() if l.strip() and not l.startswith(("vpn://", "tg://"))]
out = "\n".join(lines) out = "\n".join(lines)
return base64.b64encode(out.encode()).decode().encode() if encoded else out.encode() return base64.b64encode(out.encode()).decode().encode() if encoded else out.encode()
@@ -416,7 +458,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
ua = self.headers.get("User-Agent", "") ua = self.headers.get("User-Agent", "")
host = CONF.get("link_host") or self.headers.get("Host", CONF.get("host", "")) host = CONF.get("link_host") or self.headers.get("Host", CONF.get("host", ""))
accept = self.headers.get("Accept", "") accept = self.headers.get("Accept", "")
code, headers, body = upstream(sub_id, ua, host, accept, prefix) code, headers, body = upstream(sub_id, upstream_ua(ua), host, accept, prefix)
if code is None: if code is None:
return self.send_html_error(502) 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 ( 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"{"))): "text/plain" in headers.get("content-type", "") and body.lstrip()[:1] in (b"[", b"{"))):
# Панель 3.9 отдаёт JSON-подписку с Content-Type text/plain – определяем по телу. # Панель 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", ""): 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): if code == 200 and clash and CONF.get("auto", True):
body = add_auto(body) body = add_auto(body)
if code == 200 and clash and CONF.get("dns", True): 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") cert, key = os.path.join(CREDS, "cert.pem"), os.path.join(CREDS, "key.pem")
if not cert: if not cert:
srv = Server((CONF.get("listen", "127.0.0.1"), int(CONF["port"])), Handler) srv = Server((CONF.get("listen", "127.0.0.1"), int(CONF["port"])), Handler)
log(f"kit-sub слушает http://{CONF.get('listen', '127.0.0.1')}:{CONF['port']}{PATH} (TLS снимает nginx)") log(f"kit-sub слушает {CONF.get('listen', '127.0.0.1')}:{CONF['port']} (TLS снимает nginx)") # путь подписки в журнал не пишем: он секретный
srv.serve_forever() srv.serve_forever()
return return
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -571,7 +613,7 @@ def main():
threading.Thread(target=reload_cert, daemon=True).start() threading.Thread(target=reload_cert, daemon=True).start()
srv = Server((CONF.get("listen", "0.0.0.0"), int(CONF["port"])), Handler) srv = Server((CONF.get("listen", "0.0.0.0"), int(CONF["port"])), Handler)
srv.ssl_ctx = ctx srv.ssl_ctx = ctx
log(f"kit-sub слушает {CONF.get('listen', '0.0.0.0')}:{CONF['port']}{PATH}") log(f"kit-sub слушает {CONF.get('listen', '0.0.0.0')}:{CONF['port']}")
srv.serve_forever() srv.serve_forever()
+893 -115
View File
File diff suppressed because it is too large Load Diff
+70 -28
View File
@@ -116,9 +116,9 @@ def main():
), ),
( (
'scripts/kit.sh', '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, 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', 'авто-шов: replace, строк 2',
), ),
( (
@@ -214,25 +214,11 @@ def main():
), ),
( (
'scripts/kit.sh', 'scripts/kit.sh',
' "7. Резервная копия" "8. Версии" "" "9. Панель 3X-UI (меню x-ui)" "0. Выход"\n', ' echo "${B}Полное удаление 3X-UI KIT${N}"\n',
False, False,
' "7. Резервная копия" "8. Версии" "" "9. Удалить всё установленное" "10. Выход" \\\n "11. Панель 3X-UI (меню x-ui)"\n', ' echo "${B}Полное удаление 3X-UI_autoVPS${N}"\n',
'авто-шов: replace, строк 1', 'авто-шов: replace, строк 1',
), ),
(
'scripts/kit.sh',
' ask_num "Выбор [0-9]: "; c=$REPLY\n',
False,
' ask_num "Выбор [1-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',
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',
'авто-шов: replace, строк 2',
),
( (
'scripts/kit.sh', 'scripts/kit.sh',
'${B}3X-UI KIT $KIT_VERSION${N} – команды (то же самое есть в меню: просто ${B}kit${N})\n', '${B}3X-UI KIT $KIT_VERSION${N} – команды (то же самое есть в меню: просто ${B}kit${N})\n',
@@ -289,6 +275,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', ' 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', 'авто-шов: 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', 'scripts/3x-ui.sh',
" _ _ _ _ _ _ _\n (_) |_ ___ _ __ ___ | |_| | ___ _| |__ _ __(_) ___| | __\n | | __/ __| '_ \\ / _ \\| __| |/ / | | | '_ \\| '__| |/ __| |/ /\n | | |_\\__ \\ | | | (_) | |_| <| |_| | |_) | | | | (__| <\n |_|\\__|___/_| |_|\\___/ \\__|_|\\_\\\\__,_|_.__/|_| |_|\\___|_|\\_\\\n", " _ _ _ _ _ _ _\n (_) |_ ___ _ __ ___ | |_| | ___ _| |__ _ __(_) ___| | __\n | | __/ __| '_ \\ / _ \\| __| |/ / | | | '_ \\| '__| |/ __| |/ /\n | | |_\\__ \\ | | | (_) | |_| <| |_| | |_) | | | | (__| <\n |_|\\__|___/_| |_|\\___/ \\__|_|\\_\\\\__,_|_.__/|_| |_|\\___|_|\\_\\\n",
@@ -312,9 +326,9 @@ def main():
), ),
( (
'scripts/3x-ui.sh', '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, 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', 'авто-шов: replace, строк 1',
), ),
( (
@@ -328,7 +342,7 @@ def main():
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
' if ((${#nb[@]})); then\n SNI=${nb[0]}; SNI2=${nb[1]:-}; SNI3=${nb[2]:-}\n', ' if ((${#nb[@]})); then\n SNI=${nb[0]}; SNI2=${nb[1]:-}; SNI3=${nb[2]:-}\n',
False, 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', 'авто-шов: replace, строк 2',
), ),
( (
@@ -352,6 +366,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', ' # Наш шов: если 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', 'авто-шов: 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', 'scripts/3x-ui.sh',
' local extra\n extra=$(sub_links "$SUBID-awg" 1; sub_links "$SUBID-tg" 1)\n', ' local extra\n extra=$(sub_links "$SUBID-awg" 1; sub_links "$SUBID-tg" 1)\n',
@@ -366,6 +394,20 @@ def main():
' echo "3X-UI_autoVPS (3X-UI $XUI_VERSION) – данные для входа (файл виден только root)"\n', ' echo "3X-UI_autoVPS (3X-UI $XUI_VERSION) – данные для входа (файл виден только root)"\n',
'авто-шов: replace, строк 1', 'авто-шов: 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', 'scripts/3x-ui.sh',
'KIT_CLI_URL="$KIT_RAW/scripts/kit.sh"\n', 'KIT_CLI_URL="$KIT_RAW/scripts/kit.sh"\n',
@@ -424,17 +466,17 @@ def main():
), ),
( (
'scripts/3x-ui.sh', '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, False,
' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n', ' [[ $(LC_ALL=C tr -d \'\\n -~\' <"$f" | wc -c) -eq 0 ]] \\\n || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 1', 'авто-шов: replace, строк 2',
), ),
( (
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
' && die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n', ' [[ $rc -eq 1 ]] \\\n || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
False, False,
' && die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n', ' [[ $rc -eq 1 ]] \\\n || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
'авто-шов: replace, строк 1', 'авто-шов: replace, строк 2',
), ),
( (
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
@@ -489,7 +531,7 @@ def main():
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
' [[ -n $DOMAIN ]] && { issue_domain_cert || die "Не удалось получить сертификат для $DOMAIN: порт 80 должен быть свободен, а A-запись домена указывать на этот сервер. Лог: /var/log/kit-domain-cert.log"; }\n', ' [[ -n $DOMAIN ]] && { issue_domain_cert || die "Не удалось получить сертификат для $DOMAIN: порт 80 должен быть свободен, а A-запись домена указывать на этот сервер. Лог: /var/log/kit-domain-cert.log"; }\n',
False, 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', 'авто-шов: replace, строк 1',
), ),
( (
+20 -5
View File
@@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
die() { printf '✗ %s\n' "$*" >&2; exit 1; } die() { printf '✗ %s\n' "$*" >&2; exit 1; }
ok() { printf '✓ %s\n' "$*"; } ok() { printf '✓ %s\n' "$*"; }
warn() { printf '! %s\n' "$*" >&2; } # сеть до GitHub отвалилась: проверку пропускаем, релиз не блокируем
sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; }
key=${1:-$HOME/.ssh/3xui_kit_release} key=${1:-$HOME/.ssh/3xui_kit_release}
@@ -61,10 +62,18 @@ ok "Все наши функции определены раньше своих
[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md FORK-CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит." [[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md FORK-CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
# Часть проверок сверяется с GitHub, а он из домашней сети отвечает через раз. Сначала короткая проба:
# нет связи — сразу предупреждаем и пропускаем внешние проверки (подписи это не мешает), а не висим минутами.
gh_ok=yes
curl -fsI --connect-timeout 8 --max-time 12 https://github.com >/dev/null 2>&1 || gh_ok=no
[[ $gh_ok == yes ]] || warn "GitHub недоступен из вашей сети — установщик, суммы ядра и панели не сверяю. Подписи это не мешает."
gh_curl() { [[ ${gh_ok:-no} == yes ]] || return 1; curl -fsSL --retry 2 --retry-delay 2 --connect-timeout 15 --max-time 90 "$@"; }
# Установщик 3X-UI: хеш в скрипте должен совпадать с тем, что сейчас лежит на GitHub. # Установщик 3X-UI: хеш в скрипте должен совпадать с тем, что сейчас лежит на GitHub.
xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh) xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh)
xs=$(sed -n 's/^XUI_INSTALL_SHA256="\(.*\)"/\1/p' scripts/3x-ui.sh) xs=$(sed -n 's/^XUI_INSTALL_SHA256="\(.*\)"/\1/p' scripts/3x-ui.sh)
got=$(curl -fsSL "https://raw.githubusercontent.com/MHSanaei/3x-ui/$xv/install.sh" | sha | awk '{print $1}') got=$(gh_curl "https://raw.githubusercontent.com/MHSanaei/3x-ui/$xv/install.sh" | sha | awk '{print $1}') \
|| { warn "GitHub недоступен — проверку установщика 3X-UI пропускаю (релиз не блокирую)"; got=$xs; }
[[ $got == "$xs" ]] || die "Установщик 3X-UI $xv изменился: $got вместо $xs. Проверьте, что поменялось, прежде чем выпускать." [[ $got == "$xs" ]] || die "Установщик 3X-UI $xv изменился: $got вместо $xs. Проверьте, что поменялось, прежде чем выпускать."
ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256" ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256"
@@ -72,7 +81,8 @@ ok "Установщик 3X-UI $xv совпадает с закреплённы
xc=$(sed -n 's/^XRAY_CORE="\(.*\)"/\1/p' scripts/3x-ui.sh) xc=$(sed -n 's/^XRAY_CORE="\(.*\)"/\1/p' scripts/3x-ui.sh)
for a in 64 arm64-v8a; do for a in 64 arm64-v8a; do
pin=$(sed -n "s/^ \[$a\]=\([0-9a-f]\{64\}\)\$/\1/p" scripts/3x-ui.sh) 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»." [[ -n $pin && $pin == "$off" ]] || die "Сумма ядра Xray $xc ($a) в 3x-ui.sh не совпадает с официальной: «$pin» вместо «$off»."
done done
ok "Суммы архивов ядра Xray $xc совпадают с официальными" ok "Суммы архивов ядра Xray $xc совпадают с официальными"
@@ -85,11 +95,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)." [[ $kx == "$xc" ]] || die "XRAY_PIN в kit.sh ($kx) не совпадает с XRAY_CORE в 3x-ui.sh ($xc)."
for a in amd64 arm64; do for a in amd64 arm64; do
pin=$(sed -n "s/^ \[$a\]=\([0-9a-f]\{64\}\)\$/\1/p" scripts/kit.sh) 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»." [[ -n $pin && $pin == "$off" ]] || die "Сумма архива панели $kp ($a) в kit.sh не совпадает с официальной: «$pin» вместо «$off»."
done done
shs=$(sed -n 's/^XUI_SH_SHA256=\(.*\)/\1/p' scripts/kit.sh) 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»." [[ $shs == "$got" ]] || die "Сумма x-ui.sh $kp в kit.sh не совпадает с официальной: «$shs» вместо «$got»."
ok "Панель $kp и ядро $kx в kit.sh совпадают с установщиком и официальными суммами" ok "Панель $kp и ядро $kx в kit.sh совпадают с установщиком и официальными суммами"
@@ -102,7 +114,10 @@ ok "Ключ подписи записан в kit"
{ echo "# 3X-UI KIT $v"; echo "# 3X-UI_autoVPS $v"; sha "${FILES[@]}" | awk '{ sub(/^\*/, "", $2); print $1" "$2 }'; } >SHA256SUMS { echo "# 3X-UI KIT $v"; echo "# 3X-UI_autoVPS $v"; sha "${FILES[@]}" | awk '{ sub(/^\*/, "", $2); print $1" "$2 }'; } >SHA256SUMS
rm -f SHA256SUMS.sig rm -f SHA256SUMS.sig
ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null # Ключ защищён парольной фразой: её вводит человек. Понятная подсказка на случай опечатки.
echo "Подписываю ключом $key — если он защищён парольной фразой, введите её сейчас."
ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null \
|| die "Ключ не подписал. Чаще всего это неверная парольная фраза («incorrect passphrase») — запустите команду ещё раз и введите её внимательно. Файл SHA256SUMS при этом не портится."
tmp=$(mktemp) tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT trap 'rm -f "$tmp"' EXIT
signers scripts/kit.sh | while read -r k; do printf '%s namespaces="%s" %s\n' "$ID" "$NS" "$k"; done >"$tmp" signers scripts/kit.sh | while read -r k; do printf '%s namespaces="%s" %s\n' "$ID" "$NS" "$k"; done >"$tmp"