Compare commits

...
25 Commits
Author SHA1 Message Date
hacker07 31dd4d575b подпись релиза 1.2.1.9
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 18:59:16 +03:00
hacker07 c4f7c4f710 v1.2.1.9: kit fix снова выполняется при установке; убрано только описание команды 2026-10-14 18:57:00 +03:00
hacker07 19eb77be68 подпись релиза 1.2.1.8
Синхронизация с автором / sync (push) Failing after 2s
2026-10-14 18:30:20 +03:00
hacker07 333b8edc9a release.sh: проверка меню через python из PATH (python3 есть не везде) 2026-10-14 18:27:05 +03:00
hacker07 13dfb44f3f v1.2.1.8: подсказка меню 0-11, тест меню в гейте, без kit fix и лишних строк в установке 2026-10-14 18:25:33 +03:00
hacker07 3a8518651c подпись релиза 1.2.1.7
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 18:05:59 +03:00
hacker07 915dd35f42 v1.2.1.7: переставлен порядок — сначала качаем все файлы релиза, потом проверяем установщик 2026-10-14 18:03:57 +03:00
hacker07 edabfd9a83 подпись релиза 1.2.1.6
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 17:55:19 +03:00
hacker07 741a70c9bf v1.2.1.6: доступы к панели в конце установки; все файлы релиза скачиваются и сверяются (рабочая заглушка) 2026-10-14 17:53:23 +03:00
hacker07 0d2af45bdb подпись релиза 1.2.1.5
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 17:34:04 +03:00
hacker07 a02650311e v1.2.1.5: пункт меню «Доступ к панели, ссылки и заглушка» + kit local access 2026-10-14 17:31:58 +03:00
hacker07 2b57e6f6d7 подпись релиза 1.2.1.4
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 17:16:10 +03:00
hacker07 df8a99bb78 v1.2.1.4: короткий итог установки, автовыбор в общедоступных сайтах, kit local ports, без дампа ссылок 2026-10-14 17:06:23 +03:00
hacker07 47328b33b3 подпись релиза 1.2.1.3
Синхронизация с автором / sync (push) Failing after 2s
2026-10-14 16:51:06 +03:00
hacker07 776b7a8c5e v1.2.1.3: ссылки и QR — в меню kit (и kit local links), в итоге видно сертификат панели 2026-10-14 16:49:45 +03:00
hacker07 2d63136853 подпись релиза 1.2.1.2
Синхронизация с автором / sync (push) Failing after 2s
2026-10-14 16:32:58 +03:00
hacker07 1b85a4bb23 v1.2.1.2: отказ сертификата на IP — с датой повтора; «без домена» продолжает установку вместо остановки 2026-10-14 16:31:54 +03:00
hacker07 b80c44077c подпись релиза 1.2.1.1 (база автора 3X-UI KIT 1.2.1)
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 16:19:59 +03:00
hacker07 f894b48cd2 сборка на базе автора 1.2.1: версия 1.2.1.1, швы пересобраны, удаление через kit uninstall, номера протоколов через пробел 2026-10-14 16:08:14 +03:00
hacker07 bddef5b8d3 v1.2.27: свой домен вводится заново при промахе; провал сертификата не обрывает установку 2026-10-14 15:51:03 +03:00
hacker07 53cb694206 подпись релиза 1.2.26
Синхронизация с автором / sync (push) Failing after 1s
2026-10-14 14:51:24 +03:00
hacker07 5c22cbf4fe v1.2.26: лимит Let-Encrypt называется прямо; готовый сертификат переиспользуется вместо нового заказа 2026-10-14 14:44:47 +03:00
hacker07 a7b4586bc3 подпись релиза 1.2.25
Синхронизация с автором / sync (push) Failing after 3s
2026-10-14 14:38:07 +03:00
hacker07 cec5246e47 release.sh: понятная подсказка при неверной парольной фразе ключа 2026-10-14 14:36:44 +03:00
hacker07 151d510495 v1.2.25: вопрос про панель (IP/домен) в интерактивном выборе домена; понятнее блок про мягкую защиту 2026-10-14 14:34:27 +03:00
14 changed files with 1827 additions and 500 deletions
+160
View File
@@ -1,3 +1,163 @@
## v1.2.1.9: kit fix снова выполняется при установке
* В 1.2.1.8 я понял просьбу слишком широко и **выключил сам `kit fix`** — вместе с ним пропала и
синхронизация времени. Возвращено: `kit fix` при установке выполняется как раньше (время, службы, права),
ключ `--no-time` его пропускает.
* Убрана только та строка, о которой шла речь: описание команды «kit fix – безопасные исправления
(службы, права, автообновление, сертификат, время)» больше не печатается — результат («Чинить нечего»
или список найденного) остаётся.
* Наша строка шага сокращена: «kit fix: безопасные починки (время, службы, права)».
## v1.2.1.8: подсказка меню исправлена, установка стала тише
* **«Выбор [0-10]» при 11 пунктах** — подсказка главного меню отстала, когда мы добавили пункт «Доступ к
панели, ссылки и заглушка». Теперь «Выбор [0-11]». Чтобы это не повторилось, добавлен тест
`tools/check-menus.py`: он сверяет подсказку каждого меню с числом пунктов и запускается в релизном
гейте — если мы снова добавим пункт и забудем подсказку, выпуск остановится.
* **`kit fix` больше не выполняется при установке** (в выводе это выглядело как «Чинить нечего: всё в
порядке»). Нужен — запускается из меню или ключом `--time`.
* **Вывод про автообновления безопасности сокращён до одной строки** («автообновления безопасности
включены…»): три строки про ядро и перезагрузку убраны из установки.
## v1.2.1.7: исправлена установка (моя регрессия в 1.2.1.6)
* В 1.2.1.6 я переписал загрузку файлов релиза и оставил проверку SHA256 установщика **раньше**
скачивания — установка обрывалась на первой же строке: `sha256sum: .../scripts/3x-ui.sh: No such file
or directory`. Теперь сначала скачиваются все файлы релиза (каждый сверяется с подписанным списком),
и только потом проверяется установщик.
* Проверено не на глаз: воспроизвёл ошибку у себя тем же путём (`--verify-only`) — красное, затем после
правки зелёное. Этот прогон добавлен в процедуру выпуска: перед подписью я запускаю установщик в режиме
`--verify-only` и убеждаюсь, что путь «скачать и проверить релиз» проходит целиком.
## v1.2.1.6: доступы к панели в конце установки и рабочая заглушка
* **Исправлено: доступы к панели не выводились.** Автор пишет строки в `/root/3x-ui.txt` без ведущих
пробелов, а наш фильтр требовал два пробела — отсюда «данных панели нет». Теперь адрес панели, логин и
пароль печатаются в конце установки (и в пункте меню «9. Доступ к панели, ссылки и заглушка»).
Рядом указывается, что подписка и ссылки по протоколам лежат в том же файле.
* **Исправлено: наша заглушка не готовилась до установщика.** Обёртка скачивала из релиза только три файла,
а `scripts/kit-local.sh` (там функция страницы) не качала — падало с «kl_site_html: command not found».
Теперь качаются все файлы релиза, и каждый сверяется с подписанным списком (проверка целостности стала
полной, а не только для установщика).
## v1.2.1.5: доступ к панели, ссылки и заглушка — одним пунктом меню
* **В меню kit пункт «9. Доступ к панели, ссылки и заглушка»** — открывает одним экраном: адрес панели,
логин и пароль (из /root/3x-ui.txt), состояние сертификата панели (доверенный на IP или
самоподписанный, с датой повтора при лимите Let's Encrypt), ссылки на подключения с QR-кодом и что
сейчас стоит заглушкой. Раньше доступ к панели в меню не показывался вообще, а про заглушку было
непонятно, как её посмотреть.
* Меню перенумеровано: «10. Панель x-ui», «11. Удалить KIT с сервера» (логика удаления — авторская).
* То же самое доступно из консоли: `kit local access [имя]`.
## v1.2.1.4: короткий итог установки, автовыбор в общедоступных сайтах, команда про порты
* **Итог установки вместо простыни текста** — компактный блок: сборка, сервер, протоколы, состояние
сертификата панели, одна строка проверки. Дальше только то, что нужно человеку: доступ к панели (адрес,
логин, пароль — по просьбе из ранних правок), указатель на меню kit для ссылок и QR, открытые порты и
команда для закрытия лишних. Длинные отчёты (состояние защиты, полный вывод kit check) убраны из
установки — они остались доступны командами, в том числе `kit local hardening`.
* **Ссылки на подключения больше не попадают в вывод установки даже косвенно**: раньше печатался весь
файл /root/3x-ui.txt, а в нём лежат и ссылки vless://. Теперь из него берутся только строки панели —
адрес, логин, пароль.
* **В меню общедоступных сайтов последним пунктом добавлен автовыбор** (раньше можно было только выбрать
номером или вернуться).
* **Новая команда `kit local ports`** — что открыто в ufw, что реально нужно (порты подключений и SSH),
что лишнее; `kit local ports --close-extra` закрывает лишнее.
* Ошибка подготовки нашей заглушки теперь печатает причину, а не просто «не смог».
## v1.2.1.3: ссылки и QR — в меню kit; сертификат панели видно в итоге
* **Ссылки для подключения и QR-код больше не печатаются в выводе установки.** Их смотрят позже, когда
настраивают телефон, и они всегда доступны заново: `kit → Пользователи → Показать ссылки`. В итоговом
блоке вместо них — короткий путь к этому пункту и команды `kit local links` (подписка и QR) и
`kit user link <имя> --all` (все протоколы). Доступы к панели (адрес, логин, пароль) по-прежнему
в конце установки: их вы просили показывать один раз и сразу.
* **Добавлена команда `kit local links`** — подписка, QR-код и ссылки по протоколам одной командой;
пользователя можно указать, иначе берётся первый.
* **В итоговом блоке видно состояние сертификата панели.** Панель и подписка висят на IP, поэтому
печатается: «Let's Encrypt на IP — доверенный, браузер не ругается» либо «самоподписанный» с датой,
после которой Let's Encrypt выпустит сертификат на IP (тот же лимит, что и для домена).
## v1.2.1.2: сертификат на IP — с датой повтора; «без домена» продолжает установку
* **Отказ Let's Encrypt на IP теперь объясняется честно.** Как и для домена, при лимите в выводе дата,
после которой выпуск возможен, и прямо сказано, что порт 80 и файрвол тут не при чём. Раньше печатались
«частые причины» и человек шёл проверять хостера.
* **Пункт «без своего домена» больше не останавливает установку.** При отказе с сертификатом домена выбор
«3» продолжает работу сразу: маскировка берётся из соседей по подсети (или известный сайт, если соседей
нет), панель и подписка переключаются на IP, дальше установка идёт своим ходом. Перезапускать скрипт
и заново отвечать на вопросы не нужно.
## v1.2.1.1: сборка на новой базе автора (3X-UI KIT 1.2.1)
**Правило нумерации:** берём версию автора и через точку добавляем свою, начиная с 1. У автора 1.2.1 —
значит наша сборка 1.2.1.1; когда выйдет его 1.2.2, наша будет 1.2.2.1.
**Взяли от автора (его код теперь работает вместо наших заплаток):**
* проверка A-записи домена сверяется с **публичным DNS**, а не с кэшем самого сервера — та самая причина,
когда запись уже есть, а сервер её «не видит»;
* пропал наш шов, открывавший порт 80: автор делает это сам перед выпуском сертификата **и закрывает обратно**;
* пропал наш шов переиспользования сертификата: автор тоже берёт прошлый;
* повторный запуск после неудачной установки: автор заново спрашивает протоколы и сайт маскировки и не
оставляет сервер на полпути — прежнее наше «доустанавливаю» больше не нужно;
* полное удаление: наш `kit local uninstall` теперь **делегирует авторской команде `kit uninstall`**
(она снимает и сам kit) и остаётся синонимом;
* меню `kit` — авторское (в том числе «9. Панель 3X-UI (меню x-ui)», «10. Удалить KIT с сервера»);
* попутно приехали его починки: Hiddify 4.x снова принимает подписку, ссылка MTProto ведёт на 443, смена
сайта маскировки не обрывает клиентов (прежнее имя живёт неделю), `nginx -t` в проверке сервера,
устойчивость к битому apt-репозиторию, длинные имена сайта в nginx, проверка копии (в т.ч. U+202E),
честная глубокая проверка, предупреждение «обновлять панель и ядро только через kit».
**Сохранили своё:**
* наш файл `scripts/kit-local.sh` и наша обёртка установщика — автор их не трогает;
* наш канал обновлений (GitHub → наш Gitea) и **только наш ключ подписи** в `KIT_SIGNERS`: сервер обязан
принимать обновления лишь из нашего канала;
* наш экран выбора протоколов (13 пунктов, дефолт REALITY + AmneziaWG-3.1) — теперь номера вводятся
**через пробел** («1 12»), как вы просили; запятые по-прежнему принимаются;
* наши меню маскировки REALITY и XHTTP/MTProto, вопрос «панель по IP или по домену», возврат «ввести
другой домен» без обрыва установки;
* наша заглушка ставится сразу, авторская не устанавливается;
* финальный блок: доступы, подписка и QR печатает наш установщик один раз, в самом конце (авторский дубль
«Готово! Сервер работает» убран, предупреждения остаются).
**Технически:** из 77 прежних швов 66 легли без изменений, 8 больше не нужны (их работу делает автор),
3 перенастроены на его новый код (ключ подписи, защита наших меню, финальный блок).
## v1.2.27: ошибка с доменом больше не обрывает установку
* **Свой домен вводится заново, пока не заработает.** Раньше при промахе (A-запись ещё не разошлась
по DNS, опечатка в имени) установка молча маскировалась под чужой сайт, а при домене из флага —
обрывалась с «исправьте причину и запустите скрипт снова». Теперь сразу после ввода домена идёт
проверка и при промахе — выбор: «проверить снова» (DNS расходится от минут до часов), «ввести другой
домен», «без своего домена — маскировка соседом по подсети».
* **Провал сертификата на шаге подключений не прерывает установку.** Предлагается повторить попытку
(пока ждали обновления DNS или лимит Let's Encrypt), ввести другое имя (с той же проверкой A-записи)
или остановиться с понятной инструкцией, как поставить без домена. Причина и дата повтора при лимите
печатаются в самом отказе, лог остаётся в /var/log/kit-domain-cert.log.
## v1.2.26: лимит Let's Encrypt виден прямо, готовый сертификат переиспользуется
* **Причина отказа теперь называется честно.** Let's Encrypt разрешает 5 сертификатов на точный набор имён
за 7 суток; при повторных установках лимит выбирается, и acme.sh отвечает `rateLimited`, а установщик
писал про «A-запись, порт 80 или Cloudflare» — то есть отправлял искать причину не там. Теперь в выводе
дата, после которой повтор сработает, и подсказка взять другое имя (`node3.…` — лимит считается на набор имён).
* **Готовый сертификат переиспользуется.** Если у acme.sh уже лежит годный сертификат на этот домен,
новый не заказывается — лишний заказ и упирался в лимит. Заодно не тратится время на повторные попытки.
* Итоговая ошибка установщика больше не повторяет догадки про порт и DNS: причина печатается выше,
а лог остаётся в /var/log/kit-domain-cert.log.
## v1.2.25: вопрос про панель возвращён, блок про защиту переписан понятнее
* **Снова спрашиваем, где открывать панель и подписку.** У автора этот вопрос задавался только когда
домен передан флагом `--domain`; при выборе «ввести свой домен» в меню маскировки его не было, и панель
молча оставалась по IP с самоподписанным сертификатом — то самое «панель на это ругается». Теперь:
выбрали свой домен → сразу вопрос «По IP (как раньше) или По домену» (`https://домен/…`, сертификат
Let's Encrypt, браузер не ругается).
* В отчёте защиты блок «чего здесь нет» больше не ссылается на сторонний проект без пояснений: сказано,
что именно сделано мягче (без полного обновления системы, без автоперезагрузки, без общесистемного
запрета ICMP) и почему — панель и ядро закреплённых версий, сервер не должен вставать сам.
## v1.2.24: сертификат идёт вслед за выбором «домен или IP» ## v1.2.24: сертификат идёт вслед за выбором «домен или IP»
* Выбрали в меню маскировки **свой домен** — панель и подписка тоже переезжают на домен: * Выбрали в меню маскировки **свой домен** — панель и подписка тоже переезжают на домен:
+14
View File
@@ -191,6 +191,20 @@ 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 + подпись нашим ключом
**Перед подписью — обязательный прогон.** `bash scripts/3x-ui-local.sh --verify-only` проверяет ровно тот
путь установки, которым пойдут серверы: скачивание всех файлов релиза и сверка каждого с подписанным
списком. На Windows запускать с `TMPDIR=C:/…` (нативные утилиты не понимают MSYS-пути).
**Нумерация версий.** Основа — версия автора, наша надстройка добавляется через точку с единицы:
у автора 1.2.1 → наша сборка 1.2.1.1; вышла его 1.2.2 → наша 1.2.2.1. Так на сервере всегда видно,
на чьей базе собрана сборка.
**Обновление базы автора.** Когда автор выпускает новую версию: перенести его файлы
(`git checkout upstream -- scripts/kit.sh scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit-sub.py`),
прогнать `python tools/fork_apply.py` и посмотреть, какие якоря не нашли себя. Те, чью работу автор уже
делает сам, — вычеркнуть; остальные перенастроить на его новый код и пересобрать таблицу швов
`git branch -f upstream <коммит автора>`.
Скрипт сам приводит переводы строк к LF, проверяет справку и то, что установщик не ругается на пустые Скрипт сам приводит переводы строк к LF, проверяет справку и то, что установщик не ругается на пустые
строки, сверяет панель и ядро Xray с официальными хешами GitHub (если GitHub недоступен — предупреждает и строки, сверяет панель и ядро Xray с официальными хешами GitHub (если GitHub недоступен — предупреждает и
пропускает эти проверки за секунды, а не висит; расхождение хешей при живом GitHub по-прежнему останавливает пропускает эти проверки за секунды, а не висит; расхождение хешей при живом GitHub по-прежнему останавливает
+9 -9
View File
@@ -1,9 +1,9 @@
# 3X-UI KIT 1.2.24 # 3X-UI KIT 1.2.1.9
# 3X-UI_autoVPS 1.2.24 # 3X-UI_autoVPS 1.2.1.9
24b55f3a5e3c8372c0eca0db5f194d09480e4356a73d711a8259b75b5f6fb9f8 scripts/3x-ui.sh 840b814246e0676a474511a26466b7b3406a8210c2b1e7097579b578d7c71257 scripts/3x-ui.sh
8163c6039cd6737d5f2c603201b57662ca7c07795e758f5e9d00936dc06b9536 scripts/hysteria2.sh a85f01f285156131e0a51a9de5fb2bc8ea76d5b49763bf0d544ca294b69173ed scripts/hysteria2.sh
ec87e519160f4923266bbf72ba7f552793725b6441ea276217e89f14bbced5d5 scripts/kit.sh 89b6c8635d77558dd361cf64c5a01cde08fbe2964ad6f63ba22c17599f1e620c scripts/kit.sh
4027ea2c6dbc9fd176356acac3f676445419c2f021146ed25dfe5a304c3ac5fb scripts/kit-sub.py f214671464e1c2e174e5af9df42c95567a9b4a9141f31a39f7df2e971e89d240 scripts/kit-sub.py
c38e5fe05bec66e747bb3feee2af4444d3a53d66e74ef1acb59861fbafa65e4c scripts/kit-local.sh 5d07c87452057e1c73e0f3af008074f94f5be6175817b2d6f94a93f69b0533b7 scripts/kit-local.sh
4d650d07bbe3dfc68eae56fbd1862f1cb136ced3ccb644d4b7edde1247a4fa73 scripts/3x-ui-local.sh bfaac8949c1e9f92fe92f1f0f6ff59ac7f37fad74cbf62439f327bc3144a8a06 scripts/3x-ui-local.sh
3e5c1851b0dbbe43aa03d460b95975a70946a5da993dc6b345ec9e8c39755246 VERSION bb43cbd01c2071d4aacdce23a00da7d86a126799e511a521c8a3c6818001a670 VERSION
+2 -2
View File
@@ -1,6 +1,6 @@
-----BEGIN SSH SIGNATURE----- -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV
FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
AAALc3NoLWVkMjU1MTkAAABA+EDwmG9uF2ewnHAAlQKJb7rsfX2r4CKHqvq9hJ0/J7887/ AAALc3NoLWVkMjU1MTkAAABA+3t9MmBZ+iwMvnKIfkihiqb0dX/qo8Ca5pTjBFec4v5ulG
4ASIxUTY/2jItB50ou+m+TFwmF7En7Z/TU3wnKAw== L4RtQ+cG1mBiv66u5h6Lh+yI/JgNzd/0Yccr5NBw==
-----END SSH SIGNATURE----- -----END SSH SIGNATURE-----
+1 -1
View File
@@ -1 +1 @@
1.2.24 1.2.1.9
+81 -79
View File
@@ -32,7 +32,6 @@
# --no-harden не трогать ping и автообновления # --no-harden не трогать ping и автообновления
# --ssh-port N добавить порт SSH N (прежний порт продолжает работать) # --ssh-port N добавить порт SSH N (прежний порт продолжает работать)
# --yes ни о чём не спрашивать (ping закрою, автообновления включу, порт и пароль — нет) # --yes ни о чём не спрашивать (ping закрою, автообновления включу, порт и пароль — нет)
# --no-time не запускать kit fix
# --no-check не запускать в конце kit check # --no-check не запускать в конце kit check
# --clean убрать прошлую установку и поставить заново (SSH не трогает) # --clean убрать прошлую установку и поставить заново (SSH не трогает)
# --auto-sni не предлагать выбор сайта маскировки (иначе покажем меню по скану подсети) # --auto-sni не предлагать выбор сайта маскировки (иначе покажем меню по скану подсети)
@@ -44,7 +43,7 @@
set -Eeuo pipefail set -Eeuo pipefail
KIT_VERSION="1.2.24" # версия сборки форка; должна совпадать с VERSION релиза KIT_VERSION="1.2.1.9" # версия сборки форка; должна совпадать с 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"
@@ -83,7 +82,7 @@ usage() {
--no-sub не показывать в конце подписку, доступ к панели и QR-код --no-sub не показывать в конце подписку, доступ к панели и QR-код
--clean снести прошлую установку и поставить заново (если прошлый запуск оборвался) --clean снести прошлую установку и поставить заново (если прошлый запуск оборвался)
--auto-sni не предлагать выбор сайта маскировки (автоподбор автора) --auto-sni не предлагать выбор сайта маскировки (автоподбор автора)
--no-time не запускать kit fix (время и безопасные починки) --no-time не запускать kit fix при установке (время, службы, права)
--no-check не запускать в конце kit check --no-check не запускать в конце kit check
--verify-only только проверить релиз (подпись и SHA256) и выйти --verify-only только проверить релиз (подпись и SHA256) и выйти
--dry-run показать план и ничего не делать --dry-run показать план и ничего не делать
@@ -162,7 +161,7 @@ if [[ $dry == yes ]]; then
random) pre "заглушка: случайный ИТ-сайт из нашего набора (10 штук)" ;; random) pre "заглушка: случайный ИТ-сайт из нашего набора (10 штук)" ;;
*) pre "заглушка: сайт №$site из нашего набора (список: kit local site --list)" ;; *) pre "заглушка: сайт №$site из нашего набора (список: kit local site --list)" ;;
esac esac
[[ $fix_time == yes ]] && pre "после: kit fix (время, безопасные починки)" [[ $fix_time == yes ]] && pre "после: kit fix (время, службы, права) — тихо, без описания команды"
[[ $do_check == yes ]] && pre "проверка: kit check + kit local hardening" [[ $do_check == yes ]] && pre "проверка: kit check + kit local hardening"
[[ $show_sub == yes ]] && pre "в самом конце: доступ к панели 3X-UI, подписка, QR-код и ссылки (--no-sub — не надо)" [[ $show_sub == yes ]] && pre "в самом конце: доступ к панели 3X-UI, подписка, QR-код и ссылки (--no-sub — не надо)"
echo echo
@@ -179,7 +178,7 @@ say "беру релиз v$KIT_VERSION и проверяю подпись"
tmp=$(mktemp -d) tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT trap 'rm -rf "$tmp"' EXIT
raw="$GITEA/raw/tag/v$KIT_VERSION" raw="$GITEA/raw/tag/v$KIT_VERSION"
for f in SHA256SUMS SHA256SUMS.sig scripts/3x-ui.sh; do for f in SHA256SUMS SHA256SUMS.sig; do
mkdir -p "$tmp/$(dirname "$f")" mkdir -p "$tmp/$(dirname "$f")"
curl -fsSL --retry 3 -m 60 -o "$tmp/$f" "$raw/$f" || die "не скачался $f из $raw (Gitea доступна с сервера?)" curl -fsSL --retry 3 -m 60 -o "$tmp/$f" "$raw/$f" || die "не скачался $f из $raw (Gitea доступна с сервера?)"
done done
@@ -188,9 +187,21 @@ ssh-keygen -Y verify -f "$tmp/allowed_signers" -I "$SIG_ID" -n "$NS" -s "$tmp/SH
|| die "подпись релиза v$KIT_VERSION не сошлась с ключом форка — это не наш релиз, ничего не запускаю" || die "подпись релиза v$KIT_VERSION не сошлась с ключом форка — это не наш релиз, ничего не запускаю"
sum=$(awk '{f = $2; sub(/^\*/, "", f)} f == "scripts/3x-ui.sh" {print $1}' "$tmp/SHA256SUMS") sum=$(awk '{f = $2; sub(/^\*/, "", f)} f == "scripts/3x-ui.sh" {print $1}' "$tmp/SHA256SUMS")
[[ $(printf '%s' "$sum" | wc -c) -eq 64 ]] || die "в SHA256SUMS нет установщика — релиз собран неверно" [[ $(printf '%s' "$sum" | wc -c) -eq 64 ]] || die "в SHA256SUMS нет установщика — релиз собран неверно"
# Качаем ВСЕ файлы релиза и сверяем каждый с подписанным списком. Так у нас есть и scripts/3x-ui.sh
# (установщик), и scripts/kit-local.sh (наша страница-заглушка), и всё остальное. Раньше брали только
# установщик: заглушка не готовилась («kl_site_html: command not found»), а проверка целостности была неполной.
while read -r sum2 name2; do
case $sum2 in \#* | "") continue ;; esac
case $name2 in SHA256SUMS | SHA256SUMS.sig) continue ;; esac
mkdir -p "$tmp/$(dirname "$name2")"
curl -fsSL --retry 3 -m 60 -o "$tmp/$name2" "$raw/$name2" || die "не скачался $name2 из $raw (Gitea доступна с сервера?)"
got2=$(sha256sum "$tmp/$name2" | awk '{print $1}')
[[ $got2 == "$sum2" ]] || die "SHA256 не совпал у $name2 — не запускаю"
done <"$tmp/SHA256SUMS"
got=$(sha256sum "$tmp/scripts/3x-ui.sh" | awk '{print $1}') got=$(sha256sum "$tmp/scripts/3x-ui.sh" | awk '{print $1}')
[[ $got == "$sum" ]] || die "SHA256 установщика не совпал с подписанным — не запускаю" [[ $got == "$sum" ]] || die "SHA256 установщика не совпал с подписанным — не запускаю"
bash -n "$tmp/scripts/3x-ui.sh" || die "установщик не проходит проверку синтаксиса" bash -n "$tmp/scripts/3x-ui.sh" || die "установщик не проходит проверку синтаксиса"
[[ -r $tmp/scripts/kit-local.sh ]] || die "в релизе нет scripts/kit-local.sh — сборка неполная"
printf ' ✓ подпись сошлась, SHA256 установщика совпал\n' printf ' ✓ подпись сошлась, SHA256 установщика совпал\n'
if [[ $verify_only == yes ]]; then if [[ $verify_only == yes ]]; then
@@ -276,11 +287,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[*]}")"
@@ -457,13 +468,13 @@ if [[ $site != off ]]; then
esac esac
if [[ -n $stub_n ]]; then if [[ -n $stub_n ]]; then
install -d -m 755 /var/www/kit 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 \ if ( source "$tmp/scripts/kit-local.sh" 2>/dev/null; kl_site_html "$stub_n" ) >/var/www/kit/index.html 2>"$tmp/stub.err" \
&& [[ -s /var/www/kit/index.html ]]; then && [[ -s /var/www/kit/index.html ]]; then
stub_staged=yes stub_staged=yes
rm -f "$tmp/var/www/kit/index.html" rm -f "$tmp/var/www/kit/index.html"
say "заглушка: наш сайт №$((stub_n + 1)) — ставится сразу, авторская страница не используется" say "заглушка: наш сайт №$((stub_n + 1)) — ставится сразу, авторская страница не используется"
else else
warn "не смог подготовить нашу заглушку — поставлю после установки (kit local site)" warn "не смог подготовить нашу заглушку — поставлю после установки (kit local site). Причина: $(head -c 200 "$tmp/stub.err" 2>/dev/null | tr '\n' ' ')"
fi fi
fi fi
fi fi
@@ -500,7 +511,7 @@ case ${stub_staged:-no} in
esac esac
if [[ $fix_time == yes ]]; then if [[ $fix_time == yes ]]; then
say "kit fix: синхронизация времени и безопасные починки (авторская команда, ничего необратимого)" say "kit fix: безопасные починки (время, службы, права)"
kit fix || warn "kit fix завершился с замечаниями — посмотрите вывод" kit fix || warn "kit fix завершился с замечаниями — посмотрите вывод"
fi fi
@@ -525,77 +536,68 @@ if [[ -n $ssh_port ]]; then
kit local ssh "$ssh_port" || warn "порт не добавился — сделайте отдельно: kit local ssh $ssh_port" kit local ssh "$ssh_port" || warn "порт не добавился — сделайте отдельно: kit local ssh $ssh_port"
fi fi
# ------------------------------------------------------------------ проверка # ------------------------------------------------------------------ итог: коротко и по делу
if [[ $do_check == yes ]]; then if [[ $do_check == yes || $show_sub == yes ]]; then
say "проверяю, что получилось" # kit check печатает много строк: человеку нужны только замечания и одна строка «всё ли в порядке».
kit check || warn "kit check нашёл замечания — читайте выше; безопасные починки: kit fix" chk=$(kit check 2>&1 || true)
kit local hardening problems=$(printf '%s\n' "$chk" | grep -cE '^❌|^⚠|^!' || true)
fi [[ $problems == 0 ]] || printf '%s\n' "$chk" | grep -E '^❌|^⚠|^!' | sed 's/^/ /'
ports_open=""
cat <<EOF command -v ufw >/dev/null 2>&1 && ports_open=$(ufw status 2>/dev/null | awk '$2 == "ALLOW" { printf "%s ", $1 }')
ip_show=$(hostname -I 2>/dev/null | awk '{ print $1 }')
${G}${B}Готово.${N} Сервер поставлен из нашей сборки (3X-UI_autoVPS $KIT_VERSION, канал: наш Gitea). os_show=$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}" )
cert_show="без сертификата: панель открывается через SSH-туннель"
подписка и ссылки: kit user link admin (или страница подписки из вывода установщика) if [[ -s /root/cert/ip/fullchain.pem ]]; then
если что-то не так: kit check --deep, затем kit fix if openssl x509 -in /root/cert/ip/fullchain.pem -noout -issuer 2>/dev/null | grep -q "Let's Encrypt"; then
вернуться к авторскому каналу: положить авторский kit поверх и выполнить kit update --force cert_show="${G}Let's Encrypt на IP — доверенный, браузер не ругается${N}"
EOF else
cert_show="${Y}самоподписанный — браузер предупредит${N}"
# ------------------------------------------------------------------ SSH: как закрыть прежние порты fi
fi
if [[ -n ${ssh_port:-} ]]; then protos_show=$(tr '\n' ' ' </etc/kit/kl-protos 2>/dev/null || true)
cat <<EOF cat <<EOF
${Y}${B}SSH переведён на порт $ssh_port.${N} Прежние порты (${ssh_ports_before:-22}) пока работают — это ${B}────────────── Установка завершена ──────────────${N}
сделано специально, чтобы вы не потеряли доступ. Порядок обязательный: сборка: 3X-UI_autoVPS $KIT_VERSION (канал: наш Gitea)
1) во ВТОРОМ окне проверьте вход: ssh -p $ssh_port root@${HOST:-<IP сервера>} сервер: ${ip_show:-?}${os_show:+ · $os_show}
2) вход работает — закройте прежние порты: ${B}kit local ssh --close-old $ssh_port${N} протоколы: ${protos_show:-—}
3) поправьте порт у себя: ~/.ssh/config, терминалы, мониторинг сертификат: $cert_show
проверка: $([[ $problems == 0 ]] && echo 'замечаний нет' || echo "замечаний: $problems (подробности: kit check)")
${B}───────────────────────────────────────────────────${N}
${B}Доступ к панели${N} (файл /root/3x-ui.txt, виден только root)
EOF
# Автор пишет эти строки без ведущих пробелов (RESULT=/root/3x-ui.txt), поэтому ищем их как есть.
if grep -aqE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt 2>/dev/null; then
grep -aE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt | sed 's/^[[:space:]]*/ /'
printf ' файл целиком (там же подписка и ссылки по протоколам): /root/3x-ui.txt\n'
else
warn "данных панели в /root/3x-ui.txt нет — смотрите вывод установщика выше"
fi
cat <<EOF
${B}Ссылки для подключения и QR-код${N} — в меню kit
kit → Пользователи → Показать ссылки из консоли: kit local links
EOF
if [[ -n $ports_open ]]; then
cat <<EOF
${B}Порты${N}
открыто: $ports_open
что нужно и что лишнее: kit local ports закрыть лишние: kit local ports --close-extra
EOF
fi
if [[ -n ${ssh_port:-} ]]; then
cat <<EOF
${Y}SSH переведён на порт $ssh_port.${N} Прежние (${ssh_ports_before:-22}) пока открыты намеренно:
1) проверьте вход во втором окне: ssh -p $ssh_port root@${ip_show:-<IP>}
2) закройте прежние порты: kit local ssh --close-old $ssh_port
EOF
fi
cat <<EOF
если что-то не так: kit check --deep, затем kit fix
EOF EOF
fi fi
# ------------------------------------------------------------------ доступ к панели (перед подпиской)
if [[ $show_sub == yes ]]; then
say "панель 3X-UI: адрес, логин и пароль"
if [[ -s /root/3x-ui.txt ]]; then
printf ' файл: /root/3x-ui.txt (виден только root). Сохраните данные себе:\n'
sed 's/^/ /' /root/3x-ui.txt
else
warn "файла /root/3x-ui.txt нет — адрес панели и логин были в выводе установщика выше"
fi
if grep -qaE '^Панель: ?https?://[0-9]' /root/3x-ui.txt 2>/dev/null; then
printf ' %sпанель открывается по IP: сертификат самоподписанный, браузер предупредит.\n' "$D"
printf ' Завести свой домен и перейти на него: kit net panel domain%s\n' "$N"
fi
fi
# ------------------------------------------------------------------ подписка, QR-код и доступы
# Показываем в самом конце, после всех проверок: это то, что человек уносит с собой.
# Печатает авторская команда (подписка + QR-код через qrencode + ссылки по протоколам),
# поэтому вывод не расходится с тем, что показывает `kit` потом.
if [[ $show_sub == yes ]]; then
sub_user=admin
for ((_i = 0; _i < ${#INSTALLER_ARGS[@]}; _i++)); do
[[ ${INSTALLER_ARGS[_i]} == --user ]] && sub_user=${INSTALLER_ARGS[_i + 1]:-admin}
done
say "подписка и доступы — это то, что стоит сохранить"
printf ' подписка — одна ссылка на все устройства; QR-код ниже наводите камерой в приложении\n'
printf ' приложения: Happ, Hiddify, Karing, v2rayN, Clash/Mihomo\n'
printf ' отдельные ссылки по протоколам — ниже же (если приложение не умеет подписку)\n'
printf ' %sпозже то же самое: kit user link %s --all (или меню: kit)%s\n' "$D" "$sub_user" "$N"
[[ -n ${ssh_port:-} ]] && printf ' SSH: новый порт %s, прежние (%s) ещё открыты — закройте их после проверки входа: kit local ssh --close-old %s\n' "$ssh_port" "${ssh_ports_before:-22}" "$ssh_port"
# Ссылки на каждый протокол показываем только если протоколов больше одного: при одном это лишний шум.
protos_now=$(tr '\n' ' ' < /etc/kit/kl-protos 2>/dev/null || true)
if [[ $(wc -w <<<"$protos_now") -gt 1 ]]; then
if ! kit user link "$sub_user" --all; then
warn "не смог показать ссылки для «$sub_user» — список пользователей: kit user list"
fi
elif ! kit user link "$sub_user"; then
warn "не смог показать подписку для «$sub_user» — список пользователей: kit user list"
fi
else
say "ссылки и QR-код не показываю (--no-sub). Посмотреть: kit user link admin --all"
fi
+390 -82
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.24" KIT_VERSION="1.2.1.9"
# 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
@@ -581,6 +756,8 @@ kl_sni_menu() {
if ((i == known_idx)); then if ((i == known_idx)); then
printf '\nОбщедоступные сайты:\n' >&2 printf '\nОбщедоступные сайты:\n' >&2
for k in "${!known[@]}"; do printf ' %d - %s\n' "$((k + 1))" "${known[k]}" >&2; done for k in "${!known[@]}"; do printf ' %d - %s\n' "$((k + 1))" "${known[k]}" >&2; done
# Наш шов: последним пунктом — автовыбор из этого списка (раньше выбор был только по номеру).
printf ' %d - автовыбор из списка\n' "$((${#known[@]} + 1))" >&2
printf ' 0 - назад\nВаш выбор: ' >&2 printf ' 0 - назад\nВаш выбор: ' >&2
ans="" ans=""
read -r -t 300 ans <"$src" 2>/dev/null || true read -r -t 300 ans <"$src" 2>/dev/null || true
@@ -588,6 +765,11 @@ kl_sni_menu() {
if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans >= 1 && 10#$ans <= ${#known[@]})); then if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans >= 1 && 10#$ans <= ${#known[@]})); then
printf 'sni:%s' "${known[10#$ans - 1]}"; return 0 printf 'sni:%s' "${known[10#$ans - 1]}"; return 0
fi fi
if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans == ${#known[@]} + 1)); then
local autopick="${known[RANDOM % ${#known[@]}]}"
printf ' автовыбор: %s\n' "$autopick" >&2
printf 'sni:%s' "$autopick"; return 0
fi
printf ' ничего не выбрано — оставляю автовыбор\n' >&2 printf ' ничего не выбрано — оставляю автовыбор\n' >&2
return 0 return 0
fi fi
@@ -605,13 +787,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 +808,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,16 +820,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 # Наш шов: домен вводится заново, пока он не указывает на этот сервер. Раньше при промахе
# установка молча маскировалась под чужой сайт, а при домене из флага — обрывалась.
# A-запись у регистратора расходится не мгновенно, поэтому первым пунктом — «проверить снова».
while :; do
if domain_points_here "$DOMAIN"; then
SNI=$DOMAIN SNI=$DOMAIN
# Наш шов: домен выбран для маскировки — и панель с подпиской переводим на домен. Сертификат
# Let's Encrypt выписан на домен, поэтому браузер не ругается (у автора это делает --panel-on domain).
PANEL_ON=domain
ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let's Encrypt выпустит установщик)" ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let's Encrypt выпустит установщик)"
ok "панель и подписка: ${B}https://$DOMAIN/${N} — тот же сертификат, без предупреждений браузера" # Наш шов: спрашиваем, где открывать панель и подписку, и при интерактивном вводе домена тоже.
else # У автора этот вопрос задавался только при домене из флага --domain, а при выборе «ввести свой
SNI=$DOMAIN; later "домен $DOMAIN не указывает на этот сервер — маскируюсь под него как под чужой сайт, панель и подписка остаются по IP" # домен» в меню маскировки — нет, и панель молча оставалась по IP с самоподписанным сертификатом.
panel_on_choice
break
fi 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
@@ -673,7 +878,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)
@@ -699,7 +904,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
@@ -715,17 +922,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
# Наш шов: про XHTTP и MTProto говорим только если они выбраны — иначе это шум.
[[ " ${PROTOS[*]} " == *" xhttp "* || " ${PROTOS[*]} " == *" mtproto "* ]] && \
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 с закреплённой версией ---
@@ -735,7 +939,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"
@@ -760,8 +966,13 @@ kl_sni_menu() {
if [[ $PANEL_SSL == ip && ! -s /root/cert/ip/fullchain.pem ]]; then if [[ $PANEL_SSL == ip && ! -s /root/cert/ip/fullchain.pem ]]; then
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
if [[ $rerun == yes ]]; then
# Сертификат на IP выпускает установщик 3X-UI, а при повторном запуске он не работает.
later "Сертификата на IP нет: в прошлый запуск его не получили, а повторный запуск его не выпускает. Ставлю без него: панель будет доступна только через SSH-туннель."
else else
later "Let's Encrypt не выдал сертификат на IP $HOST (порт 80 закрыт у хостера, лимит выпусков или сбой). Ставлю без него: панель будет доступна только через SSH-туннель." later "Let's Encrypt не выдал сертификат на IP $HOST (порт 80 закрыт у хостера, лимит выпусков или сбой). Ставлю без него: панель будет доступна только через SSH-туннель."
fi
PANEL_SSL=none PANEL_SSL=none
TRUSTED=no TRUSTED=no
fi fi
@@ -784,6 +995,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 "Подключения"
@@ -798,9 +1012,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.
@@ -818,12 +1068,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
@@ -852,7 +1114,7 @@ kl_sni_menu() {
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
# --- итог --- # --- итог ---
@@ -890,38 +1152,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
echo "${G}${B}Установка завершена.${N} Доступы к панели, подписка и QR-код — в самом конце вывода." echo "${G}${B}Установка завершена.${N} Доступы к панели, подписка и QR-код — в самом конце вывода."
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
} }
# Официальный установщик 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"
} }
@@ -936,7 +1201,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
@@ -987,12 +1252,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
@@ -1011,6 +1278,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)
@@ -1018,6 +1298,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
} }
# ---------- сертификат ---------- # ---------- сертификат ----------
@@ -1089,11 +1370,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"
@@ -1498,9 +1779,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
@@ -1571,6 +1862,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]};"
@@ -1591,8 +1884,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;
@@ -1630,8 +1923,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;
@@ -1753,7 +2046,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.
@@ -1804,12 +2099,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
@@ -1922,7 +2219,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"
@@ -1955,7 +2252,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
# Сначала проверяем архив целиком: только наши пути, только файлы и папки, без ссылок # Сначала проверяем архив целиком: только наши пути, только файлы и папки, без ссылок
@@ -1964,7 +2261,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")
@@ -1992,7 +2289,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
@@ -2006,7 +2303,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}
@@ -2030,6 +2327,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)
@@ -2094,6 +2406,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
@@ -2140,7 +2453,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
@@ -2151,6 +2464,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"
@@ -2178,25 +2493,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
} }
@@ -2205,11 +2516,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.24" KIT_VERSION="1.2.1.9"
cat >&2 <<'MSG' cat >&2 <<'MSG'
Отдельный установщик Hysteria2 больше не поддерживается. Отдельный установщик Hysteria2 больше не поддерживается.
+104 -164
View File
@@ -73,7 +73,10 @@ 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 access доступ к панели, сертификат, ссылки и заглушка — одним экраном
kit local links подписка, QR-код и ссылки по протоколам (как в меню kit)
kit local ports открытые порты: что нужно, что лишнее и как закрыть
kit local uninstall то же, что kit uninstall: снести всё, включая сам kit (SSH не трогает)
Заглушка (сайт на 443 и ответ Hysteria2 — одна и та же страница) Заглушка (сайт на 443 и ответ Hysteria2 — одна и та же страница)
kit local site поставить случайный сайт из нашего набора (10 штук) kit local site поставить случайный сайт из нашего набора (10 штук)
@@ -420,9 +423,8 @@ Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
EOF EOF
systemctl enable --now unattended-upgrades >/dev/null 2>&1 || kl_warn "служба не включилась — проверьте: systemctl status unattended-upgrades" systemctl enable --now unattended-upgrades >/dev/null 2>&1 || kl_warn "служба не включилась — проверьте: systemctl status unattended-upgrades"
kl_ok "включено: обновления безопасности ставятся сами" # Одна строка: подробности нужны редко, а в выводе установки они превращались в шум.
kl_ok "панель и ядро это не затрагивает — они ставятся не из apt, а из своих релизов" kl_ok "автообновления безопасности включены (панель и ядро это не затрагивает, сервер сам не перезагружается)"
kl_warn "обновление ядра применится только после перезагрузки, сам сервер не перезагружается"
return 0 return 0
} }
@@ -443,12 +445,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 +902,9 @@ 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 "$@" ;;
access) shift; kit_local_access "$@" ;;
ports) shift; kit_local_ports "$@" ;;
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 +927,99 @@ kit_local_cmd() {
# #
# kit local uninstall спросит подтверждение словом «удалить» # kit local uninstall спросит подтверждение словом «удалить»
# kit local uninstall --dry-run только покажет, что будет удалено # kit local uninstall --dry-run только покажет, что будет удалено
kit_local_uninstall() { kit_local_ports() { # [--close-extra] — что открыто в ufw, что реально нужно и как закрыть лишнее
kl_args "$@" local close=no a
local arg=${KL_REST[0]:-} ans found for a in "$@"; do
local reset_ssh=no i=0 case $a in
case $arg in --close-extra) close=yes ;;
"") ;; --help | -h) printf 'kit local ports [--close-extra] — открытые порты, нужные и лишние\n'; return 0 ;;
--dry-run) KL_DRY=yes ;; *) kl_die "kit local ports [--close-extra]"; return 1 ;;
--yes) KIT_LOCAL_UNINSTALL_CONFIRMED=yes ;;
--reset-ssh) reset_ssh=yes ;;
*) kl_die "kit local uninstall [--dry-run] [--yes] [--reset-ssh]"; return 1 ;;
esac esac
# Службы: и явный список, и всё, что найдётся по нашим шаблонам (чтобы новые юниты kit не оставались).
# kit запускает наши команды с `set -e`: при уборке это недопустимо — любая «не найденная» служба
# или пустой grep обрывали бы снос на середине (так и было: файлы оставались). Убираем errexit.
set +e
local -a svc=(x-ui.service kit-sub.service kit-update.timer kit-update.service kit-limit.timer kit-limit.service)
local u
for u in $(systemctl list-unit-files --no-legend 2>/dev/null | awk '{print $1}' \
| grep -E '^(x-ui|kit-|kit\.)' | grep -E '\.(service|timer)$'); do
[[ " ${svc[*]} " == *" $u "* ]] || svc+=("$u")
done done
# Список собран по коду установщика (scripts/3x-ui.sh) и нашего слоя: убираем всё, что они создают. command -v ufw >/dev/null 2>&1 || { kl_warn "ufw не установлен — порты смотреть нечем"; return 0; }
local -a paths=( local -a opened=() needed=() extra=()
/usr/local/x-ui /etc/x-ui /usr/bin/x-ui /usr/bin/x-ui.new /etc/systemd/system/x-ui.service mapfile -t opened < <(ufw status 2>/dev/null | awk '$2 == "ALLOW" { print $1 }' | sort -u)
/usr/local/bin/kit /usr/local/lib/kit /usr/local/lib/kit-sub /etc/kit /etc/kit-sub # Нужные порты: те, что слушают наши подключения (x-ui и ядро Xray), плюс SSH.
/etc/systemd/system/kit-sub.service /etc/systemd/system/kit-update.service mapfile -t needed < <({ ss -tlnp 2>/dev/null; ss -ulnp 2>/dev/null; } \
/etc/systemd/system/kit-update.timer /etc/systemd/system/kit-limit.service | awk '/x-ui|xray/ { print $4 }' | sed 's/.*://' | sort -un; ss -tlnp 2>/dev/null | awk '/sshd/ { print $4 }' | sed 's/.*://' | sort -un)
/etc/systemd/system/kit-limit.timer local p q keep
/etc/cron.d/kit-nginx-reload /etc/cron.d/kit-xui-menu /etc/cron.d/kit-sub-cert for p in "${opened[@]}"; do
/etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf /var/www/kit keep=no
/root/3x-ui.txt /root/cert /root/.acme.sh /etc/letsencrypt /var/log/kit-install-check.log case $p in 80/tcp | 443/tcp) keep=yes ;; esac # 443 — работа, 80 — продление сертификата
/etc/sysctl.d/99-kit-local-icmp.conf /etc/apt/apt.conf.d/52-kit-local-upgrades for q in "${needed[@]}"; do [[ $p == "$q/tcp" || $p == "$q/udp" || $p == "$q" ]] && keep=yes; done
/etc/apt/apt.conf.d/20auto-upgrades [[ $keep == yes ]] && continue
) extra+=("$p")
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 done
if kl_have nginx; then printf ' открыто в ufw: %s\n' "$(printf '%s ' "${opened[@]}")"
local others if ((${#needed[@]})); then printf ' нужны: %s\n' "$(printf '%s/tcp %s/udp ' "${needed[@]}" "${needed[@]}")"
others=$( { find /etc/nginx/conf.d /etc/nginx/sites-enabled /etc/nginx/streams-enabled -mindepth 1 2>/dev/null || true; } \ else printf ' нужны: (не определил — смотрите kit check)\n'; fi
| { grep -vE 'kit\.conf$|(^|/)default$' || true; } | wc -l) if ((${#extra[@]})); then
if [[ $others -eq 0 ]]; then printf ' лишние: %s\n' "$(printf '%s ' "${extra[@]}")"
systemctl disable --now nginx >/dev/null 2>&1 && kl_ok "nginx остановлен (чужих сайтов в нём не было)" printf ' закрыть лишние: kit local ports --close-extra\n'
KL_NGINX_PURGE=yes if [[ $close == yes ]]; then
for p in "${extra[@]}"; do ufw --force delete allow "$p" >/dev/null 2>&1 && printf ' закрыл %s\n' "$p"; done
fi
else else
kl_ok "nginx оставляю: в нём есть другие сайты — уберу только наши строки и файлы" printf ' лишних нет\n'
KL_NGINX_PURGE=no
fi fi
fi }
kit_local_access() { # доступ к панели, ссылки на подключения, заглушка и сертификат — одним экраном
kl_step "Убираю файлы" local u=${1:-} till2
local f printf '%s\n' "${B:-}Доступ к панели 3X-UI${N:-} (файл /root/3x-ui.txt, виден только root)"
for f in "${paths[@]}"; do if grep -aqE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt 2>/dev/null; then
[[ -e $f ]] || continue grep -aE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt | sed 's/^[[:space:]]*/ /'
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 else
kl_ok "чисто: от установки не осталось ни файлов, ни служб, порт 443 свободен" kl_warn "данных панели в /root/3x-ui.txt нет"
kl_ok "проверка установщика пройдена: он снова поставит сервер с нуля"
fi fi
kl_ok "SSH не тронут (если не указан --reset-ssh)" printf '\n%s\n' "${B:-}Сертификат панели${N:-}"
kl_step "Готово: сервер в состоянии до установки, можно ставить заново" if [[ -s /root/cert/ip/fullchain.pem ]] && openssl x509 -in /root/cert/ip/fullchain.pem -noout -issuer 2>/dev/null | grep -q "Let's Encrypt"; then
printf ' Let'"'"'s Encrypt на IP — доверенный, браузер не ругается\n'
elif [[ -s /root/cert/ip/fullchain.pem ]]; then
printf ' самоподписанный — браузер предупредит, подписка в приложениях может не открыться\n'
else
printf ' сертификата на IP нет: панель открывается только через SSH-туннель\n'
fi
till2=$(grep -rhoE 'retry after [0-9-]+ [0-9:]+ UTC' /var/log/3x-ui-install.log "$HOME"/.acme.sh/*/ca 2>/dev/null | tail -1 | sed 's/^retry after //')
[[ -n $till2 ]] && printf ' лимит Let'"'"'s Encrypt: повтор выпуска возможен после %s (затем запустите установщик снова)\n' "$till2"
printf '\n%s\n' "${B:-}Ссылки на подключения и QR-код${N:-}"
if [[ -z $u ]]; then u=$(kit user list 2>/dev/null | awk 'NR > 1 && NF { print $1; exit }'); u=${u:-admin}; fi
local now; now=$(tr '\n' ' ' </etc/kit/kl-protos 2>/dev/null || true)
if [[ $(wc -w <<<"$now") -gt 1 ]]; then kit user link "$u" --all; else kit user link "$u"; fi
printf '\n%s\n' "${B:-}Заглушка (что видит посторонний, открыв адрес сервера)${N:-}"
kit local site --show
printf ' посмотреть снаружи: откройте https://<IP сервера>/ в браузере или curl -k https://<IP>/
'
}
kit_local_links() { # [имя] — подписка, QR-код и ссылки по протоколам (то же, что «kit → Пользователи → Показать ссылки»)
local u=${1:-} now
if [[ -z $u ]]; then
u=$(kit user list 2>/dev/null | awk 'NR > 1 && NF { print $1; exit }')
u=${u:-admin}
fi
printf ' пользователь: %s\n' "$u"
now=$(tr '\n' ' ' </etc/kit/kl-protos 2>/dev/null || true)
# Ссылки на каждый протокол — только если протоколов больше одного; при одном достаточно подписки.
if [[ $(wc -w <<<"$now") -gt 1 ]]; then kit user link "$u" --all; else kit user link "$u"; fi
}
kit_local_uninstall() {
# Наш шов: логику удаления отдаём авторской команде «kit uninstall». Она снимает и сам kit, чтобы на
# сервере не осталось следов, поэтому своя реализация здесь больше не нужна — команда оставлена
# синонимом, чтобы привычное «kit local uninstall» продолжало работать.
local a=() x
for x in "$@"; do
case $x in
--dry-run | --yes | --no-backup) a+=("$x") ;;
--help | -h)
printf 'kit local uninstall [--dry-run] [--yes] [--no-backup] — то же, что kit uninstall\n'
return 0 ;;
*) kl_die "kit local uninstall [--dry-run] [--yes] [--no-backup]"; return 1 ;;
esac
done
[[ -x /usr/local/bin/kit ]] || { kl_die "не нашёл /usr/local/bin/kit — удалить вручную: kit uninstall"; return 1; }
printf ' удаляю по авторской логике: /usr/local/bin/kit uninstall %s\n' "${a[*]}"
exec /usr/local/bin/kit uninstall "${a[@]}"
} }
# ---------------------------------------------------------------- полная справка `kit help` # ---------------------------------------------------------------- полная справка `kit help`
@@ -1131,7 +1068,10 @@ ${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 access [имя] доступ к панели, сертификат, ссылки и заглушка одним экраном
kit local links [имя] подписка, QR-код и ссылки по протоколам (по умолчанию — первый пользователь)
kit local ports [--close-extra] открытые порты: нужные, лишние; --close-extra закрывает лишние
kit local uninstall то же, что kit uninstall: снести установку целиком и сам kit; SSH не трогает
kit check [--deep | --fix] проверка сервера (--deep — ходит наружу как клиент) kit 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()
+897 -117
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Проверка: подсказка «Выбор [0-N]» в каждом меню kit должна совпадать с числом пунктов.
Пункты считаются по строкам box (с учётом переносов через «\\»). Смысл проверки: мы добавляем в меню
свои пункты — однажды подсказка осталась «0-10» при 11 пунктах, и человек не мог выбрать последний.
"""
import re
import sys
import pathlib
path = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "scripts/kit.sh")
text = path.read_text(encoding="utf-8").replace("\\\n", "")
lines = text.splitlines()
bad = 0
for i, line in enumerate(lines):
m = re.search(r'\bbox\s+"([^"]+)"(.*)$', line)
if not m:
continue
items = re.findall(r'"(\d+)\.\s', m.group(2))
maxn = max((int(x) for x in items), default=0)
prompt = None
for j in range(i + 1, min(i + 120, len(lines))):
pm = re.search(r'ask_num\s+"Выбор \[0-(\d+)\]', lines[j])
if pm:
prompt = int(pm.group(1))
break
if prompt != maxn:
print(f"! меню «{m.group(1)}»: пунктов {maxn}, а подсказка «Выбор [0-{prompt}]»", file=sys.stderr)
bad += 1
print(f"меню проверены: {len(lines) and sum(1 for l in lines if 'box \"' in l)} · рассинхронов: {bad}")
sys.exit(1 if bad else 0)
+69 -27
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',
), ),
( (
@@ -191,6 +191,13 @@ def main():
' echo "${G}✓ Готово: 3X-UI_autoVPS $latest.${N} Пользователи, ссылки и подписки не менялись."\n', ' echo "${G}✓ Готово: 3X-UI_autoVPS $latest.${N} Пользователи, ссылки и подписки не менялись."\n',
'авто-шов: replace, строк 1', 'авто-шов: replace, строк 1',
), ),
(
'scripts/kit.sh',
' echo "${B}kit fix${N} – безопасные исправления (службы, права, автообновление, сертификат, время)"\n echo\n',
False,
' # Наш шов: строку-описание команды не печатаем — при установке это лишний шум,\n # а смысл виден по результату (подробности — в kit help).\n',
'авто-шов: replace, строк 2',
),
( (
'scripts/kit.sh', 'scripts/kit.sh',
'split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/<base64> с Name "3X-UI KIT"\n', 'split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/<base64> с Name "3X-UI KIT"\n',
@@ -214,25 +221,32 @@ def main():
), ),
( (
'scripts/kit.sh', 'scripts/kit.sh',
' "7. Резервная копия" "8. Версии" "" "9. Панель 3X-UI (меню x-ui)" "0. Выход"\n', ' "7. Резервная копия" "8. Версии" "" "9. Панель 3X-UI (меню x-ui)" "10. Удалить KIT с сервера" "0. Выход"\n',
False, False,
' "7. Резервная копия" "8. Версии" "" "9. Удалить всё установленное" "10. Выход" \\\n "11. Панель 3X-UI (меню x-ui)"\n', ' "7. Резервная копия" "8. Версии" "" "9. Доступ к панели, ссылки и заглушка" "10. Панель x-ui" "11. Удалить KIT с сервера" "0. Выход"\n',
'авто-шов: replace, строк 1', 'авто-шов: replace, строк 1',
), ),
( (
'scripts/kit.sh', 'scripts/kit.sh',
' ask_num "Выбор [0-9]: "; c=$REPLY\n', ' ask_num "Выбор [0-10]: "; c=$REPLY\n',
False, False,
' ask_num "Выбор [1-11]: "; c=$REPLY\n', ' ask_num "Выбор [0-11]: "; c=$REPLY\n',
'авто-шов: replace, строк 1', 'авто-шов: replace, строк 1',
), ),
( (
'scripts/kit.sh', 'scripts/kit.sh',
' 9) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 0 | "") return 0 ;;\n', ' 9) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 10) run_action cmd_uninstall; pause ;;\n',
False, False,
' 9) kit_local_uninstall && return 0 ;; # снос — и сразу выход: показывать меню после удаления незачем\n 10 | 0 | "") return 0 ;;\n 11) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n', ' # Наш шов: пункт 9 — доступ к панели, ссылки на подключения и состояние заглушки в одном месте.\n 9) kit local access; pause ;;\n 10) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 11) run_action cmd_uninstall; pause ;;\n',
'авто-шов: replace, строк 2', 'авто-шов: replace, строк 2',
), ),
(
'scripts/kit.sh',
' echo "${B}Полное удаление 3X-UI KIT${N}"\n',
False,
' echo "${B}Полное удаление 3X-UI_autoVPS${N}"\n',
'авто-шов: replace, строк 1',
),
( (
'scripts/kit.sh', '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 +303,34 @@ def main():
' names=$(nearby_scan "$base" | grep -vE "^(${me})$" || true)\n [[ -n $names ]] || continue\n out=$(while IFS= read -r n; do\n [[ $n =~ $SNI_DYN_RE || $n =~ $SNI_BRAND_RE || $n =~ $SNI_BAD_RE ]] && continue\n printf \'%s\\n\' "$n"\n done <<<"$names" | xargs -P 8 -I{} bash -c \'same_net "$1" && sni_alive "$1" && sni_trusted "$1" && sni_quality "$1" && printf "%s\\n" "$1"\' _ {} 2>/dev/null | head -8)\n [[ -n $out ]] && { printf \'%s\\n\' "$out"; return 0; }\n', ' 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",
@@ -307,14 +349,14 @@ def main():
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
' later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP."\n fi\n\n', ' later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP."\n fi\n\n',
False, False,
' later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP."\n fi\n\n# ---------------------------------------------------------------------------\n# Наш шов: выбор сайта маскировки по результатам скана подсети.\n# Печатает выбранное имя в stdout (пусто — оставляем автоподбор автора).\n# role — подпись для человека: REALITY или XHTTP/MTProto.\nkl_sni_menu() {\n local role=$1; shift\n local -a found=("$@")\n local -a known=("${SNI_CANDIDATES[@]}")\n local src=${KL_ASK_SRC:-/dev/tty} ans i k n auto_idx known_idx own_idx try\n n=${#found[@]}\n printf \'\\n%s%s%s\\n\' "$B" "$role" "$N" >&2\n if ((n)); then\n printf \'Найдено в подсети:\\n\\n\' >&2\n for i in "${!found[@]}"; do printf \' %d - %s\\n\' "$((i + 1))" "${found[i]}" >&2; done\n auto_idx=$((n + 1)); known_idx=$((n + 2)); own_idx=$((n + 3))\n printf \' %d - автовыбор из списка\\n\' "$auto_idx" >&2\n printf \' %d - выбрать общедоступный (например %s, %s)\\n\' "$known_idx" "${known[0]}" "${known[1]}" >&2\n printf \' %d - ввести свой домен\\n\\n\' "$own_idx" >&2\n else\n auto_idx=0; known_idx=1; own_idx=2\n printf \'В подсети доменов не найдено, выберите:\\n\\n\' >&2\n printf \' 1 - общедоступный (например %s, %s)\\n\' "${known[0]}" "${known[1]}" >&2\n printf \' 2 - ввести свой домен\\n\\n\' >&2\n fi\n printf \'Ваш выбор (Enter — автовыбор): \' >&2\n for try in 1 2 3; do\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n [[ -z $ans ]] && return 0\n [[ $ans =~ ^[0-9]+$ ]] || { printf \' введите номер из списка\\n\' >&2; continue; }\n i=$((10#$ans))\n if ((n)) && ((i >= 1 && i <= n)); then printf \'sni:%s\' "${found[i - 1]}"; return 0; fi\n ((i == auto_idx)) && return 0\n if ((i == known_idx)); then\n printf \'\\nОбщедоступные сайты:\\n\' >&2\n for k in "${!known[@]}"; do printf \' %d - %s\\n\' "$((k + 1))" "${known[k]}" >&2; done\n printf \' 0 - назад\\nВаш выбор: \' >&2\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans >= 1 && 10#$ans <= ${#known[@]})); then\n printf \'sni:%s\' "${known[10#$ans - 1]}"; return 0\n fi\n printf \' ничего не выбрано — оставляю автовыбор\\n\' >&2\n return 0\n fi\n if ((i == own_idx)); then\n printf \' адрес сайта (например vpn.example.com): \' >&2\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n [[ $ans =~ ^[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$ ]] && { printf \'own:%s\' "$ans"; return 0; }\n printf \' не похоже на адрес сайта — оставляю автовыбор\\n\' >&2\n return 0\n fi\n printf \' такого номера нет\\n\' >&2\n done\n return 0\n}\n\n', ' later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP."\n fi\n\n# ---------------------------------------------------------------------------\n# Наш шов: выбор сайта маскировки по результатам скана подсети.\n# Печатает выбранное имя в stdout (пусто — оставляем автоподбор автора).\n# role — подпись для человека: REALITY или XHTTP/MTProto.\nkl_sni_menu() {\n local role=$1; shift\n local -a found=("$@")\n local -a known=("${SNI_CANDIDATES[@]}")\n local src=${KL_ASK_SRC:-/dev/tty} ans i k n auto_idx known_idx own_idx try\n n=${#found[@]}\n printf \'\\n%s%s%s\\n\' "$B" "$role" "$N" >&2\n if ((n)); then\n printf \'Найдено в подсети:\\n\\n\' >&2\n for i in "${!found[@]}"; do printf \' %d - %s\\n\' "$((i + 1))" "${found[i]}" >&2; done\n auto_idx=$((n + 1)); known_idx=$((n + 2)); own_idx=$((n + 3))\n printf \' %d - автовыбор из списка\\n\' "$auto_idx" >&2\n printf \' %d - выбрать общедоступный (например %s, %s)\\n\' "$known_idx" "${known[0]}" "${known[1]}" >&2\n printf \' %d - ввести свой домен\\n\\n\' "$own_idx" >&2\n else\n auto_idx=0; known_idx=1; own_idx=2\n printf \'В подсети доменов не найдено, выберите:\\n\\n\' >&2\n printf \' 1 - общедоступный (например %s, %s)\\n\' "${known[0]}" "${known[1]}" >&2\n printf \' 2 - ввести свой домен\\n\\n\' >&2\n fi\n printf \'Ваш выбор (Enter — автовыбор): \' >&2\n for try in 1 2 3; do\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n [[ -z $ans ]] && return 0\n [[ $ans =~ ^[0-9]+$ ]] || { printf \' введите номер из списка\\n\' >&2; continue; }\n i=$((10#$ans))\n if ((n)) && ((i >= 1 && i <= n)); then printf \'sni:%s\' "${found[i - 1]}"; return 0; fi\n ((i == auto_idx)) && return 0\n if ((i == known_idx)); then\n printf \'\\nОбщедоступные сайты:\\n\' >&2\n for k in "${!known[@]}"; do printf \' %d - %s\\n\' "$((k + 1))" "${known[k]}" >&2; done\n # Наш шов: последним пунктом — автовыбор из этого списка (раньше выбор был только по номеру).\n printf \' %d - автовыбор из списка\\n\' "$((${#known[@]} + 1))" >&2\n printf \' 0 - назад\\nВаш выбор: \' >&2\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans >= 1 && 10#$ans <= ${#known[@]})); then\n printf \'sni:%s\' "${known[10#$ans - 1]}"; return 0\n fi\n if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans == ${#known[@]} + 1)); then\n local autopick="${known[RANDOM % ${#known[@]}]}"\n printf \' автовыбор: %s\\n\' "$autopick" >&2\n printf \'sni:%s\' "$autopick"; return 0\n fi\n printf \' ничего не выбрано — оставляю автовыбор\\n\' >&2\n return 0\n fi\n if ((i == own_idx)); then\n printf \' адрес сайта (например vpn.example.com): \' >&2\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n [[ $ans =~ ^[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$ ]] && { printf \'own:%s\' "$ans"; return 0; }\n printf \' не похоже на адрес сайта — оставляю автовыбор\\n\' >&2\n return 0\n fi\n printf \' такого номера нет\\n\' >&2\n done\n return 0\n}\n\n',
'авто-шов: insert, строк 3', 'авто-шов: insert, строк 3',
), ),
( (
'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 +370,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\n # Наш шов: домен выбран для маскировки — и панель с подпиской переводим на домен. Сертификат\n # Let\'s Encrypt выписан на домен, поэтому браузер не ругается (у автора это делает --panel-on domain).\n PANEL_ON=domain\n ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let\'s Encrypt выпустит установщик)"\n ok "панель и подписка: ${B}https://$DOMAIN/${N} — тот же сертификат, без предупреждений браузера"\n else\n SNI=$DOMAIN; later "домен $DOMAIN не указывает на этот сервер — маскируюсь под него как под чужой сайт, панель и подписка остаются по IP"\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',
), ),
( (
@@ -354,9 +396,9 @@ def main():
), ),
( (
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
' later "XHTTP и MTProto используют известные сайты из общего списка (достаточно соседей по подсети не нашлось). Заменить: kit net site"\n', ' issue_domain_cert || die "Без сертификата для $DOMAIN продолжать нельзя. Исправьте причину и запустите скрипт снова."\n',
False, False,
' # Наш шов: про XHTTP и MTProto говорим только если они выбраны — иначе это шум.\n [[ " ${PROTOS[*]} " == *" xhttp "* || " ${PROTOS[*]} " == *" mtproto "* ]] && \\\n later "XHTTP и MTProto используют известные сайты из общего списка (достаточно соседей по подсети не нашлось). Заменить: kit net site"\n', ' # Наш шов: провал сертификата не обрывает установку. Человек может повторить попытку (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', 'авто-шов: replace, строк 1',
), ),
( (
@@ -382,17 +424,17 @@ def main():
), ),
( (
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
' echo "${G}${B}Готово! Сервер работает.${N}"\n [[ -n $DOMAIN ]] && echo "Маскировка: свой домен ${B}$DOMAIN${N}, сертификат Let\'s Encrypt продлевается сам."\n [[ $SELF_IP_CERT == yes ]] && later "Сертификат на IP самоподписанный: пользуйтесь ссылками на отдельные подключения, подписка в приложениях может не открыться."\n echo\n 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 echo\n KIT_NO_QR=1 /usr/local/bin/kit __links "$NAME" "$SUBID" || true\n else\n /usr/local/bin/kit __links "$NAME" "$SUBID" || true\n fi\n echo\n echo "${D}Порты, выключение и включение протоколов: ${N}${B}kit net${N}"\n', ' } >"$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, False,
' echo "${G}${B}Установка завершена.${N} Доступы к панели, подписка и QR-код — в самом конце вывода."\n', ' } >"$RESULT"\n\n echo\n',
'авто-шов: replace, строк 27', 'авто-шов: delete, строк 6',
), ),
( (
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
' ((${#WARNINGS[@]} > 3)) && echo " … и ещё $((${#WARNINGS[@]} - 3)): kit check"\n fi\n echo\n echo "${B}ДАЛЬШЕ${N}"\n echo " ${B}kit${N} меню управления"\n echo " ${B}kit user add имя --gb 50 --days 30${N} добавить пользователя"\n echo " ${B}kit check --deep${N} проверить, что всё работает"\n echo " ${B}kit backup${N} копия сервера (держите в надёжном месте)"\n', ' 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, False,
' ((${#WARNINGS[@]} > 3)) && echo " … и ещё $((${#WARNINGS[@]} - 3)): kit check"\n fi\n\n', ' echo "${G}${B}Установка завершена.${N} Доступы к панели, подписка и QR-код — в самом конце вывода."\n',
'авто-шов: replace, строк 8', 'авто-шов: replace, строк 25',
), ),
( (
'scripts/3x-ui.sh', 'scripts/3x-ui.sh',
@@ -452,17 +494,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',
@@ -517,7 +559,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',
), ),
( (
+9 -1
View File
@@ -59,6 +59,11 @@ for fn in ask_yes ask_ssh_port ssh_ports_now ask_protocols proto_parse proto_men
[[ -z $c || $c -gt $d ]] || die "scripts/3x-ui-local.sh: $fn вызывается (строка $c) раньше, чем определена ($d)" [[ -z $c || $c -gt $d ]] || die "scripts/3x-ui-local.sh: $fn вызывается (строка $c) раньше, чем определена ($d)"
done done
ok "Все наши функции определены раньше своих вызовов" ok "Все наши функции определены раньше своих вызовов"
# python3 есть не везде (на Windows обычно только python) — берём тот, что есть.
py=$(command -v python3 || command -v python || true)
[[ -n $py ]] || die "нет python — им проверяются меню (tools/check-menus.py)"
"$py" tools/check-menus.py >/dev/null || die "подсказка «Выбор [0-N]» не совпадает с числом пунктов меню (tools/check-menus.py)"
ok "Подсказки меню совпадают с числом пунктов"
[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md FORK-CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит." [[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md FORK-CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
@@ -114,7 +119,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"