Compare commits
182
Commits
v1.1.1
..
edabfd9a83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edabfd9a83 | ||
|
|
741a70c9bf | ||
|
|
0d2af45bdb | ||
|
|
a02650311e | ||
|
|
2b57e6f6d7 | ||
|
|
df8a99bb78 | ||
|
|
47328b33b3 | ||
|
|
776b7a8c5e | ||
|
|
2d63136853 | ||
|
|
1b85a4bb23 | ||
|
|
b80c44077c | ||
|
|
f894b48cd2 | ||
|
|
bddef5b8d3 | ||
|
|
53cb694206 | ||
|
|
5c22cbf4fe | ||
|
|
a7b4586bc3 | ||
|
|
cec5246e47 | ||
|
|
151d510495 | ||
|
|
f9940026dc | ||
|
|
6d7ebf234e | ||
|
|
41092d4cd1 | ||
|
|
78cf3c05dc | ||
|
|
83d9b11c5c | ||
|
|
1fbf27e07c | ||
|
|
9571e93d46 | ||
|
|
9dfb4e7957 | ||
|
|
51c35dd2c1 | ||
|
|
3f3db5b04c | ||
|
|
8fd5572450 | ||
|
|
01ab468e24 | ||
|
|
b20fd6af77 | ||
|
|
bc18fd354e | ||
|
|
25e25232b2 | ||
|
|
6a9a6ed07e | ||
|
|
d127a92785 | ||
|
|
f38aeefcbc | ||
|
|
d0bfb2dc38 | ||
|
|
a0df115616 | ||
|
|
f4cf157293 | ||
|
|
73754dbc01 | ||
|
|
c261d27b80 | ||
|
|
fa9e7225ea | ||
|
|
4a9e452e3e | ||
|
|
d034e207da | ||
|
|
22b9925b0c | ||
|
|
9aa93fad37 | ||
|
|
5558314849 | ||
|
|
b778ce4428 | ||
|
|
143b41c84e | ||
|
|
01662ff039 | ||
|
|
04a69f3ae3 | ||
|
|
0daaf65b5c | ||
|
|
64796e0f53 | ||
|
|
b7834d5f5c | ||
|
|
77f98da99a | ||
|
|
6bb4f55f33 | ||
|
|
6fb7d6c59c | ||
|
|
ad7b66dbf8 | ||
|
|
6323c28533 | ||
|
|
6da8650d1d | ||
|
|
8d470f3eb1 | ||
|
|
c270b09891 | ||
|
|
a1ce2f1c54 | ||
|
|
65f29782fe | ||
|
|
db549348e5 | ||
|
|
7cc7e684d5 | ||
|
|
5e316fb1be | ||
|
|
6b497b047d | ||
|
|
13c86723be | ||
|
|
96823d74f1 | ||
|
|
ca5a57db55 | ||
|
|
628130992d | ||
|
|
445c066791 | ||
|
|
1fdc4945f1 | ||
|
|
cc17b11bd1 | ||
|
|
4ed9c5ceb5 | ||
|
|
0f31fc1739 | ||
|
|
619b2120ce | ||
|
|
b61a3225d8 | ||
|
|
70e2a499c7 | ||
|
|
a70dd14542 | ||
|
|
934077c56f | ||
|
|
ada50ae24e | ||
|
|
2e875aaa2f | ||
|
|
88af9444c0 | ||
|
|
e0ddf8fe25 | ||
|
|
c83fde40e4 | ||
|
|
211053a36b | ||
|
|
80258f1d45 | ||
|
|
7b30cd9b23 | ||
|
|
45b8d1c783 | ||
|
|
47cdfd34a5 | ||
|
|
ddfbabf0c2 | ||
|
|
a94f755317 | ||
|
|
cd99a12c29 | ||
|
|
2c6549a3af | ||
|
|
64fffa5e73 | ||
|
|
5913dcee09 | ||
|
|
018d9cefa2 | ||
|
|
436acffc22 | ||
|
|
25df91e79a | ||
|
|
387d6ae66e | ||
|
|
397804faa7 | ||
|
|
5449a73fd8 | ||
|
|
f7a8475c88 | ||
|
|
220d82c01e | ||
|
|
1aa9a84e96 | ||
|
|
25848a24c1 | ||
|
|
972757ed75 | ||
|
|
159c1b271d | ||
|
|
888a4cb46b | ||
|
|
da07108a83 | ||
|
|
42be16a5a2 | ||
|
|
58fc3b4704 | ||
|
|
43835928bb | ||
|
|
7741f26eb4 | ||
|
|
d2a299a088 | ||
|
|
69796f9ed8 | ||
|
|
1f495d70f3 | ||
|
|
53823c6734 | ||
|
|
5dc1140bf3 | ||
|
|
51388780dc | ||
|
|
6e3da5774d | ||
|
|
a2e67e2521 | ||
|
|
795a71f54b | ||
|
|
da6abc665d | ||
|
|
1b11c966ff | ||
|
|
635c909426 | ||
|
|
8e2215f9f4 | ||
|
|
bb42586454 | ||
|
|
c1710d10d5 | ||
|
|
81ae0337b4 | ||
|
|
7d6f13d315 | ||
|
|
3efbc327f5 | ||
|
|
73bbaadd48 | ||
|
|
d74ef02d65 | ||
|
|
3a7b760dfc | ||
|
|
a8a4aae6b3 | ||
|
|
88f2c1066b | ||
|
|
3ea6c0c868 | ||
|
|
1c71ff5c24 | ||
|
|
31bc2d5480 | ||
|
|
97718bd3e6 | ||
|
|
2a8cfe6ea2 | ||
|
|
c4e6ed1a5d | ||
|
|
e818d22c65 | ||
|
|
7425c672b8 | ||
|
|
00319a6655 | ||
|
|
f22e043902 | ||
|
|
26019b39f4 | ||
|
|
3422bee5e1 | ||
|
|
7161ef6fe8 | ||
|
|
21c3ac18c2 | ||
|
|
eb152b201f | ||
|
|
a6b70c154a | ||
|
|
2b5b9c8d9e | ||
|
|
58af616ced | ||
|
|
0f5912b198 | ||
|
|
85f3535a1c | ||
|
|
844ac083b3 | ||
|
|
4c795ea6e0 | ||
|
|
f7ac1ddf49 | ||
|
|
346159210b | ||
|
|
0a9cce18b6 | ||
|
|
4246031ff9 | ||
|
|
4b336d7395 | ||
|
|
31db2d30a4 | ||
|
|
2bcfb95b05 | ||
|
|
c9fc0c25be | ||
|
|
a19f60c8b0 | ||
|
|
d6109a9b3a | ||
|
|
88feba6190 | ||
|
|
5232e431de | ||
|
|
2e40b85fd4 | ||
|
|
d751093bbd | ||
|
|
ec25f0e0e8 | ||
|
|
ca74a1dee3 | ||
|
|
0f52237286 | ||
|
|
173c227c2e | ||
|
|
0e7372efb7 | ||
|
|
67bb5bc231 | ||
|
|
3d3f3e2d9d |
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Синхронизация с автором
|
||||
|
||||
# Раз в сутки проверяем обновления у автора проекта и, если они есть, переносим наши правки
|
||||
# на свежий код и публикуем в ветку dev. Релиз и подпись здесь НЕ делаются: закрытый ключ
|
||||
# подписи лежит только на рабочей станции владельца.
|
||||
#
|
||||
# Ручной запуск: в Gitea → Actions → «Синхронизация с автором» → Run workflow.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 3 * * *" # каждую ночь в 03:30 по времени сервера Gitea
|
||||
workflow_dispatch: {}
|
||||
push:
|
||||
paths:
|
||||
- ".gitea/workflows/fork-sync.yml"
|
||||
|
||||
concurrency:
|
||||
group: fork-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Подтянуть обновления автора, проверить и опубликовать
|
||||
env:
|
||||
GITEA_URL: http://10.10.10.54:3000
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL "$GITEA_URL/hacker07/3X-UI_autoVPS/raw/branch/main/tools/fork-sync-bot.sh" -o /tmp/fork-sync-bot.sh
|
||||
bash /tmp/fork-sync-bot.sh
|
||||
@@ -18,16 +18,16 @@ jobs:
|
||||
run: python3 -m py_compile scripts/kit-sub.py
|
||||
- name: Генераторы
|
||||
run: node --test tools/test/*.test.js
|
||||
- name: Проверка копии для --restore (GNU grep и bash)
|
||||
run: |
|
||||
python3 tools/test/restore_env_test.py
|
||||
python3 tools/test/restore_archive_test.py
|
||||
- name: Версия в скриптах совпадает с VERSION
|
||||
run: |
|
||||
v=$(cat VERSION)
|
||||
grep -q "^KIT_VERSION=\"$v\"" scripts/3x-ui.sh
|
||||
grep -q "^KIT_VERSION=\"$v\"" scripts/hysteria2.sh
|
||||
grep -q "^KIT_VERSION=\"$v\"" scripts/kit.sh
|
||||
- name: Ключ подписи одинаковый в kit и hy2
|
||||
run: |
|
||||
signers() { awk '/^KIT_SIGNERS=\(/ {on = 1; next} on && /^\)/ {exit} on' "$1"; }
|
||||
diff <(signers scripts/kit.sh) <(signers scripts/hysteria2.sh)
|
||||
- name: Подпись релиза совпадает с файлами
|
||||
run: |
|
||||
[ -f SHA256SUMS ] || { echo "SHA256SUMS нет – релиз ещё не подписан"; exit 0; }
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
*.pyc
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
|
||||
+29
-33
@@ -1,44 +1,40 @@
|
||||
# Что нового
|
||||
|
||||
## v1.2: панель 3X-UI v3.9.0, меню `kit` и маскировка по соседям
|
||||
|
||||
* Панель 3X-UI обновлена до v3.9.0, а ядро Xray я оставил проверенным: новое не всегда лучше, это показал тест. `kit update --panel` обновит панель без смены ядра и настроек, а при сбое откатит всё назад.
|
||||
* `kit` без аргументов открывает меню, как у x-ui: нажали цифру – готово. Всё про сеть собрано в `kit net`: порты, протоколы, сайт маскировки, отпечаток. `kit check --deep` подключается к серверу настоящим клиентом, а `--fix` лечит безопасное.
|
||||
* Раздельная маршрутизация: `kit net split on` – и через VPN идёт только список популярных иностранных сервисов (нейросети, видео, соцсети, мессенджеры, GitHub, Google и другие), а всё остальное напрямую. Правила приезжают вместе с подпиской: Mihomo-приложения берут их из подписки, Happ – из заголовка, приложения на JSON-подписке Xray – из конфига. Список лежит в `/etc/kit-sub/rules.yaml`, проверка – `kit net split check`, после правки – `kit net split apply`. У v2rayN, Karing и Hiddify подписка прежняя. Спасибо [VasilevVitalii](https://github.com/VasilevVitalii) за идею и первый набросок.
|
||||
* Маскировка: у каждого сервера теперь своя заглушка, она собирается при установке из двадцати тем и пяти вёрсток со случайным названием и цветами (пекарня, переводчик или мастерская по велосипедам – как повезёт). Со своим доменом на сервере стоит ваш сайт, без него – сайт среди соседей по подсети. REALITY теперь с `xtls-rprx-vision`, а Hysteria2 отвечает на чужой запрос той же страницей, байт в байт.
|
||||
* Подписка и переезд: ссылка подписки в браузере открывается страницей с трафиком, сроком и кнопками «открыть в приложении», в Clash есть «Авто», а общий лимит считается по всем подключениям пользователя. Со своим доменом панель и подписку можно открывать по домену: `--panel-on domain` при установке или `kit net panel domain` потом. Вернулся `--restore`: чистый сервер поднимается из `kit backup`, как из резервной копии настроек телефона. Если Let's Encrypt не выдал сертификат на новый IP, я ставлю самоподписанный и честно об этом говорю. Спасибо [xlok1e](https://github.com/xlok1e) за исправление времени на Debian 13.
|
||||
* Исправил то, что спотыкалось: `kit backup` теперь работает из любой папки, `kit net masq on` не роняет Hysteria2, JSON-подписка снова открывается в приложениях на Xray, а `kit check` смотрит, запущено ли ядро и слушает ли каждое подключение свой порт.
|
||||
* Отдельный установщик Hysteria2 ушёл на пенсию: он и так входит в основную установку. Кто ставил его отдельно – сервер работает как работал, а `hy2 update` расскажет, что дальше. Нужен один Hysteria2 без панели – официальный установщик: `bash <(curl -fsSL https://get.hy2.sh/)`.
|
||||
|
||||
Сервер обновится сам этой ночью, нетерпеливым – `kit update`.
|
||||
|
||||
## v1.1.2: исправление установки и мелкие починки
|
||||
|
||||
* Исправил «Ошибка в строке 452»: установка спотыкалась о собственную проверку, как человек о порог в тёмной комнате. Теперь идёт ровно. Спасибо [ram0973](https://github.com/ram0973), который сообщил первым, и [xlok1e](https://github.com/xlok1e) за точную правку.
|
||||
* `ufw` больше не запирает вас снаружи на нестандартном порту SSH (сервер, закрытый от хозяина, – так себе охрана). Имя с точкой не цепляет чужих, а `kit fix` и откат обновления доводят дело до конца.
|
||||
* Параметры прощают `--port=8443` и `VPN.Example.COM`: я перестал придираться к регистру.
|
||||
|
||||
Сервер обновится сам этой ночью, нетерпеливым – `kit update`.
|
||||
|
||||
## v1.1.1: kit check и kit fix, исправления для Happ и скачивания ядра
|
||||
|
||||
**Новое**
|
||||
|
||||
* `kit check` и `kit fix`. Завёл для сервера врача. `kit check` осматривает службы, сертификат, подписку, права на файлы и сайт маскировки и честно говорит, что болит, а `kit fix` лечит только то, что безопасно лечить: перезапустит упавшее, вернёт права, включит автообновление. Никаких операций без наркоза. Заодно он спасает подписку, которая молча умирала после `x-ui cert`: она вела себя как кот, который делает вид, что вас не знает.
|
||||
* Инструкция для XKeen прямо в README. Восемь шагов от компонентов роутера до проверки, со скриншотами, чтобы не собирать её по десяти вкладкам. Автору XKeen огромное спасибо: всю тяжёлую работу сделал он, я только собрал шпаргалку.
|
||||
|
||||
**Исправлено**
|
||||
|
||||
* Маршрутизация Happ доходит до клиентов. `kit-sub` пропускал только свой короткий список заголовков, поэтому профиль в Happ просто не появлялся. Спасибо [xlok1e](https://github.com/xlok1e): он нашёл причину и прислал исправление, мне осталось только вежливо принять его и проверить.
|
||||
* Ядро Xray ставится, даже когда GitHub тормозит или молчит. Запасной путь идёт через зеркало, но только с проверкой SHA256: чужой бинарник не пройдёт, зеркалу я не верю ни на слово.
|
||||
* Установка со своим доменом больше не обрывается, если Let's Encrypt отказал в сертификате на IP (лимит выдачи, закрытый порт 80, сбой). Я объясню причины, спрошу «продолжить с самоподписанным сертификатом? [Y/n]» и честно предупрежу: ссылки на подключения работают, а подписка в приложениях может не открыться. Без драмы.
|
||||
* Подтянул мелочи по итогам проверки: права на ключ запасного сертификата, ожидание замка `dpkg` при установке `unzip` и защиту заголовков подписки от лишних символов.
|
||||
|
||||
Если у вас 1.1, сервер обновится сам этой ночью. Хотите сразу: `kit update`.
|
||||
* `kit check` осматривает сервер, как терапевт: «где болит?». `kit fix` лечит безопасное, без наркоза и без героизма.
|
||||
* Заголовки Happ доходят до клиентов, а не теряются по дороге, как носки в стиралке. Спасибо [xlok1e](https://github.com/xlok1e) за находку.
|
||||
* Xray ставится, даже когда GitHub молчит: через зеркало, но только с проверкой SHA256. Чужой бинарник не пройдёт, я никому не верю.
|
||||
* Со своим доменом установка не рвётся, если Let's Encrypt отказал в сертификате на IP. Инструкция для XKeen теперь в README.
|
||||
|
||||
## v1.1: безопасность, автообновление и новый генератор для роутера
|
||||
|
||||
**Новое**
|
||||
* Обновления приходят сами по ночам и только с моей подписью: «мам, удали старый VPN» больше не нужно. Не любите сюрпризы: `kit update --manual`. Копия сервера: `kit backup`.
|
||||
* Маскировка на выбор: чужой сайт или свой домен. На случайный заход сервер изображает кофейню, фотографа или блог про Linux.
|
||||
* Один генератор для роутера вместо двух: Keenetic и OpenWrt, Xray или Mihomo. Меньше вкладок, меньше «а я точно там нажал?».
|
||||
* Безопасность: установщики проверяются по SHA256 (подмену, как незнакомца с «выгодным предложением», не пускаем), Hysteria2 не исполняет почту как команду, `kit-sub` больше не root.
|
||||
|
||||
* Обновления приходят сами. Раз в сутки ночью сервер проверяет мою подпись и обновляет `kit`, а пользователи и ссылки остаются прежними. «Мам, удали старый VPN, вот новый» больше не нужно. Подпись не сошлась или подписка замолчала: откат. Не любите сюрпризы? `kit update --manual`.
|
||||
* Копия сервера одной командой. `kit backup` складывает подключения, ключи и пользователей в один файл.
|
||||
* Маскировка на выбор. Установщик спрашивает: стандартный чужой сайт (работает сразу) или свой домен (надёжнее: настоящая страница и сертификат Let's Encrypt). Без вопросов: `--domain vpn.example.com`. Под какой сайт будет выглядеть сервер, теперь решаете вы, а не скрипт за вас.
|
||||
* Заглушка с характером. На случайный заход сервер показывает кофейню, фотографа или блог про Linux. Тысячи серверов 3X-UI KIT больше не выглядят как близнецы.
|
||||
* Один генератор для роутера вместо двух. Выбираете Keenetic или OpenWrt, ядро Xray или Mihomo, вставляете ссылки и получаете конфиг плюс команду, которая сама положит его на роутер. Меньше вкладок, меньше «а я точно там нажал?». Старые адреса генераторов продолжают работать.
|
||||
* OpenWrt через Nikki (бета). Генератор собирает профиль Mihomo и команду, которая включит его и перезапустит Nikki. Xray на OpenWrt я не предлагаю: Nikki работает только на Mihomo, а кнопку, которая не заработает, лучше не рисовать.
|
||||
|
||||
**Исправлено**
|
||||
|
||||
* Установщик 3X-UI сверяется по SHA256. Если его кто-то подменит, скрипт посмотрит на него как на незнакомца в чате с «выгодным предложением» и запускать не станет. Файлы я теперь беру из тега версии, а не из `main`.
|
||||
* Hysteria больше не проверяется по справке, которую сама же принесла. Контрольная сумма записана прямо в скрипте. Проверять человека по его же рекомендательному письму – так себе идея.
|
||||
* Hysteria2 перестала исполнять почту. Особо изобретательный адрес раньше мог выполниться как команда. Теперь `me@gmail.com;rm -rf` получит вежливый отказ.
|
||||
* Подписка больше не root. Даже если `kit-sub` взломают, ключей от сервера у него нет. Пусть попробует открыть хоть одну дверь.
|
||||
|
||||
**Как обновиться с 1.0** (один раз, дальше само)
|
||||
|
||||
`curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update`
|
||||
|
||||
Hysteria2: `hy2 update`. Новые установки получают всё сразу.
|
||||
С 1.0 один раз: `curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update`
|
||||
|
||||
## v1.0
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
## v1.2.1.6: доступы к панели в конце установки и рабочая заглушка
|
||||
|
||||
* **Исправлено: доступы к панели не выводились.** Автор пишет строки в `/root/3x-ui.txt` без ведущих
|
||||
пробелов, а наш фильтр требовал два пробела — отсюда «данных панели нет». Теперь адрес панели, логин и
|
||||
пароль печатаются в конце установки (и в пункте меню «9. Доступ к панели, ссылки и заглушка»).
|
||||
Рядом указывается, что подписка и ссылки по протоколам лежат в том же файле.
|
||||
* **Исправлено: наша заглушка не готовилась до установщика.** Обёртка скачивала из релиза только три файла,
|
||||
а `scripts/kit-local.sh` (там функция страницы) не качала — падало с «kl_site_html: command not found».
|
||||
Теперь качаются все файлы релиза, и каждый сверяется с подписанным списком (проверка целостности стала
|
||||
полной, а не только для установщика).
|
||||
|
||||
## v1.2.1.5: доступ к панели, ссылки и заглушка — одним пунктом меню
|
||||
|
||||
* **В меню kit пункт «9. Доступ к панели, ссылки и заглушка»** — открывает одним экраном: адрес панели,
|
||||
логин и пароль (из /root/3x-ui.txt), состояние сертификата панели (доверенный на IP или
|
||||
самоподписанный, с датой повтора при лимите Let's Encrypt), ссылки на подключения с QR-кодом и что
|
||||
сейчас стоит заглушкой. Раньше доступ к панели в меню не показывался вообще, а про заглушку было
|
||||
непонятно, как её посмотреть.
|
||||
* Меню перенумеровано: «10. Панель x-ui», «11. Удалить KIT с сервера» (логика удаления — авторская).
|
||||
* То же самое доступно из консоли: `kit local access [имя]`.
|
||||
|
||||
## v1.2.1.4: короткий итог установки, автовыбор в общедоступных сайтах, команда про порты
|
||||
|
||||
* **Итог установки вместо простыни текста** — компактный блок: сборка, сервер, протоколы, состояние
|
||||
сертификата панели, одна строка проверки. Дальше только то, что нужно человеку: доступ к панели (адрес,
|
||||
логин, пароль — по просьбе из ранних правок), указатель на меню kit для ссылок и QR, открытые порты и
|
||||
команда для закрытия лишних. Длинные отчёты (состояние защиты, полный вывод kit check) убраны из
|
||||
установки — они остались доступны командами, в том числе `kit local hardening`.
|
||||
* **Ссылки на подключения больше не попадают в вывод установки даже косвенно**: раньше печатался весь
|
||||
файл /root/3x-ui.txt, а в нём лежат и ссылки vless://. Теперь из него берутся только строки панели —
|
||||
адрес, логин, пароль.
|
||||
* **В меню общедоступных сайтов последним пунктом добавлен автовыбор** (раньше можно было только выбрать
|
||||
номером или вернуться).
|
||||
* **Новая команда `kit local ports`** — что открыто в ufw, что реально нужно (порты подключений и SSH),
|
||||
что лишнее; `kit local ports --close-extra` закрывает лишнее.
|
||||
* Ошибка подготовки нашей заглушки теперь печатает причину, а не просто «не смог».
|
||||
|
||||
## v1.2.1.3: ссылки и QR — в меню kit; сертификат панели видно в итоге
|
||||
|
||||
* **Ссылки для подключения и QR-код больше не печатаются в выводе установки.** Их смотрят позже, когда
|
||||
настраивают телефон, и они всегда доступны заново: `kit → Пользователи → Показать ссылки`. В итоговом
|
||||
блоке вместо них — короткий путь к этому пункту и команды `kit local links` (подписка и QR) и
|
||||
`kit user link <имя> --all` (все протоколы). Доступы к панели (адрес, логин, пароль) по-прежнему
|
||||
в конце установки: их вы просили показывать один раз и сразу.
|
||||
* **Добавлена команда `kit local links`** — подписка, QR-код и ссылки по протоколам одной командой;
|
||||
пользователя можно указать, иначе берётся первый.
|
||||
* **В итоговом блоке видно состояние сертификата панели.** Панель и подписка висят на IP, поэтому
|
||||
печатается: «Let's Encrypt на IP — доверенный, браузер не ругается» либо «самоподписанный» с датой,
|
||||
после которой Let's Encrypt выпустит сертификат на IP (тот же лимит, что и для домена).
|
||||
|
||||
## v1.2.1.2: сертификат на IP — с датой повтора; «без домена» продолжает установку
|
||||
|
||||
* **Отказ Let's Encrypt на IP теперь объясняется честно.** Как и для домена, при лимите в выводе дата,
|
||||
после которой выпуск возможен, и прямо сказано, что порт 80 и файрвол тут не при чём. Раньше печатались
|
||||
«частые причины» и человек шёл проверять хостера.
|
||||
* **Пункт «без своего домена» больше не останавливает установку.** При отказе с сертификатом домена выбор
|
||||
«3» продолжает работу сразу: маскировка берётся из соседей по подсети (или известный сайт, если соседей
|
||||
нет), панель и подписка переключаются на IP, дальше установка идёт своим ходом. Перезапускать скрипт
|
||||
и заново отвечать на вопросы не нужно.
|
||||
|
||||
## v1.2.1.1: сборка на новой базе автора (3X-UI KIT 1.2.1)
|
||||
|
||||
**Правило нумерации:** берём версию автора и через точку добавляем свою, начиная с 1. У автора 1.2.1 —
|
||||
значит наша сборка 1.2.1.1; когда выйдет его 1.2.2, наша будет 1.2.2.1.
|
||||
|
||||
**Взяли от автора (его код теперь работает вместо наших заплаток):**
|
||||
* проверка A-записи домена сверяется с **публичным DNS**, а не с кэшем самого сервера — та самая причина,
|
||||
когда запись уже есть, а сервер её «не видит»;
|
||||
* пропал наш шов, открывавший порт 80: автор делает это сам перед выпуском сертификата **и закрывает обратно**;
|
||||
* пропал наш шов переиспользования сертификата: автор тоже берёт прошлый;
|
||||
* повторный запуск после неудачной установки: автор заново спрашивает протоколы и сайт маскировки и не
|
||||
оставляет сервер на полпути — прежнее наше «доустанавливаю» больше не нужно;
|
||||
* полное удаление: наш `kit local uninstall` теперь **делегирует авторской команде `kit uninstall`**
|
||||
(она снимает и сам kit) и остаётся синонимом;
|
||||
* меню `kit` — авторское (в том числе «9. Панель 3X-UI (меню x-ui)», «10. Удалить KIT с сервера»);
|
||||
* попутно приехали его починки: Hiddify 4.x снова принимает подписку, ссылка MTProto ведёт на 443, смена
|
||||
сайта маскировки не обрывает клиентов (прежнее имя живёт неделю), `nginx -t` в проверке сервера,
|
||||
устойчивость к битому apt-репозиторию, длинные имена сайта в nginx, проверка копии (в т.ч. U+202E),
|
||||
честная глубокая проверка, предупреждение «обновлять панель и ядро только через kit».
|
||||
|
||||
**Сохранили своё:**
|
||||
* наш файл `scripts/kit-local.sh` и наша обёртка установщика — автор их не трогает;
|
||||
* наш канал обновлений (GitHub → наш Gitea) и **только наш ключ подписи** в `KIT_SIGNERS`: сервер обязан
|
||||
принимать обновления лишь из нашего канала;
|
||||
* наш экран выбора протоколов (13 пунктов, дефолт REALITY + AmneziaWG-3.1) — теперь номера вводятся
|
||||
**через пробел** («1 12»), как вы просили; запятые по-прежнему принимаются;
|
||||
* наши меню маскировки REALITY и XHTTP/MTProto, вопрос «панель по IP или по домену», возврат «ввести
|
||||
другой домен» без обрыва установки;
|
||||
* наша заглушка ставится сразу, авторская не устанавливается;
|
||||
* финальный блок: доступы, подписка и QR печатает наш установщик один раз, в самом конце (авторский дубль
|
||||
«Готово! Сервер работает» убран, предупреждения остаются).
|
||||
|
||||
**Технически:** из 77 прежних швов 66 легли без изменений, 8 больше не нужны (их работу делает автор),
|
||||
3 перенастроены на его новый код (ключ подписи, защита наших меню, финальный блок).
|
||||
|
||||
## v1.2.27: ошибка с доменом больше не обрывает установку
|
||||
|
||||
* **Свой домен вводится заново, пока не заработает.** Раньше при промахе (A-запись ещё не разошлась
|
||||
по DNS, опечатка в имени) установка молча маскировалась под чужой сайт, а при домене из флага —
|
||||
обрывалась с «исправьте причину и запустите скрипт снова». Теперь сразу после ввода домена идёт
|
||||
проверка и при промахе — выбор: «проверить снова» (DNS расходится от минут до часов), «ввести другой
|
||||
домен», «без своего домена — маскировка соседом по подсети».
|
||||
* **Провал сертификата на шаге подключений не прерывает установку.** Предлагается повторить попытку
|
||||
(пока ждали обновления DNS или лимит Let's Encrypt), ввести другое имя (с той же проверкой A-записи)
|
||||
или остановиться с понятной инструкцией, как поставить без домена. Причина и дата повтора при лимите
|
||||
печатаются в самом отказе, лог остаётся в /var/log/kit-domain-cert.log.
|
||||
|
||||
## v1.2.26: лимит Let's Encrypt виден прямо, готовый сертификат переиспользуется
|
||||
|
||||
* **Причина отказа теперь называется честно.** Let's Encrypt разрешает 5 сертификатов на точный набор имён
|
||||
за 7 суток; при повторных установках лимит выбирается, и acme.sh отвечает `rateLimited`, а установщик
|
||||
писал про «A-запись, порт 80 или Cloudflare» — то есть отправлял искать причину не там. Теперь в выводе
|
||||
дата, после которой повтор сработает, и подсказка взять другое имя (`node3.…` — лимит считается на набор имён).
|
||||
* **Готовый сертификат переиспользуется.** Если у acme.sh уже лежит годный сертификат на этот домен,
|
||||
новый не заказывается — лишний заказ и упирался в лимит. Заодно не тратится время на повторные попытки.
|
||||
* Итоговая ошибка установщика больше не повторяет догадки про порт и DNS: причина печатается выше,
|
||||
а лог остаётся в /var/log/kit-domain-cert.log.
|
||||
|
||||
## v1.2.25: вопрос про панель возвращён, блок про защиту переписан понятнее
|
||||
|
||||
* **Снова спрашиваем, где открывать панель и подписку.** У автора этот вопрос задавался только когда
|
||||
домен передан флагом `--domain`; при выборе «ввести свой домен» в меню маскировки его не было, и панель
|
||||
молча оставалась по IP с самоподписанным сертификатом — то самое «панель на это ругается». Теперь:
|
||||
выбрали свой домен → сразу вопрос «По IP (как раньше) или По домену» (`https://домен/…`, сертификат
|
||||
Let's Encrypt, браузер не ругается).
|
||||
* В отчёте защиты блок «чего здесь нет» больше не ссылается на сторонний проект без пояснений: сказано,
|
||||
что именно сделано мягче (без полного обновления системы, без автоперезагрузки, без общесистемного
|
||||
запрета ICMP) и почему — панель и ядро закреплённых версий, сервер не должен вставать сам.
|
||||
|
||||
## v1.2.24: сертификат идёт вслед за выбором «домен или IP»
|
||||
|
||||
* Выбрали в меню маскировки **свой домен** — панель и подписка тоже переезжают на домен:
|
||||
`https://ваш.домен/...` и открываются тем же сертификатом Let's Encrypt, без предупреждений браузера.
|
||||
Раньше сертификат выписывался только для маскировки, а панель оставалась по IP с самоподписанным —
|
||||
отсюда ругань и в панели, и в подписке.
|
||||
* Если выбрали соседа или общедоступный сайт (своего домена нет) — панель остаётся по IP, и в итоговом
|
||||
блоке прямо сказано: сертификат самоподписанный, браузер предупредит; перейти на домен потом —
|
||||
`kit net panel domain`.
|
||||
* Домен, который не указывает на этот сервер, используется только как маска (и об этом сообщается),
|
||||
панель в этом случае остаётся по IP.
|
||||
|
||||
## v1.2.23: чистый вывод — без дублей доступов, без авторской заглушки, без лишних ссылок
|
||||
|
||||
* **Убран дублирующий блок автора.** Он печатал «Готово! Сервер работает» с адресом панели, логином,
|
||||
паролем, подпиской, QR-кодом и ссылками — а потом то же самое выводил наш финальный блок.
|
||||
Теперь авторский блок заканчивается строкой «Установка завершена» и списком предупреждений;
|
||||
доступы и подписка печатаются один раз, в самом конце.
|
||||
* **Авторская заглушка больше не ставится.** Наш сайт кладётся до установщика, а его страница из
|
||||
релиза убирается — иначе она перезаписывала нашу на шаге сборки сайта. Заодно пропадает лишний
|
||||
шаг «поставить поверх» и его бэкап.
|
||||
* **Ссылки по протоколам — только если протоколов больше одного.** При одном REALITY печатается
|
||||
подписка и QR-код, без списка «отдельные ссылки на каждый протокол».
|
||||
* Самопроверка в конце установки больше не перечисляет чужие протоколы («REALITY, XHTTP и
|
||||
Hysteria2») — пишет те, что реально созданы. Предупреждение про XHTTP/MTProto показывается только
|
||||
если эти протоколы выбраны.
|
||||
|
||||
## v1.2.22: 80 в ufw открывается прямо перед выпуском сертификата
|
||||
|
||||
* Настоящая причина сбоя сертификата (проверено на живом сервере): установщик открывает список
|
||||
портов в ufw **позже**, чем пробует выпустить сертификат. На свежем сервере ufw в этот момент
|
||||
разрешает только 22, поэтому проверка Let's Encrypt доходила до сервера и «проглатывалась» —
|
||||
в логе acme `Timeout during connect (likely firewall problem)`. Провайдер тут ни при чём:
|
||||
с настоящим слушателем 80 и 443 отвечают снаружи нормально (проверено в том числе с другого сервера).
|
||||
* Теперь 80/tcp открывается в ufw непосредственно перед `issue_domain_cert`, а формулировка ошибки
|
||||
в установщике исправлена: сначала проверяем ufw, потом хостера.
|
||||
|
||||
## v1.2.21: порты до выпуска сертификата и понятный блок про SSH-порт
|
||||
|
||||
* **Порты до сертификата.** Установщик теперь сам открывает 80 и 443 в ufw перед работой и прямо
|
||||
предупреждает, что такие же правила нужны в файрволе хостинга: частая причина срыва — порт 80
|
||||
закрыт именно у провайдера, а не в сервере. Если сертификат всё же не выпустился, в выводе
|
||||
разбирается причина: при `Timeout during connect (likely firewall problem)` пишется, что делать
|
||||
(открыть 80 в панели хостинга) и предлагается вариант без своего домена (тогда сертификат домена
|
||||
не нужен, панель остаётся на самоподписанном). После установки 80 и 443 в ufw проверяются ещё раз.
|
||||
* **Смена порта SSH — с понятным завершением.** Прежние порты запоминаются до смены, и в конце
|
||||
печатается блок: новый порт, какие прежние ещё открыты, команда проверки входа во втором окне и
|
||||
готовая команда `kit local ssh --close-old <новый>` для закрытия прежних. Строка про SSH-порт
|
||||
добавлена и в итоговый блок с доступом к панели и подпиской.
|
||||
|
||||
## v1.2.20: после сноса меню не показывается, и нет ошибок про latest-version
|
||||
|
||||
* Удаление из меню (`kit`, пункт 9) теперь завершается сразу: печатается «Готово: сервер в состоянии
|
||||
до установки» — и выход. Раньше меню перерисовывалось на уже удалённом сервере и сыпало
|
||||
`touch: cannot touch '/etc/kit/latest-version'`.
|
||||
* `update_hint` больше не пытается что-либо писать, если каталога `/etc/kit` уже нет.
|
||||
* Входит исправление 1.2.19: `extra` объявлена пустой (иначе `set -u` валил установку на шаге
|
||||
«[6/6] Проверка» сообщением `extra: unbound variable`).
|
||||
|
||||
## v1.2.19: исправлено «extra: unbound variable» при установке
|
||||
|
||||
* На середине установки (после проверки протоколов) скрипт падал: `extra: unbound variable`.
|
||||
Причина — моя правка v1.2.13: присваивание ссылок legacy-подписок завёрнуто в `if`, а переменная
|
||||
объявлялась как `local extra` без значения. Авторский скрипт работает с `set -u`, поэтому при
|
||||
невыполненном условии (AWG и MTProto не выбраны) чтение `$extra` валило установку. Объявлена пустой.
|
||||
* Правило записано в FORK.md: оборачивая авторское присваивание в `if`, объявляй переменную пустой —
|
||||
`local extra=""`, иначе `set -u` обрывает установку на середине.
|
||||
|
||||
## v1.2.18: выбранный «свой домен» больше не подменяется соседом
|
||||
|
||||
* Что было: после выбора «ввести свой домен» установщик всё равно брал запасной сайт из подсети.
|
||||
Две причины, обе в моём коде: (1) выбор перезаписывался сырым значением меню (`own:домен`)
|
||||
вместо самого домена; (2) домен проверялся на «TLS 1.3 + HTTP/2» — а до установки на нём ещё нет
|
||||
сертификата, поэтому проверка честно не проходила и срабатывал откат на соседа. Теперь для своего
|
||||
домена проверка TLS не делается (он указывает на этот сервер — установщик сам выпустит
|
||||
сертификат Let's Encrypt), а автоподбор и запасной сайт включаются только если вы НЕ выбрали
|
||||
ничего (автовыбор).
|
||||
|
||||
## v1.2.17: меню маскировки идёт ПЕРВЫМ (меню автора больше не перебивает)
|
||||
|
||||
* Причина, по которой меню не было видно: **меню спрашивал сам установщик** («Свой домен / Сосед /
|
||||
Свой сайт») — и до нашего скана подсети дело не доходило. Теперь, когда включено наше меню,
|
||||
меню автора не показывается вообще: сразу скан, список найденных доменов и выбор — из найденных,
|
||||
автовыбор, общедоступный или свой домен. Отключить и вернуть поведение автора: `--auto-sni`.
|
||||
* Ответ меню помечается: если вы ввели домен, который указывает на этот сервер, маскировка идёт
|
||||
«под свой домен» (сертификат Let's Encrypt выпустит установщик); если не указывает — маскируюсь
|
||||
под него как под чужой сайт и прямо об этом сообщаю.
|
||||
* Входит исправление 1.2.16: возвращён `ask_yes` (в 1.2.13–1.2.15 при установке печаталось
|
||||
`ask_yes: command not found`, и значения брались по умолчанию) + гейт «определение раньше вызова».
|
||||
|
||||
## v1.2.16: возвращён ask_yes (вылезало «command not found» при установке)
|
||||
|
||||
* При переносе вопросов наверх (v1.2.13) потерялось определение функции `ask_yes` — установщик
|
||||
писал `ask_yes: command not found` на двух строках и молча брал значения по умолчанию:
|
||||
ping оставался открытым, автообновления выключались. Скрипт при этом не падал, поэтому
|
||||
ошибку было видно, но установку она не срывала.
|
||||
* Почему не поймали: гейт в релизе прогонял только `--dry-run`, а он выходит ДО вопросов.
|
||||
Добавлена статическая проверка: каждая наша функция должна быть определена раньше первого
|
||||
вызова (`ask_yes`, `ask_ssh_port`, `ask_protocols`, `interactive`, `clean_previous` и др.).
|
||||
|
||||
## v1.2.15: меню выбора сайта в вашем виде, быстрый скан, снос с добивкой
|
||||
|
||||
* Меню маскировки выглядит так, как и просили. Если соседи нашлись — их список по номерам,
|
||||
затем `автовыбор из списка`, `выбрать общедоступный` (отдельным списком) и `ввести свой домен`.
|
||||
Если в подсети ничего не нашлось — сразу «общедоступный» или «свой домен» (ничего лишнего).
|
||||
Enter — автовыбор. Общедоступные сайты предлагаются и для REALITY, и для XHTTP/MTProto.
|
||||
* **Скан подсети стал быстрым.** Было: адреса проверялись по 8 сразу, а найденные имена —
|
||||
по очереди (до 40 имён × три проверки с сетевыми запросами ≈ минуты). Стало: 32 адреса сразу
|
||||
и имена проверяются параллельно по 8; таймаут проверки адреса 3 с вместо 4.
|
||||
* В выводе видно, сколько соседей нашлось (раньше было не понять, работает ли скан).
|
||||
* После нашего меню автор больше не спрашивает «продолжить с запасным сайтом?» — выбор уже сделан.
|
||||
* В меню `kit`: **9 — «Удалить всё установленное», 10 — «Выход»** (меню панели `x-ui` переехало
|
||||
в пункт 11).
|
||||
* **`kit local uninstall` доводит дело до конца:** после явного списка идёт добивка по шаблонам
|
||||
(`/etc/kit*`, `/usr/local/lib/kit*`, юниты `kit-*`/`x-ui*`, `/var/www/kit`, сертификаты), повторная
|
||||
проверка и внятный итог. Появился флаг `--yes` — снести без вопроса.
|
||||
|
||||
## v1.2.14: выбор сайта маскировки по результатам скана + снос, который не обрывается
|
||||
|
||||
* **Появилось меню маскировки.** Сначала один скан подсети (~250 обращений к 443 соседних адресов),
|
||||
потом выбор: номер из найденного, `0` — ввести адрес вручную, Enter — автоподбор. Отдельно для
|
||||
**REALITY** (найденное или свой) и отдельно для **XHTTP/MTProto** (найденное, свой или известный
|
||||
сайт — список известных расширен Microsoft, AMD, Intel, NVIDIA). Если XHTTP/MTProto не выбраны,
|
||||
второго меню не будет. Отключить меню: `--auto-sni`.
|
||||
* **Исправлено: `kit local uninstall` обрывался на середине.** `kit` запускает наши команды
|
||||
с `set -e`, и подсчёт чужих сайтов nginx (`find | grep | wc -l`, где grep не находит ничего и
|
||||
возвращает 1) убивал скрипт сразу после остановки служб — файлы и `/usr/local/bin/kit` оставались,
|
||||
поэтому `kit help` продолжал работать. Теперь снос выполняется с отключённым `set -e` и все
|
||||
подобные конвейеры защищены; проверка после сноса это подтверждает списком, если что-то осталось.
|
||||
## v1.2.13: вопросов после показа доступов нет, и никакой работы для невыбранных протоколов
|
||||
|
||||
* **Все вопросы — до установщика.** Раньше после установки (когда на экране уже были данные входа
|
||||
в панель) скрипт спрашивал про ping, автообновления, порт SSH и пароль root — доступы терялись
|
||||
в потоке вопросов. Теперь спрашиваем всё заранее, а действия выполняем после установки; последнее,
|
||||
что видит человек, — доступ к панели, подписка и QR-код.
|
||||
* Автор тянет подписки «-awg» и «-tg» (наследие установок kit < 1.1) всегда — теперь только если
|
||||
AmneziaWG или MTProto выбраны.
|
||||
* Наши команды помнят выбор: установщик пишет список протоколов в `/etc/kit/kl-protos`, и
|
||||
`kit local site` не синхронизирует ответ Hysteria2, если Hysteria2 не ставился (раньше он это делал
|
||||
и выдавал предупреждение про отсутствующий Hysteria2).
|
||||
## v1.2.12: не сканируем соседей, когда они не нужны
|
||||
|
||||
* Автор подбирал сайты-соседи для XHTTP и MTProto **всегда**, когда маскировка доверенная («панель по IP»)
|
||||
и всё висит на 443 — независимо от того, выбраны ли эти протоколы. При установке одного REALITY это
|
||||
давало ~250 обращений к чужим адресам впустую. Теперь скан идёт только если XHTTP или MTProto выбраны;
|
||||
иначе имена SNI2/SNI3 берутся из общего списка сайтов автора (как и раньше при неудаче скана).
|
||||
|
||||
# Что нового в сборке форка
|
||||
|
||||
Здесь записи о наших версиях. Файл автора (`CHANGELOG.md`) мы не трогаем — так обновления
|
||||
автора не конфликтуют с нашими записями.
|
||||
## v1.2.11: снос знает про kit-limit и находит свои службы сам
|
||||
|
||||
* В списке сноса не было `kit-limit.service` и `kit-limit.timer` (юнит «общий лимит трафика» из kit) —
|
||||
его создаёт установка, а `kit local uninstall` его не убирал. Из-за этого после сноса оставался
|
||||
работающий таймер, который раз в 5 минут дёргал уже удалённый `kit` и падал.
|
||||
* Теперь снос берёт список служб **и из явного перечня, и по шаблонам `x-ui*`/`kit*`** из systemd —
|
||||
новая служба, добавленная автором позже, не останется незамеченной. Проверка после сноса ищет
|
||||
по тем же шаблонам и показывает всё найденное.
|
||||
* Флаг `--clean` у установщика использует тот же список.
|
||||
## v1.2.10: убран мусор в выводе установщика
|
||||
|
||||
* В шапке `scripts/3x-ui-local.sh` один из флагов потерял `#` и **при каждом запуске печаталось
|
||||
`--dry-run: command not found`** (до `set -e` — потому и не роняло установку, но выглядело как ошибка).
|
||||
Исправлено, заодно выровнены строки справки.
|
||||
* В релизный гейт добавлена проверка, которая такое ловит: холостой прогон установщика, чей вывод в stderr
|
||||
содержит «command not found / syntax error / bad substitution», релиз не собирается.
|
||||
## v1.2.9: снос действительно убирает всё, повторная установка после сноса
|
||||
|
||||
* Список удаляемого в `kit local uninstall` собран заново **по коду установщика** и дополнен тем, чего
|
||||
не хватало: `/usr/local/lib/kit-sub`, `/root/kit-backup-*` и `/root/x-ui-before-*` (копии kit),
|
||||
`/var/log/kit-install-check.log`, `/usr/bin/x-ui.new`, строка `include kit-stream.conf` в `nginx.conf`,
|
||||
симлинки в `sites-enabled`/`streams-enabled`, файл автообновлений безопасности, пользователь `kit-sub`.
|
||||
Из-за пропущенных файлов установщик после сноса отказывался ставить заново («3X-UI уже установлена»).
|
||||
* После сноса печатается проверка **теми же условиями, что у установщика**, и список того, что осталось
|
||||
на диске — вместо бодрого «удалено» вслепую.
|
||||
* Флаг `--reset-ssh`: снести и наш ssh-дроп-ин (порт возвращается к системному). По умолчанию SSH не трогаем.
|
||||
* У установщика появился флаг **`--clean`**: убрать прошлую установку и поставить заново. Если установщик
|
||||
всё-таки упал на «уже установлена», в подсказке прямо сказано запустить с `--clean`.
|
||||
## v1.2.8: доступ к панели в конце установки и снос сервера из меню
|
||||
|
||||
* В конце установки (перед подпиской) печатается **доступ к панели 3X-UI**: адрес, логин и пароль из
|
||||
файла `/root/3x-ui.txt` — раньше это было видно только в общем потоке вывода установщика.
|
||||
* **`kit local uninstall`** — снос установки целиком: панель и служба `x-ui`, ядро Xray, службы и файлы
|
||||
kit (`kit-sub`, автообновление, cron), конфиги nginx и сайт-заглушка, сертификаты (acme.sh),
|
||||
наши правки системы (правило ICMP, обновления безопасности), наши бэкапы и файл с данными панели.
|
||||
Правила ufw для 80/443 снимаются, ответ на ping возвращается. Спрашивает подтверждение словом
|
||||
«удалить», есть `--dry-run`.
|
||||
**SSH не трогается** — ни порт, ни конфиги, ни правила для него: иначе легко потерять доступ к серверу.
|
||||
В главном меню `kit` появился пункт **10. Удалить всё установленное**.
|
||||
## v1.2.7: имя 3X-UI_autoVPS, справка kit help, подписка с QR, живые кнопки на заглушках
|
||||
|
||||
* **Сборка называется `3X-UI_autoVPS`** (было `3X-UI KIT`): баннер сервера — `Hacker 07` в том же
|
||||
стиле, новое имя в справке, версии, описаниях служб и в имени профиля в клиенте. В файлах автора
|
||||
это сделано швами (`tools/fork_apply.py`), поэтому обновление автора правки не затирает.
|
||||
* **Совместимость шапки релиза.** В подписанном `SHA256SUMS` этого релиза шапка пишется **двумя
|
||||
строками** (старая и новая), а проверка на сервере принимает обе. Иначе уже установленные серверы
|
||||
отказались бы ставить обновление: они сверяют точный вид строки. Со следующего релиза останется
|
||||
только новое имя.
|
||||
* Убрано дублирование подсказки «Дальше обязательно» при смене порта SSH: она печаталась и
|
||||
установщиком, и командой `kit local ssh`.
|
||||
* **`kit help` — полный справочник по категориям**: пользователи, подключения и порты, маскировка и
|
||||
сайт, проверка и починка, обслуживание и обновление, защита сервера (наш слой). Раньше `kit help`
|
||||
показывал короткий список автора и не упоминал наши команды. Чтобы справка не отставала молча,
|
||||
добавлена проверка `tools/check-help.sh`: она сверяет справку с диспетчером команд и встроена в
|
||||
сборку релиза (`tools/release.sh`).
|
||||
* У сайтов-заглушек все ссылки и кнопки **живые**: нажатие открывает окно «раздел в разработке,
|
||||
приносим извинения» (закрыть — кнопкой, кликом мимо окна или Esc). Никаких внешних запросов:
|
||||
скрипт и стили внутри страницы.
|
||||
* В самом конце установки (после всех проверок) печатается то, что нужно сохранить: **подписка
|
||||
с QR-кодом** и ссылки по протоколам — авторской командой `kit user link <имя> --all`, поэтому
|
||||
вывод совпадает с тем, что показывает `kit` потом. Отключается флагом `--no-sub`.
|
||||
|
||||
* Набор по умолчанию — **REALITY + AmneziaWG-3.1** (самый совместимый минимум); ответ «все»
|
||||
ставит полный набор автора. Умолчание задаётся в одном месте (`DEFAULT_PROTOS`).
|
||||
* **Сайт-заглушка переделан на ИТ-тематику:** десять правдоподобных сайтов (дата-аналитика, DevOps,
|
||||
мониторинг, разработка, аудит, ИТ-поддержка, колокейшн, заметки про Linux, бэкапы, документация)
|
||||
вместо авторских «кофеен и пекарен». Ставится случайный, конкретный — `kit local site 7`,
|
||||
список — `kit local site --list`. Команда обновляет и файл для nginx, и копию страницы внутри
|
||||
подключения Hysteria2 (`kit net masq on`): если менять файл вручную, 443/tcp и UDP-порт начнут
|
||||
отвечать разными страницами — это заметно снаружи.
|
||||
* Обёртка спрашивает, **какие протоколы ставить** (нумерованный список, `1,3,11` или «все»,
|
||||
Enter — набор автора): невыбранные протоколы не создаются вообще — ни подключения в панели,
|
||||
ни записей в nginx, ни ссылок в подписке. Список и умолчание взяты из самого установщика
|
||||
(`ALL_PROTOS`/`DEFAULT_PROTOS`), передаётся его же флагом `--protocols`, так что поведение
|
||||
не расходится с авторским. Свой `--protocols` можно задать флагом — тогда вопроса не будет.
|
||||
* Обёртка `scripts/3x-ui-local.sh` теперь спрашивает, а не решает за вас: закрывать ли ответ на ping,
|
||||
включать ли автообновления безопасности, **менять ли порт SSH** (Enter — оставить текущий,
|
||||
число — перевести на него; мусор и занятый порт переспрашивает), менять ли пароль root.
|
||||
Маскировку и домен, как и раньше, спрашивает установщик автора.
|
||||
* Смена порта SSH никогда не закрывает прежний порт сама: сначала добавляется новый, затем скрипт
|
||||
печатает две команды — проверить вход во втором окне и закрыть прежние (`kit local ssh --close-old`).
|
||||
* Запуск без флагов = «одна команда, дальше вопросы». Флаг `--yes` — наоборот, ничего не спрашивать
|
||||
(для автоматизации): ping закрывается, автообновления включаются, порт SSH и пароль не трогаются.
|
||||
* Флаг `--harden` теперь действительно существует (раньше он уходил установщику автора неизвестным
|
||||
параметром и ронял установку): закрыть ping и включить автообновления без вопросов.
|
||||
* `--dry-run` показывает, что именно будет спрошено или сделано по умолчанию.
|
||||
## v1.2.6: вопросы при установке
|
||||
|
||||
* **README.md переписан целиком:** убрано авторское описание с его командой установки, добавлены
|
||||
актуальная команда, флаги, таблица протоколов, список сайтов-заглушек, команды `kit` и `kit local`,
|
||||
канал обновлений и оговорки. Две картинки — наши (`manuals/assets/fork-banner.svg` и
|
||||
`manuals/assets/script-3x-ui-local.svg`, терминал собран из настоящего вывода скрипта).
|
||||
* Шов в README больше не нужен (`tools/fork_apply.py`): файл наш целиком, как `kit-local.sh`.
|
||||
## v1.2.5: суммы релиза в формате, который понимает kit
|
||||
|
||||
* Исправлена ошибка нашего релизного конвейера: на Windows `sha256sum` пишет суммы как
|
||||
`hash *файл`, а `kit` на сервере сверяет имя строго (`$2 == "файл"`) — то есть `kit update`
|
||||
отклонил бы релизы v1.2.1–v1.2.4 как «в подписанном списке нет такого файла». Теперь
|
||||
`tools/release.sh` приводит список к POSIX-виду (`hash файл`) **до** подписи.
|
||||
* `scripts/3x-ui-local.sh` принимает оба формата, чтобы не спотыкаться о суммы, собранные на
|
||||
другой машине.
|
||||
* Проверка теперь повторяет логику самого `kit`: подпись `SHA256SUMS` + строгая сверка имени
|
||||
каждого файла из подписанного списка (а не только `sha256sum -c`, который звёздочку прощает).
|
||||
## v1.2.4: проверка релиза отдельной командой
|
||||
|
||||
* Флаг `--verify-only`: скачать релиз, проверить подпись `SHA256SUMS` и SHA256 установщика и выйти —
|
||||
можно проверить канал на сервере, ничего не устанавливая.
|
||||
* Порядок шагов изменён: проверка подписи теперь идёт до предполётных проверок и до любых
|
||||
изменений на сервере, чтобы чужой код не запускался раньше проверки.
|
||||
## v1.2.3: установка одной командой (`scripts/3x-ui-local.sh`)
|
||||
|
||||
* Один скрипт на чистом VPS: проверка сервера → скачивание нашего релиза → проверка подписи
|
||||
`SHA256SUMS` и SHA256 самого установщика → установщик автора (панель 3X-UI, протоколы,
|
||||
сайт-заглушка, маскировка, сертификат, подписка, ufw) → наши дополнения → проверка.
|
||||
* По умолчанию досыпает: закрыть ответ на ping (правилом ufw), включить обновления безопасности,
|
||||
`kit fix` (время и безопасные починки), в конце `kit check` и сводку `kit local hardening`.
|
||||
Отключается флагами `--no-harden`, `--no-time`, `--no-check`.
|
||||
* Второй порт SSH — только по явному флагу `--ssh-port N`: прежний порт продолжает работать,
|
||||
закрытие — отдельным шагом `kit local ssh --close-old N`. Автоматически доступ не запираем.
|
||||
* Все флаги установщика автора (`--domain`, `--sni`, `--panel-on`, `--host`, `--restore` …)
|
||||
передаются как есть; есть `--dry-run` (план без изменений) и `--help`.
|
||||
* Публичный ключ форка вшит в обёртку — подпись проверяется до запуска чужого кода; при смене
|
||||
ключа обновляется и здесь, и в `kit`.
|
||||
## v1.2.2: команды защиты сервера (поведение из StressOzz/vps-setup)
|
||||
|
||||
* Новые команды `kit local`: `ssh <порт>` (добавить порт SSH), `ssh --close-old` (закрыть прежние
|
||||
порты), `ping off|on` (не отвечать на ICMP echo), `passwd [--generate]` (сменить пароль),
|
||||
`upgrades on|off|status` (автообновления безопасности), `hardening` (сводка по защите сервера).
|
||||
* Сделано безопаснее, чем в источнике: конфиг sshd проверяется через `sshd -t` **до** перезапуска,
|
||||
новый порт сначала открывается в ufw, прежний порт продолжает работать до отдельного шага
|
||||
`--close-old`, у правок есть `--dry-run`, а копии изменённых файлов остаются в
|
||||
`/root/kit-local-backup-<дата>/` (не в `/tmp`).
|
||||
* `apt full-upgrade` из источника не выполняется: вместо него включаются обновления безопасности
|
||||
(только security, без самовольной перезагрузки) — это не затрагивает закреплённые версии панели
|
||||
и ядра, которые ставятся не из apt.
|
||||
* Учтён Ubuntu 24.04: если SSH слушает systemd (`ssh.socket`), порт добавляется socket-drop-in'ом,
|
||||
а закрытие старых портов не делается автоматически — там ошибка запирает доступ к серверу.
|
||||
* Ping отключается правилом ufw (`before.rules`), а не общесистемным `icmp_echo_ignore_all`:
|
||||
видно в `ufw status`, отменяется одной командой и не глушит ICMP для внутренних интерфейсов.
|
||||
## v1.2.1: сборка форка поверх 3X-UI_autoVPS 1.2
|
||||
|
||||
* Источник файлов и обновлений — свой репозиторий Gitea: установка и `kit update` берут релиз
|
||||
оттуда, а подпись релиза проверяется ключом форка. Авторский ночной апдейт приходит в наш
|
||||
канал, поэтому наши доработки не стираются ночью.
|
||||
* Дополнения форка живут отдельным файлом `/usr/local/lib/kit/kit-local.sh` и подключаются одной
|
||||
строкой: `kit local help | version | channel | seams | masking`.
|
||||
* Наш файл едет в том же подписанном релизе, что и код автора: он входит в `SHA256SUMS`,
|
||||
проверяется по подписи и обновляется вместе с `kit`.
|
||||
* Швы в коде автора описаны в `tools/fork_apply.py` (источник правды), синхронизация с автором и
|
||||
проверки — `tools/fork-sync.sh`.
|
||||
|
||||
Основа — 3X-UI_autoVPS 1.2 (панель 3X-UI v3.9.0, ядро Xray v26.6.27). Что нового у автора — в `CHANGELOG.md`.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Форк 3X-UI_autoVPS (hacker07) — своя сборка и свой канал обновлений
|
||||
|
||||
Коротко: это [3X-UI_autoVPS](https://github.com/itsnotkubrick/3X-UI_KIT) со своими доработками. Наш код
|
||||
живёт рядом с кодом автора и не ломается, когда автор выпускает новую версию, а серверы ставятся и
|
||||
обновляются **из этого репозитория** — релизы подписываются нашим ключом.
|
||||
|
||||
## С чего начать: новый VPS
|
||||
|
||||
1. Если нужен свой домен для маскировки — заранее направьте его A-запись на IP сервера и освободите
|
||||
порт 80 (иначе Let's Encrypt не выпустит сертификат). Без домена этот шаг пропускается.
|
||||
2. Одна команда на сервере (от root) — **дальше скрипт спросит сам**: какие протоколы ставить
|
||||
(Enter — REALITY + AmneziaWG-3.1, «все» — набор автора), какую маскировку и домен,
|
||||
закрывать ли ответ на ping, включать ли автообновления, менять ли порт SSH (Enter — оставить
|
||||
текущий, число — перевести на него), менять ли пароль root:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
|
||||
```
|
||||
|
||||
Те же ответы можно задать флагами — тогда вопросов не будет:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols reality,hy2,awg,mtproto --domain vpn.example.com
|
||||
```
|
||||
|
||||
3. Скрипт делает всё сам: проверяет сервер → скачивает наш релиз и **проверяет подпись `SHA256SUMS`
|
||||
и SHA256 установщика** → запускает установщик автора (панель 3X-UI, **только выбранные протоколы**,
|
||||
сайт-заглушка, маскировка, сертификат, подписка, ufw) → наши вопросы и правки (ping,
|
||||
автообновления, второй порт SSH, пароль root) → `kit fix` → в конце показывает `kit check` и сводку
|
||||
`kit local hardening`.
|
||||
4. Забрать ссылки и подписку: `kit user link admin` (или адрес подписки из вывода установки).
|
||||
|
||||
Сомневаетесь в канале — сначала ничего не меняющие режимы:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --verify-only # только проверка подписи релиза
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --dry-run --domain vpn.example.com # план установки
|
||||
```
|
||||
|
||||
### Флаги установки
|
||||
|
||||
| Флаг | Что делает |
|
||||
|---|---|
|
||||
| `--protocols список` | какие протоколы ставить: `all`, `minimal` или через запятую — `reality,reality2,hy2,xhttp,ws,trojan,vmess,ss,tuic,wg,awg,awg3,mtproto`. Невыбранные не создаются вообще. Без флага — вопрос, по умолчанию предлагаются **REALITY + AmneziaWG-3.1** (ответ «все» — набор автора) |
|
||||
| `--site N\|random\|off` | сайт-заглушка: конкретный из нашего набора (10 ИТ-сайтов), случайный (по умолчанию) или не трогать авторскую страницу (то же — `--no-site`) |
|
||||
| `--yes` | не задавать вопросы (ping закрывается, автообновления включаются, порт SSH и пароль не трогаются) |
|
||||
| `--harden` | закрыть ответ на ping и включить автообновления без вопросов |
|
||||
| `--domain домен` | свой домен для маскировки: сайт и REALITY маскируются под него (A-запись на сервер, свободный 80) |
|
||||
| `--sni сайт.com` | маскировка под чужой сайт вместо домена |
|
||||
| `--panel-on domain\|ip` | где открывать панель и подписку |
|
||||
| `--ssh-port N` | перевести SSH на порт N (число 1024–65535). Прежний порт сам не закрывается: сначала проверьте вход во втором окне, потом `kit local ssh --close-old N` |
|
||||
| `--no-harden` | не закрывать ответ на ping и не включать автообновления безопасности |
|
||||
| `--no-time` / `--no-check` | не запускать `kit fix` / `kit check` |
|
||||
| `--verify-only` / `--dry-run` | проверить релиз / показать план и ничего не менять |
|
||||
| остальные | передаются установщику автора как есть: `--host`, `--user`, `--port`, `--protocols`, `--restore`, `--multi-port`, `--cert`/`--key`, `--panel-ssl` |
|
||||
|
||||
Чего скрипт **не** делает (сознательно): `apt full-upgrade` (тянет систему вперёд и может сломать
|
||||
закреплённые панель 3X-UI 3.9.0 и ядро Xray 26.6.27 — вместо него обновления безопасности),
|
||||
автоматическую перезагрузку и смену пароля root (в однострочник пароль не подставляем, он попал бы
|
||||
в список процессов — для этого есть `kit local passwd`).
|
||||
|
||||
### Как проверить, что установка удалась
|
||||
|
||||
```bash
|
||||
kit check # без ❌: службы, порт 443 (nginx), подписка, маскировка, сертификат
|
||||
kit local hardening # сводка: порты SSH, ответ на ping: deny, ufw активен, автообновления: включены
|
||||
kit local channel # источник — этот репозиторий, ключей в KIT_SIGNERS: 1 (наш)
|
||||
kit local version # версия сборки: 1.2.5 или новее
|
||||
ping <IP сервера> # с домашней машины — таймаут: ответ на ping закрыт, это ожидаемо
|
||||
kit user link admin # ссылка подписки; откройте её в браузере
|
||||
```
|
||||
|
||||
Если ставили с доменом, в `kit check` должны быть строки «свой домен … указывает на этот сервер» и
|
||||
«сайт маскировки … отвечает по TLS 1.3 и HTTP/2»; предупреждения про известный сайт на чужом IP
|
||||
быть не должно (это касалось бы вспомогательного XHTTP, у REALITY должен быть ваш домен).
|
||||
|
||||
## Команды форка (`kit local`)
|
||||
|
||||
```bash
|
||||
kit local help # справка по всем командам форка
|
||||
kit local version # версия, источник обновлений, дата сборки нашего файла
|
||||
kit local channel # откуда берутся обновления и чем проверяется подпись
|
||||
kit local seams # какие швы форка сейчас в установленном /usr/local/bin/kit
|
||||
kit local masking # состояние маскировки (у REALITY должен быть свой домен)
|
||||
kit local hardening # сводка по защите сервера
|
||||
```
|
||||
|
||||
Защита сервера (поведение взято из проекта `StressOzz/vps-setup`, реализация своя и безопаснее):
|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `kit local site` | поставить случайный сайт-заглушку из нашего набора (10 ИТ-сайтов) и синхронизировать ответ Hysteria2 |
|
||||
| `kit local site --list` / `kit local site 7` / `--show` | список сайтов с номерами / поставить конкретный / какой стоит сейчас |
|
||||
| `kit local ssh 2222` | добавить порт SSH (прежние продолжают работать; порт сначала открывается в ufw, конфиг проверяется через `sshd -t`) |
|
||||
| `kit local ssh --status` | какие порты SSH слушаются сейчас |
|
||||
| `kit local ssh --close-old 2222` | закрыть прежние порты после того, как новый проверен из второго окна |
|
||||
| `kit local ping off\|on\|status` | ответ на ICMP echo: закрыть/вернуть/показать (правилом ufw, видно в `ufw status`) |
|
||||
| `kit local passwd [юзер]` | сменить пароль (спросит дважды, в историю команд не попадёт) |
|
||||
| `kit local passwd --generate` | сгенерировать пароль и показать один раз |
|
||||
| `kit local upgrades on\|off\|status` | автообновления безопасности (только security, без перезагрузки) |
|
||||
|
||||
К любой правке можно добавить `--dry-run` — покажет план и ничего не изменит. Копии изменённых
|
||||
файлов складываются в `/root/kit-local-backup-<дата>/` (не в `/tmp`).
|
||||
|
||||
Что `ping off` затрагивает: только ICMP echo. Подписка (HTTPS), панель, REALITY/XHTTP (TCP 443),
|
||||
Hysteria2 (UDP 443) работают как работали; исходящий ping с самого сервера тоже остаётся. Если
|
||||
сервер есть в вашем мониторинге — пробу до него делайте по HTTPS/TCP, а не по ICMP.
|
||||
|
||||
## Что где лежит
|
||||
|
||||
| Что | Где | Правит |
|
||||
|---|---|---|
|
||||
| Наши доработки (команды, хуки) | `scripts/kit-local.sh` | только мы |
|
||||
| Установка одной командой (обёртка) | `scripts/3x-ui-local.sh` | только мы |
|
||||
| Описание «швов» в коде автора | `tools/fork_apply.py` | только мы |
|
||||
| Синхронизация с автором и проверки | `tools/fork-sync.sh` | только мы |
|
||||
| Записи о наших версиях | `FORK-CHANGELOG.md` | только мы |
|
||||
| `README.md` | целиком, включая картинки в `manuals/assets/` | только мы |
|
||||
| Файлы автора (`scripts/kit.sh`, `scripts/3x-ui.sh`, …) | как есть | только автор |
|
||||
|
||||
Ключевая идея: в файлах автора мы правим только «швы» — несколько констант, одну строку подключения
|
||||
и нормализацию сумм в релизном скрипте. Всё, что мы дописываем по существу, лежит в наших файлах.
|
||||
Швы описаны в одном месте (`tools/fork_apply.py`) и накатываются только на чистое дерево автора;
|
||||
если автор изменил нужную строку — инструмент останавливается и говорит об этом, а не делает
|
||||
полу-патч.
|
||||
|
||||
## Ветки
|
||||
|
||||
`tools/release.sh` — **наш файл** (как README): авторский скрипт подписи мы переписали под себя
|
||||
(проверка LF, гейт справки, шапка `SHA256SUMS` в двух строках, свой список файлов). Швов к нему нет;
|
||||
при обновлении из авторского проекта файл конфликтует и решается в нашу пользу (как README).
|
||||
|
||||
Важно при правках швов: авторский скрипт работает с `set -u`, поэтому если оборачивать его присваивание
|
||||
в `if`, переменную нужно объявить пустой (`local extra=""`, а не `local extra`) — иначе при невыполненном
|
||||
условии любое чтение `$extra` валит установку на середине с «unbound variable».
|
||||
|
||||
Проверка корректности швов (делать после каждой правки и после синхронизации с автором):
|
||||
накатать `tools/fork_apply.py` на чистую копию авторского кода и сверить с нашим деревом — расхождений
|
||||
быть не должно, кроме наших собственных файлов. Это единственный способ увидеть правки, которых нет в швах.
|
||||
|
||||
**`main` двигается только вместе с подписанным релизом.** Серверы читают версию из `main` (`KIT_RAW`),
|
||||
поэтому в `main` не должно попадать то, что ещё не подписано: иначе `kit update` и однострочник
|
||||
установки упираются в отсутствующий тег или в неподписанные суммы. Порядок такой:
|
||||
|
||||
1. поднять версию и собрать изменения в `dev`;
|
||||
2. пользователь подписывает (`tools/release.sh`);
|
||||
3. коммит с `SHA256SUMS` и `SHA256SUMS.sig` → тег `v<версия>` → и только теперь `dev` вливается в `main`.
|
||||
|
||||
Проверка перед публикацией в `main`: `curl -fsSL .../raw/tag/v<версия>/VERSION` должен отдавать ту же
|
||||
версию, а `bash <(curl ... raw/branch/main/scripts/3x-ui-local.sh) --verify-only` — писать, что подпись сошлась.
|
||||
и релизы
|
||||
|
||||
- `upstream` — точная копия ветки автора (`main`), только вперёд.
|
||||
- `dev` — наша сборка: `upstream` + швы + наши файлы; из неё делаются релизы.
|
||||
- `main` — то же состояние плюс документация после релиза (по нему считается «последняя версия»).
|
||||
- релизы — теги `v<номера>`; номер = `<версия автора>.<наш уровень>` (автор 1.2 → наш 1.2.1; после
|
||||
релиза автора 1.3 → 1.3.1). Номер обязан строго расти: `kit update` не принимает откат назад.
|
||||
|
||||
## Обновление сервера и канал
|
||||
|
||||
```bash
|
||||
kit update --force && kit check # тянет наш релиз из этого репозитория, проверяет нашу подпись
|
||||
```
|
||||
|
||||
Источник файлов — `KIT_RAW` / `kit_ref_raw()` в `scripts/kit.sh` (наш Gitea и наш тег), подпись
|
||||
проверяется ключом из `KIT_SIGNERS` (там только ключ форка). Ночной `kit-update.timer` (03:00) делает
|
||||
то же автоматически — поэтому сервер и должен смотреть в этот репозиторий: иначе наши доработки
|
||||
исчезнут в первую же ночь.
|
||||
|
||||
Вернуться на канал автора: скачать `scripts/kit.sh` из релиза автора с GitHub в `/usr/local/bin/kit`,
|
||||
затем `kit update --force`.
|
||||
|
||||
## Обновление от автора (с рабочей станции)
|
||||
|
||||
### Автоматически: робот на раннере (CT 114)
|
||||
|
||||
Раз в сутки (03:30) Gitea сама запускает `.gitea/workflows/fork-sync.yml` на раннере в CT 114:
|
||||
робот `tools/fork-sync-bot.sh` забирает ветку автора, переносит на неё наши правки, прогоняет те же
|
||||
проверки, что и `tools/fork-sync.sh`, дописывает раздел в `FORK-CHANGELOG.md` и публикует `dev`
|
||||
(`--force-with-lease`). Не сошлось (конфликт в местах наших правок) — **ничего не публикуется**,
|
||||
причина видна в Gitea → Actions и в `/opt/kit-fork-sync/last-run.log` на контейнере.
|
||||
|
||||
Робот **не** выпускает и не подписывает релиз: закрытый ключ подписи остаётся на рабочей станции.
|
||||
После автоматической синхронизации — как обычно: `git fetch origin && git checkout dev && git reset --hard origin/dev`
|
||||
(робот переписывает `dev`), описать изменения в `FORK-CHANGELOG.md`, затем `tools/release.sh` и тег.
|
||||
|
||||
```bash
|
||||
cd H:/My_Proj/Fork_3X-UI_KIT
|
||||
bash tools/fork-sync.sh # подтянуть автора, перенести наши правки, прогнать проверки
|
||||
# при конфликте — разобрать вручную: инструмент сам ничего не публикует
|
||||
|
||||
bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key # SHA256SUMS + подпись нашим ключом
|
||||
|
||||
**Нумерация версий.** Основа — версия автора, наша надстройка добавляется через точку с единицы:
|
||||
у автора 1.2.1 → наша сборка 1.2.1.1; вышла его 1.2.2 → наша 1.2.2.1. Так на сервере всегда видно,
|
||||
на чьей базе собрана сборка.
|
||||
|
||||
**Обновление базы автора.** Когда автор выпускает новую версию: перенести его файлы
|
||||
(`git checkout upstream -- scripts/kit.sh scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit-sub.py`),
|
||||
прогнать `python tools/fork_apply.py` и посмотреть, какие якоря не нашли себя. Те, чью работу автор уже
|
||||
делает сам, — вычеркнуть; остальные перенастроить на его новый код и пересобрать таблицу швов
|
||||
`git branch -f upstream <коммит автора>`.
|
||||
|
||||
Скрипт сам приводит переводы строк к LF, проверяет справку и то, что установщик не ругается на пустые
|
||||
строки, сверяет панель и ядро Xray с официальными хешами GitHub (если GitHub недоступен — предупреждает и
|
||||
пропускает эти проверки за секунды, а не висит; расхождение хешей при живом GitHub по-прежнему останавливает
|
||||
выпуск). Далее скрипт ждёт **парольную фразу ключа** — её вводит человек, поэтому запускать надо в окне, где
|
||||
можно набрать пароль (в PowerShell — да, в фоне — нет).
|
||||
git add -A && git commit -m "Релиз v$(cat VERSION): …" && git tag v$(cat VERSION)
|
||||
git push origin dev v$(cat VERSION) upstream
|
||||
```
|
||||
|
||||
## Подпись релизов
|
||||
|
||||
- Открытая часть ключа — `tools/fork-signer.pub` и в `KIT_SIGNERS` (в `scripts/kit.sh`); она же
|
||||
вшита в `scripts/3x-ui-local.sh`, чтобы проверять подпись до запуска установщика.
|
||||
- Закрытая часть — только на рабочей станции владельца (`~/.ssh/3x-ui-kit-fork/release_key`).
|
||||
В репозиторий, CI и на серверы не попадает: кто получит ключ — получит root на всех серверах,
|
||||
которые обновляются из этого репозитория. Пароль на ключ пока не установлен
|
||||
(`ssh-keygen -p -f ~/.ssh/3x-ui-kit-fork/release_key`).
|
||||
- Смена ключа: новый релиз подписывается **старым** ключом, иначе серверы его не примут.
|
||||
|
||||
## Грабли, о которых стоит помнить
|
||||
|
||||
1. **Ночное автообновление** (`kit-update.timer`, 03:00) перезаписывает `/usr/local/bin/kit` тем, что
|
||||
лежит в настроенном репозитории. Канал и наши правки — один шаг, а не два.
|
||||
2. **Переводы строк.** Релиз подписывается по суммам рабочей копии, а сервер сверяет их с файлами из
|
||||
Gitea: при `core.autocrlf=true` суммы не сойдутся (CRLF против LF). В клоне должно быть
|
||||
`git config core.autocrlf false` (и `.gitattributes`), проверка есть в `tools/fork-sync.sh`.
|
||||
3. **Наш файл обязан быть в двух списках** — `FILES=(…)` в `tools/release.sh` и список загрузки
|
||||
`fetch_release()` в `scripts/kit.sh`, иначе после обновления `kit-local.sh` останется старым.
|
||||
4. **Панель 3X-UI ставится с GitHub** по закреплённой версии и SHA256 — это шаг автора; для установки
|
||||
с нуля серверу нужен доступ к GitHub.
|
||||
5. **Формат контрольных сумм.** `kit` сверяет имя файла в `SHA256SUMS` строго (`$2 == "путь"`).
|
||||
На Windows `sha256sum` пишет `hash *путь`, и такие релизы `kit update` отклоняет («в подписанном
|
||||
списке нет такого файла»), хотя `sha256sum -c` их принимает. Поэтому `tools/release.sh` приводит
|
||||
список к POSIX-виду **до** подписи, а релизы **v1.2.1–v1.2.4 ставить нельзя** — только v1.2.5+.
|
||||
Проверять релиз надо логикой самого `kit`, а не одной `sha256sum -c`.
|
||||
6. **Сборка релиза обращается к GitHub** (сверка закреплённых версий панели и ядра). Из домашней сети
|
||||
TLS до GitHub иногда рвётся (`schannel: failed to receive handshake`) — просто повторить запуск.
|
||||
7. **`README.md` — наш файл целиком.** Авторское описание убрано: иначе человек, открывший
|
||||
репозиторий, читает команду установки **авторской** сборки, ставит по ней и оказывается на
|
||||
авторском канале обновлений — ночное автообновление вернёт его сборку. При обновлении из
|
||||
авторского проекта README конфликтует: берём нашу версию (`git checkout --ours README.md`),
|
||||
авторские правки в нём нас не интересуют.
|
||||
|
||||
## Что проверено на сегодня
|
||||
|
||||
- Релиз `v1.2.5`: подпись сходится с ключом из опубликованного `kit.sh`, строгая сверка сумм
|
||||
`$2 == путь` для `kit.sh`, `kit-sub.py`, `kit-local.sh` — как это делает `kit` на сервере.
|
||||
- Публичный однострочник в режиме `--verify-only` отвечает «подпись сошлась, SHA256 установщика
|
||||
совпал» — проверено снаружи, из домашней сети.
|
||||
- Установка «в бою» на живом сервере пока не прогонялась: сначала её стоит выполнить на новом VPS,
|
||||
посмотрев `--verify-only` и `--dry-run`.
|
||||
@@ -1,268 +1,217 @@
|
||||
<div align="center">
|
||||
|
||||
<img alt="3X-UI KIT" src="manuals/assets/banner.svg" width="820">
|
||||
<img alt="3X-UI autoVPS" src="manuals/assets/fork-banner.svg" width="860">
|
||||
|
||||
**Установка 3X-UI одной командой: 11 протоколов, один порт 443, одна подписка на всё**
|
||||
**Сервер одной командой: панель 3X-UI, VPN-протоколы за одним портом 443, одна подписка на все устройства**
|
||||
|
||||
[](manuals/3x-ui.md#протоколы)
|
||||
[](https://github.com/MHSanaei/3x-ui)
|
||||
[](https://github.com/itsnotkubrick/3X-UI_KIT/commits)
|
||||
|
||||
[Возможности](#возможности) · [Установка](#установка) · [Команды](#команды-kit) · [Роутер](#vpn-на-роутере) · [Генератор](#генератор-конфигов-для-роутера) · [Полезное](#полезное) · [Поддержать](#поддержать-проект)
|
||||
Сборка со **своим каналом обновлений**: сервер ставится и обновляется из этого репозитория, релизы
|
||||
подписаны, ночное автообновление не вернёт чужую версию. В основе — открытый проект 3X-UI_autoVPS.
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
## Что ставится
|
||||
|
||||
> [!TIP]
|
||||
> **Вышла версия 1.1.1:** `kit check` и `kit fix`, исправления для Happ и скачивания ядра, инструкции для XKeen.
|
||||
> [Что нового](https://github.com/itsnotkubrick/3X-UI_KIT/releases/tag/v1.1.1) ·
|
||||
> сервер на 1.1 обновится сам этой ночью, а на 1.0 одной командой:
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update
|
||||
> ```
|
||||
|
||||
> [!NOTE]
|
||||
> **Проект – набор скриптов с открытым кодом.** Он не оказывает услуг связи и не предоставляет доступ
|
||||
> к серверам. Вы ставите его на собственный сервер и сами отвечаете за соблюдение законодательства
|
||||
> своей страны.
|
||||
|
||||
**3X-UI KIT** превращает чистый VPS в готовый VPN-сервер за несколько минут. Скрипт ставит
|
||||
официальную панель [3X-UI](https://github.com/MHSanaei/3x-ui), настраивает все популярные
|
||||
протоколы, сертификат и файрвол и выдаёт одну ссылку-подписку. Её можно вставить в любое
|
||||
приложение – оно само получит подходящие ему протоколы. Домен не нужен.
|
||||
|
||||
<div align="center">
|
||||
<img alt="Конец установки 3X-UI KIT" src="manuals/assets/script-3x-ui.svg" width="760">
|
||||
</div>
|
||||
|
||||
## Возможности
|
||||
|
||||
- 🧩 **11 протоколов сразу** – VLESS REALITY, XHTTP, WebSocket, Trojan gRPC, VMess,
|
||||
Shadowsocks 2022, Hysteria2, TUIC, AmneziaWG (классика и 3.1) и MTProto для Telegram.
|
||||
- 🚪 **Всё TCP – через порт 443.** Панель, подписка и протоколы спрятаны за одним портом,
|
||||
а на случайный заход сервер показывает обычный сайт.
|
||||
- 🔗 **Одна подписка на все приложения.** Hiddify, Happ, v2rayN, Karing, Clash Verge и FlClash
|
||||
получают свой формат и только те протоколы, которые умеют.
|
||||
- 👥 **Дополнительные пользователи одной командой** – `kit user add` добавляет пользователя
|
||||
сразу во все протоколы с общим лимитом трафика, сроком и числом устройств.
|
||||
- 🔄 **Обновляется сам, но только подписанными релизами**, а `kit backup` сохраняет
|
||||
копию сервера с пользователями и ключами.
|
||||
- 🔒 **Сертификат Let's Encrypt на IP** выпускается и продлевается сам, панель скрыта на
|
||||
случайном пути со случайными логином и паролем.
|
||||
- ✅ **Проверено настоящими клиентами** – каждый протокол на ядрах Xray, Mihomo и sing-box,
|
||||
в том числе через интернет к удалённому серверу: [tests/matrix](tests/matrix/).
|
||||
|
||||
## Что понадобится
|
||||
|
||||
- VPS с **Ubuntu 22.04/24.04** или **Debian 12/13** и доступом root по SSH
|
||||
- Свободные порты **443** и **80** – на свежем сервере они свободны
|
||||
|
||||
Нужен VPS? Я сам использую **[IS Hosting](https://ishosting.io/affiliate/NTg4MiM4)** и рекомендую его
|
||||
(реферальная ссылка).
|
||||
| Что | Зачем |
|
||||
|---|---|
|
||||
| Панель **3X-UI** + ядро **Xray** | управление подключениями и пользователями |
|
||||
| **nginx** на порту 443 | разводит подключения по именам и путям, отдаёт сайт-заглушку |
|
||||
| VPN-подключения | REALITY, Hysteria2, AmneziaWG, XHTTP, VLESS-WS, Trojan-gRPC, VMess-WS, Shadowsocks, TUIC, MTProto, WireGuard — какие выберете |
|
||||
| **Подписка** (`kit-sub`) | одна ссылка на все устройства: Happ, Hiddify, v2rayN, Clash/Mihomo |
|
||||
| **Сайт-заглушка** | на 443 и в ответе Hysteria2 — правдоподобный ИТ-сайт из нашего набора |
|
||||
| **ufw**, обновления безопасности, синхронизация времени | защита сервера и корректная работа REALITY |
|
||||
|
||||
## Установка
|
||||
|
||||
Подключитесь к серверу по SSH и выполните:
|
||||
От root, на чистом сервере (Debian 11+ / Ubuntu 22.04+, 64-бит):
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh)
|
||||
bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
|
||||
```
|
||||
|
||||
Через пару минут скрипт покажет адрес панели, логин, пароль и подписку с QR-кодом.
|
||||
Без флагов скрипт **спрашивает всё заранее, до установки**: какие протоколы ставить (Enter — REALITY +
|
||||
AmneziaWG-3.1), менять ли порт SSH (Enter — оставить 22), менять ли пароль root, закрывать ли ответ на
|
||||
ping, включать ли автообновления безопасности. Затем сканирует подсеть и **предлагает выбрать сайт
|
||||
маскировки** — отдельно для REALITY и, если выбраны XHTTP/MTProto, отдельно для них: номер из найденных
|
||||
соседей, `0` — ввести адрес вручную, для XHTTP/MTProto ещё и известный сайт (Google, Microsoft, AMD…).
|
||||
|
||||
Подробно – подключение приложений, дополнительные пользователи и параметры –
|
||||
в **[инструкции](manuals/3x-ui.md)**. Нужен только Hysteria2 – есть
|
||||
[отдельный скрипт](manuals/hysteria2.md).
|
||||
После установщика скрипт **ничего не спрашивает** — только выполняет выбранное, проверяет сервер и в
|
||||
самом конце показывает то, что нужно унести: **доступ к панели 3X-UI** (адрес, логин, пароль) и
|
||||
**подписку с QR-кодом** и ссылками по протоколам.
|
||||
|
||||
> [!WARNING]
|
||||
> Проект создан в образовательных целях. Убедитесь, что ваши действия
|
||||
> соответствуют законодательству вашей страны.
|
||||
<img alt="Установка одной командой" src="manuals/assets/script-3x-ui-local.svg" width="860">
|
||||
|
||||
## Команды kit
|
||||
Если вопросы не нужны — те же ответы задаются флагами:
|
||||
|
||||
После установки на сервере появляется команда `kit`. Подробности – в [инструкции](manuals/3x-ui.md).
|
||||
```bash
|
||||
# минимум: свой домен и два протокола, без вопросов
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols reality,awg3 --domain vpn.example.com
|
||||
|
||||
# всё, кроме WireGuard, и второй порт SSH
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols все --ssh-port 2222
|
||||
|
||||
# ничего не спрашивать: ping закрыть, автообновления включить, порт и пароль не трогать
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --yes
|
||||
```
|
||||
|
||||
Проверить канал, ничего не меняя:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --verify-only # подпись релиза и сумма установщика
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --dry-run # что именно будет сделано
|
||||
```
|
||||
|
||||
### Флаги установки
|
||||
|
||||
| Флаг | Что делает |
|
||||
|---|---|
|
||||
| `--protocols all\|minimal\|список` | какие протоколы ставить: `all`, `minimal` (только REALITY) или через запятую. Невыбранные не создаются вообще |
|
||||
| `--domain ваш.домен` | свой домен для маскировки (A-запись на сервер, свободный порт 80 — сертификат Let's Encrypt выпустит установщик) |
|
||||
| `--sni сайт.ком` | маскировка под чужой сайт вместо своего домена |
|
||||
| `--panel-on ip\|domain` | где открывать панель и подписку |
|
||||
| `--site N\|random\|off` | сайт-заглушка: конкретный из нашего набора, случайный (по умолчанию) или не трогать |
|
||||
| `--ssh-port N` | перевести SSH на порт N (прежний закроется только после вашей проверки) |
|
||||
| `--harden` / `--no-harden` | закрыть ping и включить автообновления безопасности / не трогать это |
|
||||
| `--yes` | не задавать вопросы (для автоматизации) |
|
||||
| `--host IP`, `--user имя`, `--port N`, `--restore файл` | адрес в ссылках, имя первого клиента, порт REALITY, восстановление из копии `kit backup` |
|
||||
| `--clean` | убрать прошлую (оборвавшуюся) установку и поставить заново — SSH не трогает |
|
||||
| `--auto-sni` | не показывать меню выбора сайта маскировки, подобрать самим (автоподбор автора) |
|
||||
| `--no-sub` | не показывать в конце доступ к панели, подписку и QR-код |
|
||||
| `--no-time`, `--no-check`, `--verify-only`, `--dry-run`, `-h` | служебные |
|
||||
|
||||
## Что происходит по шагам
|
||||
|
||||
1. **Проверка сервера**: root, Debian/Ubuntu, память, место на диске, занятые порты 80/443.
|
||||
2. **Проверка релиза**: файлы берутся из этого репозитория по тегу, проверяется **подпись `SHA256SUMS`**
|
||||
нашим ключом и SHA256 самого установщика. Не сошлось — ничего не запускается.
|
||||
3. **Наши вопросы** (до установки): протоколы, порт SSH, пароль root, ping, автообновления.
|
||||
4. **Установщик**: панель 3X-UI, только выбранные подключения, nginx на 443, сертификат, сайт-заглушка,
|
||||
служба подписки, правила ufw. Маскировку спрашивает он: свой домен, сосед по подсети или свой сайт.
|
||||
Если выбран сосед — сначала идёт **скан подсети**, затем меню выбора сайта (см. выше).
|
||||
5. **Наши действия** (без вопросов): правдоподобный ИТ-сайт-заглушка вместо авторской страницы,
|
||||
`kit fix` (время и безопасные починки), закрыть ответ на ping, включить обновления безопасности,
|
||||
перевести SSH на новый порт, сменить пароль root.
|
||||
6. **Проверка**: `kit check` (порты, службы, подписка, сертификат, маскировка) и сводка
|
||||
`kit local hardening`.
|
||||
7. **В самом конце** — **доступ к панели 3X-UI** (адрес, логин, пароль из `/root/3x-ui.txt`) и
|
||||
**подписка с QR-кодом** и ссылками по протоколам. После них скрипт уже ничего не делает и не спрашивает.
|
||||
|
||||
## Протоколы
|
||||
|
||||
Один порт 443 на всё: nginx разводит подключения по именам и путям, поэтому снаружи видно только
|
||||
443/tcp (и Hysteria2 на 443/udp). Список и умолчание — как у проекта: невыбранные протоколы не
|
||||
создаются вообще, ни подключения в панели, ни записей в nginx, ни ссылок в подписке.
|
||||
|
||||
| № | Протокол | Что это |
|
||||
|---|---|---|
|
||||
| 1 | **REALITY** | VLESS + Reality, основной вход. **По умолчанию** |
|
||||
| 2 | REALITY-2 | второй REALITY на случайном высоком порту |
|
||||
| 3 | Hysteria2 | быстрее на плохой сети (UDP 443) |
|
||||
| 4 | XHTTP | VLESS + Reality под другим именем маскировки |
|
||||
| 5 | VLESS-WS | VLESS через WebSocket |
|
||||
| 6 | Trojan-gRPC | Trojan через gRPC |
|
||||
| 7 | VMess-WS | VMess через WebSocket |
|
||||
| 8 | Shadowsocks | Shadowsocks 2022 |
|
||||
| 9 | TUIC | быстрый UDP-протокол |
|
||||
| 10 | WireGuard | обычный WireGuard: легко распознаётся, поэтому по умолчанию не ставится |
|
||||
| 11 | AmneziaWG | AmneziaWG: Mihomo, роутеры Keenetic |
|
||||
| 12 | **AmneziaWG-3.1** | AmneziaWG с защитой заголовков: приложения AmneziaVPN. **По умолчанию** |
|
||||
| 13 | MTProto | прокси для Telegram |
|
||||
|
||||
Наш набор по умолчанию — **REALITY + AmneziaWG-3.1**: работает почти везде и не требует
|
||||
дополнительных объяснений на клиенте. Маскировку и сайт можно менять потом: `kit net site`,
|
||||
`kit local site`.
|
||||
|
||||
**Сайт маскировки выбираете вы.** После скана подсети установщик спрашивает отдельно для REALITY
|
||||
(найденный сосед или свой адрес) и отдельно для XHTTP/MTProto (найденный, свой или известный сайт —
|
||||
список известных расширен Google, Amazon, Samsung, Yahoo, Microsoft, AMD, Intel, NVIDIA). Выбранный
|
||||
адрес проверяется на TLS 1.3 + HTTP/2. Если XHTTP и MTProto не выбраны, второго вопроса нет.
|
||||
|
||||
Для протоколов, которые **не** выбраны, не делается ничего: ни подключений, ни записей в nginx, ни
|
||||
ссылок в подписке, ни лишних обращений к сети (например, обход соседей запускается только когда он
|
||||
действительно нужен).
|
||||
|
||||
## Сайт-заглушка
|
||||
|
||||
На 443 при неизвестном имени отдаётся страница нашего набора: небольшие правдоподобные ИТ-сайты
|
||||
вместо стандартных «страница в разработке».
|
||||
|
||||
| № | Сайт | Тема |
|
||||
|---|---|---|
|
||||
| 1 | Radian Analytics | дата-аналитика: хранилище, отчёты, дашборды |
|
||||
| 2 | Circuit Nine | DevOps-консалтинг: CI/CD, инфраструктура, наблюдаемость |
|
||||
| 3 | Cortex Monitor | мониторинг серверов: проверки, алерты, сертификаты |
|
||||
| 4 | Bluefin Software | разработка ПО на заказ |
|
||||
| 5 | Vantage Security | аудит и пентест |
|
||||
| 6 | Keystone IT | ИТ-поддержка офисов |
|
||||
| 7 | Meridian Networks | колокейшн и транзит |
|
||||
| 8 | Lumen Notes | заметки про Linux, nginx и сети |
|
||||
| 9 | Evercore Backup | бэкапы и восстановление |
|
||||
| 10 | Halyard Docs | документация проекта |
|
||||
|
||||
```bash
|
||||
kit local site # случайный сайт из набора
|
||||
kit local site --list # список с номерами
|
||||
kit local site 7 # поставить конкретный
|
||||
kit local site --show # какой стоит сейчас
|
||||
```
|
||||
|
||||
Менять страницу нужно **этой командой**: `/var/www/kit/index.html` отдаёт nginx, а Hysteria2 держит
|
||||
свою копию страницы внутри подключения. Команда обновляет обе — иначе 443/tcp и UDP начнут отвечать
|
||||
разными страницами, и это заметно снаружи.
|
||||
|
||||
## Команды
|
||||
|
||||
На сервере после установки доступна команда `kit` (без аргументов — меню).
|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `kit user add имя [--gb 50] [--days 30] [--devices 3]` | добавить пользователя во все протоколы и показать его подписку с QR-кодом |
|
||||
| `kit user list` | кто сколько израсходовал, до какого числа и когда был в сети |
|
||||
| `kit user link имя [--all]` | показать подписку ещё раз; `--all` добавляет ссылки `vpn://` и `tg://` |
|
||||
| `kit user limit имя [--gb N] [--days N] [--devices N]` | изменить лимиты (`0` – без ограничений) |
|
||||
| `kit user off имя` / `kit user on имя` | временно выключить и включить пользователя |
|
||||
| `kit user del имя` | удалить пользователя |
|
||||
| `kit check` | проверить сервер: службы, сертификат, подписку, сайт маскировки, права на файлы (ничего не меняет) |
|
||||
| `kit fix [--dry-run]` | исправить безопасное: перезапустить службы, вернуть права, включить автообновление |
|
||||
| `kit update [--manual \| --auto]` | обновить сейчас; `--manual` выключает автообновление, `--auto` включает |
|
||||
| `kit backup` | сохранить копию сервера (подключения, ключи, пользователи) |
|
||||
| `kit version` | версии `kit`, панели и ядра, состояние автообновления |
|
||||
| `kit user …` | `add`, `list`, `link`, `limit`, `on`, `off`, `del` — пользователи, ссылки, лимиты трафика и сроков |
|
||||
| `kit net` | состояние подключений; `on`/`off` включить-выключить, `site` — сайт маскировки, `masq` — ответ Hysteria2 страницей, `dns` — DoH в подписках |
|
||||
| `kit port` / `kit port set <имя> <порт>` | какие порты слушают подключения / сменить порт подключения |
|
||||
| `kit reality add [порт]` | добавить второй REALITY |
|
||||
| `kit check [--deep\|--fix]`, `kit fix` | проверка сервера (в том числе с самого сервера, как клиент) и безопасные починки |
|
||||
| `kit update [--panel]`, `--auto`, `--manual` | обновление сборки и панели, режим автообновления |
|
||||
| `kit backup` | копия сервера (панель, сертификаты, настройки) |
|
||||
| `kit version` | версии сборки, панели и ядра |
|
||||
|
||||
Для отдельного сервера Hysteria2 есть команда `hy2` (`hy2 add`, `hy2 del`, `hy2 list`, `hy2 link`, `hy2 status`,
|
||||
`hy2 restart`, `hy2 update`, `hy2 uninstall`), подробнее – в [инструкции по Hysteria2](manuals/hysteria2.md).
|
||||
Панелью 3X-UI управляет команда `x-ui`.
|
||||
Наш слой — `kit local` (справка: `kit local help`):
|
||||
|
||||
## VPN на роутере
|
||||
|
||||
Через прокси идут только нужные сервисы и выбранные устройства, остальное работает напрямую.
|
||||
Порядок: **1.** установить программу на роутер → **2.** собрать конфиг в [генераторе](#генератор-конфигов-для-роутера) →
|
||||
**3.** вставить команду из генератора.
|
||||
|
||||
> [!NOTE]
|
||||
> **🙏 Спасибо автору XKeen.** На роутере работает не моя программа: я только собрал инструкцию
|
||||
> и генератор конфигов, а всю сложную работу сделал **jameszeroX**.
|
||||
>
|
||||
> **[XKeen](https://github.com/jameszeroX/XKeen)** – прокси на Keenetic (Xray и Mihomo, политики для устройств).
|
||||
> [Репозиторий](https://github.com/jameszeroX/XKeen) · [Вики](https://github.com/jameszeroX/XKeen/wiki) · ⭐ [Поставить звезду](https://github.com/jameszeroX/XKeen)
|
||||
>
|
||||
> Вопросы по самой программе задавайте в её репозитории: так автор узнаёт об ошибках. Если что-то не так
|
||||
> с моей инструкцией или генератором, пишите мне.
|
||||
|
||||
<details>
|
||||
<summary><b>📶 Keenetic: XKeen (jameszeroX) · 8 шагов, 20–30 минут</b></summary>
|
||||
|
||||
Нужны роутер **Keenetic** или **Netcraze** с USB-портом, USB-флешка в формате **ext4** и свой сервер с VLESS
|
||||
(например, из [этой инструкции](manuals/3x-ui.md)).
|
||||
|
||||
<details>
|
||||
<summary><b>1. Компоненты KeeneticOS</b></summary>
|
||||
|
||||
В веб-интерфейсе: **Управление → Общие настройки → Изменить набор компонентов**. Отметьте компоненты
|
||||
с галочками на картинке и установите. Роутер обновится и перезагрузится.
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>2. Entware на флешку</b></summary>
|
||||
|
||||
1. Отформатируйте флешку в **ext4** и вставьте её в роутер.
|
||||
2. Откройте её по сети (`\\192.168.x.x\`), создайте папку `install` и положите туда установщик под процессор роутера:
|
||||
[mipsel](https://bin.entware.net/mipselsf-k3.4/installer/mipsel-installer.tar.gz),
|
||||
[mips](https://bin.entware.net/mipssf-k3.4/installer/mips-installer.tar.gz) или
|
||||
[aarch64](https://bin.entware.net/aarch64-k3.10/installer/aarch64-installer.tar.gz).
|
||||
Не знаете, какой нужен? Модель роутера – в характеристиках на сайте Keenetic.
|
||||
|
||||

|
||||
|
||||
3. **Управление → OPKG**: выберите флешку и сохраните. Установка займёт несколько минут.
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>3. Подключение по SSH</b></summary>
|
||||
|
||||
```bash
|
||||
ssh root@192.168.x.x -p 222
|
||||
```
|
||||
|
||||
Пароль по умолчанию `keenetic`. **Сразу смените его** командой `passwd`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>4. Шифрованный DNS</b></summary>
|
||||
|
||||
Пропишите шифрованные DNS-серверы (DoT или DoH) по
|
||||
[инструкции Keenetic](https://support.keenetic.ru/ultra/kn-1811/ru/31543-dot-and-doh-proxy-servers-for-dns-requests-encryption.html):
|
||||
без этого XKeen работает неправильно.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>5. Токен для KeeneticOS 5.2 и новее</b></summary>
|
||||
|
||||
XKeen нужен токен доступа к роутеру. Создайте его в разделе **Пользователи и доступ**, вставьте в
|
||||
[шаблон xkeen.json](https://github.com/jameszeroX/XKeen/releases/download/2.0.1_Beta/xkeen.json) и положите файл на роутер
|
||||
по пути `/opt/etc/xkeen/xkeen.json`. Подробнее – в [вики XKeen](https://github.com/jameszeroX/XKeen/wiki/Порядок-установки).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>6. Установка XKeen</b></summary>
|
||||
|
||||
Подключитесь по SSH и выполните:
|
||||
|
||||
```bash
|
||||
opkg update && opkg upgrade && opkg install curl tar && cd /tmp
|
||||
sh -c "$(curl -sSL https://raw.githubusercontent.com/jameszeroX/XKeen/main/install.sh)"
|
||||
```
|
||||
|
||||
Если GitHub недоступен, замените адрес на `https://cdn.jsdelivr.net/gh/jameszeroX/XKeen@main/install.sh`.
|
||||
Установщик спросит ядро (Xray или Mihomo), геобазы и автозагрузку.
|
||||
|
||||
Затем соберите конфиг в [генераторе](#генератор-конфигов-для-роутера) (роутер Keenetic, нужное ядро), вставьте команду в SSH-консоль
|
||||
и нажмите Enter: файлы запишутся, XKeen перезапустится.
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>7. Какие устройства через прокси</b></summary>
|
||||
|
||||
В веб-интерфейсе откройте **Приоритеты подключений → Политики доступа в Интернет**, создайте политику
|
||||
с именем **`xkeen`** и перенесите в неё нужные устройства.
|
||||
|
||||
> [!WARNING]
|
||||
> Без политики `xkeen` через прокси пойдёт трафик **всех** устройств в сети.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>8. Проверка</b></summary>
|
||||
|
||||
- На роутере: `xkeen -status` – XKeen должен быть запущен.
|
||||
- На устройстве из политики `xkeen` откройте сайт, показывающий IP-адрес: он должен совпасть с адресом вашего сервера,
|
||||
а на остальных устройствах остаться прежним.
|
||||
- Удобнее управлять настройками из браузера через [XKeen UI](https://github.com/zxc-rv/XKeen-UI).
|
||||
|
||||
</details>
|
||||
|
||||
Подробная версия с теми же шагами: [инструкция для Keenetic](manuals/xkeen-keenetic.md).
|
||||
|
||||
</details>
|
||||
|
||||
## Генератор конфигов для роутера
|
||||
|
||||
Вставьте ссылку на сервер или подписку, выберите роутер и ядро, отметьте нужные сервисы –
|
||||
и получите готовый конфиг и одну команду, которая сама положит его на роутер.
|
||||
Всё считается в браузере, ссылки никуда не отправляются.
|
||||
Как поставить XKeen на роутер – в разделе [VPN на роутере](#vpn-на-роутере).
|
||||
|
||||
| Роутер | Ядро | Что получится | Генератор |
|
||||
|---|---|---|---|
|
||||
| Keenetic (XKeen) | Xray | `04_outbounds.json` и `05_routing.json`: серверы, выбор сервисов, реклама, свои сайты | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?core=xray) |
|
||||
| Keenetic (XKeen) | Mihomo | `config.yaml` с автовыбором сервера, подпиской, Hysteria2, AmneziaWG и веб-панелью | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?core=mihomo) |
|
||||
| OpenWrt (Nikki), бета | Mihomo | профиль для Nikki и команда, которая его включит | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?router=openwrt) |
|
||||
|
||||
## Полезное
|
||||
|
||||
- [XKeen](https://github.com/jameszeroX/XKeen) и его [вики](https://github.com/jameszeroX/XKeen/wiki) – документация по маршрутизации на Keenetic
|
||||
- [XKeen UI](https://github.com/zxc-rv/XKeen-UI) – веб-интерфейс для XKeen
|
||||
- [IP-адреса для AmneziaWG](https://github.com/RockBlack-VPN/ip-address) – актуальные списки от RockBlack
|
||||
|
||||
## Поддержать проект
|
||||
|
||||
Скрипты и инструкции бесплатные. Донат добровольный – он помогает оплачивать
|
||||
тестовые серверы и держать скрипты в актуальном состоянии. Спасибо! 💜
|
||||
|
||||
| Способ | |
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| Российской картой, СБП, Tinkoff Pay | [CloudTips](https://pay.cloudtips.ru/p/d4f9e3d1) |
|
||||
| Зарубежной картой, Apple Pay, Google Pay | [Buy Me a Coffee](https://buymeacoffee.com/relo.cate) |
|
||||
| USDT (TRC-20) | `TS83ViXrdezUpp1eFadqj1rBhGLZaba1c1` |
|
||||
| TON | `UQBchO4XFPwF9MMa_tjXpwqTo8IL2FhUDyllhYuFo8WM-Qbf` |
|
||||
| Ethereum (ERC-20) | `0xC06F6B3A029d7Ea00705B7028490744e2BC16799` |
|
||||
| `kit local site` | сайт-заглушка: случайный, `--list`, номер, `--show` |
|
||||
| `kit local ssh 2222` / `--status` / `--close-old 2222` | добавить порт SSH / какие слушают / закрыть прежние после проверки |
|
||||
| `kit local ping off\|on\|status` | ответ на ICMP echo правилом ufw (видно в `ufw status`, отменяется одной командой) |
|
||||
| `kit local passwd [юзер]`, `--generate` | смена пароля (спрашивает дважды, в логи не попадает) |
|
||||
| `kit local upgrades on\|off\|status` | обновления безопасности |
|
||||
| `kit local uninstall` | **снести установку целиком**: панель, службы и файлы kit, nginx и сайт, сертификаты, наши правки системы и правила ufw для 80/443. Спрашивает слово «удалить», есть `--dry-run`. **SSH не трогает** |
|
||||
| `kit local hardening`, `masking`, `channel`, `seams`, `version` | сводка защиты, состояние маскировки, канал обновлений, какие места форк правит у автора |
|
||||
|
||||
## Благодарности
|
||||
У любой правки есть `--dry-run` — показывает план и ничего не меняет. Копии изменённых файлов
|
||||
складываются в `/root/kit-local-backup-<дата>/`.
|
||||
|
||||
3X-UI KIT построен на работе авторов этих проектов:
|
||||
[3X-UI](https://github.com/MHSanaei/3x-ui) ·
|
||||
[Xray-core](https://github.com/XTLS/Xray-core) ·
|
||||
[Mihomo](https://github.com/MetaCubeX/mihomo) ·
|
||||
[Hysteria](https://github.com/apernet/hysteria) ·
|
||||
[AmneziaWG](https://github.com/amnezia-vpn) ·
|
||||
[XKeen](https://github.com/jameszeroX/XKeen)
|
||||
## Обновление и канал
|
||||
|
||||
- Сервер обновляется из **этого репозитория**: `kit update` и ночной таймер берут релиз с подписью
|
||||
нашего ключа. Чужой релиз сервер не примет.
|
||||
- Посмотреть, откуда берутся обновления и чем проверяется подпись: `kit local channel`.
|
||||
- Управление автообновлением: `kit update --auto` (включить) и `kit update --manual` (выключить),
|
||||
`kit update` — обновиться сейчас, `kit update --panel` — обновить саму панель 3X-UI.
|
||||
- Проверить канал, ничего не устанавливая: установщик с `--verify-only` или `kit local channel`.
|
||||
|
||||
## Оговорки
|
||||
|
||||
- **Пароль root** никогда не передаётся флагом: значение в командной строке видно другим процессам.
|
||||
Спрашивается в ходе установки или меняется `kit local passwd`.
|
||||
- **Смена порта SSH** делается в два шага: новый порт добавляется, прежний продолжает работать, пока
|
||||
вы не проверите вход во втором окне. Закрыть прежние: `kit local ssh --close-old 2222`.
|
||||
- **`apt full-upgrade` не выполняется** — только обновления безопасности: панель и ядро Xray
|
||||
закреплены на проверенных версиях.
|
||||
- Версии панели и ядра в сборке сверяются с официальными хешами при выпуске релиза.
|
||||
|
||||
## Документация форка
|
||||
|
||||
| Файл | О чём |
|
||||
|---|---|
|
||||
| [FORK.md](FORK.md) | как устроен форк: где наши правки (швы), регламент обновления из авторского проекта, канал, подпись, грабли |
|
||||
| [FORK-CHANGELOG.md](FORK-CHANGELOG.md) | что менялось в наших версиях |
|
||||
| `kit local help` | команды форка прямо на сервере |
|
||||
|
||||
+9
-6
@@ -1,6 +1,9 @@
|
||||
# 3X-UI KIT 1.1.1
|
||||
64433b6f4c0b4ffba4c62a90ddcba09bc2842f6a3947cf35b5b749fb5077f8e6 scripts/3x-ui.sh
|
||||
b17fd64b538d0d17cd2fbbc5f66c8310705330aea190c04cee51a16ac3f0ad34 scripts/hysteria2.sh
|
||||
fc12bf2a37cadb37135174a7b48634852023f0505b04b1b5a08a72b5a9c4ca47 scripts/kit.sh
|
||||
a31fca3e92c293ebfa60813b1b53ca8fea6e28e037bdde5ce1c6309d392964c6 scripts/kit-sub.py
|
||||
cc52f678848b814373757b460383bf61960e4943c203735adde0a350b3e50989 VERSION
|
||||
# 3X-UI KIT 1.2.1.6
|
||||
# 3X-UI_autoVPS 1.2.1.6
|
||||
f30fc23d1c667d6fc90742fdf12377ca84d9e119fc394d86822bf1daaf462795 scripts/3x-ui.sh
|
||||
4ef4c3f830196704648c15dd546e20195ce15e2d4ee6ca66e363aac47811c4ab scripts/hysteria2.sh
|
||||
b85e6ca11eef15eed8d45fba9e7b4a7eddf6141de4aeceaac1fe7f51d4c9fa78 scripts/kit.sh
|
||||
f214671464e1c2e174e5af9df42c95567a9b4a9141f31a39f7df2e971e89d240 scripts/kit-sub.py
|
||||
75cc7bc4e048c43989f66ca3b9e9addff8d7a434e651daba6db894e18109b45b scripts/kit-local.sh
|
||||
ba87dfba9abc3417092a2c2ec8da1b0332397e9952ead213e9367692f2849459 scripts/3x-ui-local.sh
|
||||
3acd832d329f916790a07153fcc6b7cbdbeb6958131e60751094c059e4fb0f76 VERSION
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
-----BEGIN SSH SIGNATURE-----
|
||||
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgatMYIOGGebxc7V6Sk8m5JZFUIm
|
||||
IWK6kSyl9DFxsQGbMAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
|
||||
AAALc3NoLWVkMjU1MTkAAABATD/Ch1JeEtozPZPAEhOWTqDWKQbgUw6S23lqC2w1mPtR2E
|
||||
wOCNaNILU48ONBz75i/kIRipFhRTggXuVCLf62Cg==
|
||||
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV
|
||||
FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
|
||||
AAALc3NoLWVkMjU1MTkAAABADBMIIRfAe53BhNokGqyMTekQwEmlBZ8HdhkKUXjRPv0ofv
|
||||
u4/0GW5p5d2QnwShb7+zS/Dr+LekQ2SMQEBvVbAA==
|
||||
-----END SSH SIGNATURE-----
|
||||
|
||||
+90
-16
@@ -30,7 +30,7 @@
|
||||
| VLESS WebSocket + TLS | 443/tcp | всех приложениях |
|
||||
| Trojan gRPC + TLS | 443/tcp | всех приложениях |
|
||||
| VMess WebSocket + TLS | 443/tcp | всех приложениях |
|
||||
| Shadowsocks 2022 | 8388/tcp+udp | всех приложениях (некоторые мобильные операторы его не пропускают, по Wi-Fi работает) |
|
||||
| Shadowsocks 2022 | 8388/tcp+udp | всех приложениях (если не подключается, попробуйте другой протокол) |
|
||||
| TUIC v5 | 8444/udp | Hiddify, Clash Verge, FlClash, NekoBox |
|
||||
| AmneziaWG | 51821/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash |
|
||||
| AmneziaWG 3.1 | 51822/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash |
|
||||
@@ -38,13 +38,12 @@
|
||||
|
||||
**Все TCP-протоколы, панель и подписка работают через один порт 443.** Его слушает nginx:
|
||||
по имени сайта в запросе (SNI) он узнаёт REALITY, XHTTP и MTProto, остальное расшифровывает
|
||||
и разводит по секретным путям, а на всё прочее показывает сайт-заглушку. Снаружи у сервера
|
||||
и разводит по секретным путям, а на всё прочее показывает сайт-заглушку. Заглушка своя у каждого сервера:
|
||||
при установке она собирается случайно (тема, название, тексты, вёрстка, цвета) и лежит в `/var/www/kit/index.html`,
|
||||
её же показывает Hysteria2. Можно положить туда свою страницу. Снаружи у сервера
|
||||
открыты только 443, 80 (для продления сертификата), SSH, порт Shadowsocks 8388 и UDP-порты. Настоящие IP клиентов
|
||||
доходят до панели, поэтому лимит устройств работает.
|
||||
|
||||
Каждый протокол проверен настоящим подключением на ядрах Xray, Mihomo и sing-box,
|
||||
в том числе через интернет к удалённому серверу – стенд лежит в [tests/matrix](../tests/matrix/).
|
||||
|
||||
> [!NOTE]
|
||||
> Обычный WireGuard по умолчанию не ставится: его рукопожатие легко распознаётся сетевым
|
||||
> оборудованием, из-за чего соединение нестабильно. AmneziaWG – тот же WireGuard с маскировкой –
|
||||
@@ -92,6 +91,12 @@ Enter) – стандартный чужой сайт, работает сраз
|
||||
а Happ, v2rayN, Karing и Hiddify – нет, потому что не умеют его. Для AmneziaVPN импортируйте
|
||||
ссылки `vpn://` из `/root/3x-ui.txt`, для Telegram – ссылку `tg://` оттуда же.
|
||||
|
||||
## Меню `kit`
|
||||
|
||||
Команда `kit` без аргументов открывает меню, как у `x-ui`: пользователи, протоколы и порты, проверка, обновление,
|
||||
копия сервера. Выберите цифру, остальное `kit` спросит сам. Те же действия есть командами: `kit user …`, `kit net …`,
|
||||
`kit check`, `kit update`, `kit backup`, `kit version` (справка: `kit help`).
|
||||
|
||||
## Дополнительные пользователи
|
||||
|
||||
Одна команда на сервере добавляет пользователя сразу во все протоколы и выдаёт его подписку с QR-кодом:
|
||||
@@ -104,7 +109,7 @@ kit user add sasha --gb 50 --days 30
|
||||
|---|---|
|
||||
| `kit user add имя [--gb N] [--days N] [--devices N]` | добавить; лимит трафика, срок и число устройств – по желанию |
|
||||
| `kit user list` | кто сколько израсходовал, до какого числа и когда был в сети |
|
||||
| `kit user link имя [--all]` | подписка и QR-код ещё раз; `--all` – ещё ссылки `vpn://` и `tg://` |
|
||||
| `kit user link имя [--all \| --amnezia \| --telegram]` (рядом с подпиской – ссылки «открыть в приложении») | подписка и ссылки ещё раз: основные и запасные по группам; AmneziaVPN и Telegram – отдельными ключами |
|
||||
| `kit user limit имя --gb N --days N` | изменить лимиты (0 – без ограничений) |
|
||||
| `kit user off имя` / `kit user on имя` | временно выключить и включить |
|
||||
| `kit user del имя` | удалить |
|
||||
@@ -113,7 +118,7 @@ kit user add sasha --gb 50 --days 30
|
||||
|
||||
## Обновление
|
||||
|
||||
Обновления kit и подписки kit-sub приходят сами: раз в сутки ночью сервер проверяет, не вышла ли
|
||||
Обновления KIT и подписки kit-sub приходят сами: раз в сутки ночью сервер проверяет, не вышла ли
|
||||
новая версия 3X-UI KIT, и ставит её. Пользователи, ссылки и подписки не меняются, раздавать
|
||||
ничего заново не нужно.
|
||||
|
||||
@@ -135,32 +140,84 @@ kit version # версии kit, панели и ядра, включе
|
||||
Сервер ставили версией 1.0? В ней команды `kit update` ещё нет, поэтому один раз:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update
|
||||
curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1.2/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update
|
||||
```
|
||||
|
||||
Дальше обновления будут приходить сами.
|
||||
|
||||
Панель 3X-UI обновляется отдельно, через `x-ui`. Ядро Xray в панели не обновляйте до последней
|
||||
Панель 3X-UI обновляет `kit update --panel` (проверенная версия, ядро и настройки сохраняются). Ядро Xray в панели не обновляйте до последней
|
||||
версии: с Xray 26.7 и новее Hiddify, FlClash и Clash Verge не подключаются к REALITY.
|
||||
|
||||
## Проверка и починка
|
||||
|
||||
```bash
|
||||
kit check # проверить сервер, ничего не меняет
|
||||
kit fix --dry-run # показать, что будет исправлено
|
||||
kit fix # исправить безопасное
|
||||
kit check --deep # то же и подключиться клиентом (REALITY, XHTTP, Hysteria2) с самого сервера
|
||||
kit check --fix # исправить безопасное
|
||||
```
|
||||
|
||||
`kit check` проверяет службы, версии, срок сертификата, что панель не торчит наружу, права на файлы с
|
||||
`kit check` проверяет службы, что ядро Xray запущено и каждое включённое подключение слушает свой порт (TCP и UDP
|
||||
отдельно), версии, срок сертификата, что панель не торчит наружу, права на файлы с
|
||||
паролями и ключами, ответ подписки для Happ, Hiddify, v2rayN и Clash, а также что сайт маскировки отвечает по
|
||||
TLS 1.3 и HTTP/2. Вывод можно просто прислать в чат, если нужна помощь.
|
||||
TLS 1.3 и HTTP/2, и подсказывает, что улучшить (сайт маскировки, лишние порты). Вывод можно просто прислать в чат, если нужна помощь.
|
||||
|
||||
`kit fix` лечит только безопасное: перезапускает упавшие службы, возвращает права `600`, включает
|
||||
`kit check --fix` лечит только безопасное: перезапускает упавшие службы и ядро Xray, возвращает права `600`, включает
|
||||
автообновление, перечитывает сертификат и снимает TLS у встроенной подписки 3X-UI (его включает команда
|
||||
`x-ui cert`, и подписка перестаёт отвечать). Ничего необратимого он не делает.
|
||||
|
||||
> [!NOTE]
|
||||
> Проверка сайта маскировки идёт с самого сервера: доступность из вашей сети она не покажет.
|
||||
> Проверка идёт с самого сервера: доступность из вашей сети она не покажет.
|
||||
|
||||
## Протоколы, порты и сайт маскировки (`kit net`)
|
||||
|
||||
```bash
|
||||
kit net # протоколы, порты, статус, сайт маскировки и отпечаток одним экраном
|
||||
kit net site # подобрать сайт маскировки среди соседей по подсети (около минуты)
|
||||
kit net site сайт.com # или задать самому (проверю TLS 1.3 и HTTP/2)
|
||||
kit net port имя порт # сменить порт подключения: ufw и ссылки обновятся сами
|
||||
kit net off vmess-ws # выключить протокол и закрыть его порт (вернуть: kit net on vmess-ws)
|
||||
kit net on reality2 # создать второй REALITY на высоком порту
|
||||
kit net fp firefox # сменить отпечаток клиента у всех подключений
|
||||
kit net masq on # Hysteria2 отвечает на чужой запрос страницей сайта
|
||||
kit net panel domain # панель и подписка открываются и по домену (только со своим доменом); kit net panel ip – как раньше
|
||||
kit net split on # через VPN только список (по умолчанию популярные иностранные сервисы), остальное напрямую; kit net split check | off
|
||||
```
|
||||
|
||||
Панель меняет подключения на ходу, без перезапуска. Если ядро Xray не приняло правку и порт закрылся (так бывает с
|
||||
маскировкой Hysteria2), `kit net` сам перезапускает ядро и говорит об этом: подключения прерываются на пару секунд.
|
||||
|
||||
Со своим доменом панель и подписка по умолчанию открываются по IP (`https://IP/путь/`). Если нужен адрес с доменом (например, для Telegram-бота панели
|
||||
или ссылки, которая не зависит от IP), выберите «по домену» при установке (`--panel-on domain`) или позже: `kit net panel domain`. Путь у них секретный и по домену, как и по IP.
|
||||
|
||||
### Раздельная маршрутизация (`kit net split`)
|
||||
|
||||
Если нужно, чтобы через VPN шли только выбранные сервисы, а всё остальное напрямую, включите: `kit net split on`. На сервере появится
|
||||
файл `/etc/kit-sub/rules.yaml` со списком иностранных сервисов (по умолчанию популярные: нейросети, видео и музыка, соцсети и мессенджеры, GitHub, Google и другие, всего около 50 категорий; тяжёлые и региональные – в комментариях, раскомментируйте нужные). Российские сайты в список не добавляйте: всё, чего в нём нет, и так идёт напрямую.
|
||||
Правила приезжают вместе с подпиской, на телефонах и компьютерах ничего настраивать не нужно: достаточно обновить подписку в приложении.
|
||||
|
||||
| Приложение | Как получает правила |
|
||||
|---|---|
|
||||
| Clash Verge, FlClash, Mihomo Party (ядро Mihomo) | из подписки, сразу (режим «Rule», не «Global») |
|
||||
| Happ | заголовком подписки (профиль «3X-UI KIT» включается сам) |
|
||||
| Приложения, которые берут JSON-подписку Xray (адрес «JSON» со страницы подписки) | из конфига подписки |
|
||||
| v2rayN, v2rayNG и другие с обычным списком ссылок, Karing, Hiddify, sing-box | не получают: подписка прежняя, весь трафик через VPN; раздельную маршрутизацию там настраивают в самом приложении |
|
||||
|
||||
```
|
||||
kit net split on # создать список и включить
|
||||
nano /etc/kit-sub/rules.yaml
|
||||
kit net split check # проверить файл: сколько правил применится и что отброшено
|
||||
kit net split apply # после правки списка: обновить профили Happ и Xray в панели (Mihomo берёт файл сам)
|
||||
kit net split off # выключить (список сохранится в rules.yaml.off)
|
||||
```
|
||||
|
||||
Формат: `GEOSITE,youtube` (готовая категория), `DOMAIN-SUFFIX,example.org`, `DOMAIN,api.example.org`, `DOMAIN-KEYWORD,слово`, `IP-CIDR,203.0.113.0/24`.
|
||||
Лишнее и непонятное KIT отбрасывает; если список пуст или повреждён, подписка отдаётся как обычно. Список входит в `kit backup`.
|
||||
DNS при этом не течёт: имена из списка у клиента не разрешаются, а запросы про них идут по DoH через VPN; для остального сайта используется
|
||||
системный DNS приложения (или адреса `direct_dns` из файла). Категории GEOSITE приложение один раз скачивает с GitHub.
|
||||
|
||||
Лучший сайт маскировки – ваш собственный домен (`--domain` при установке): сервер изображает ваш сайт, а сертификат
|
||||
и имя совпадают. Без домена установщик подбирает сайт среди соседей по подсети сервера: около 250 коротких обращений к порту 443
|
||||
соседних адресов (не нужно – укажите `--sni сайт` или `--domain`). Любой протокол можно выключить командой `kit net off имя` и включить обратно `kit net on имя`.
|
||||
|
||||
## Резервная копия
|
||||
|
||||
@@ -178,6 +235,23 @@ scp root@IP:/root/kit-backup-ДАТА.tar.gz .
|
||||
|
||||
Храните копию отдельно от сервера.
|
||||
|
||||
Новый чистый сервер поднимается из копии одной командой (нужны `3x-ui.sh` и файл копии):
|
||||
|
||||
```bash
|
||||
bash 3x-ui.sh --restore /root/kit-backup-ДАТА.tar.gz -y
|
||||
```
|
||||
|
||||
Подключения, ключи, пользователи и их лимиты вернутся прежними. Если адрес сервера сменился, подписку клиентам нужно
|
||||
добавить заново, а на домене ничего менять не придётся: достаточно переключить DNS.
|
||||
|
||||
Сертификат Let's Encrypt на новый IP выпускается заново. Если он не выдался (лимит – 5 сертификатов в неделю на один IP,
|
||||
или закрыт входящий порт 80), скрипт скажет об этом и поставит самоподписанный сертификат с отпечатком в ссылках:
|
||||
ссылки на подключения работают, а подписка в приложениях может не открыться. Если ядро Xray после восстановления
|
||||
не запустилось, скрипт не скажет «Готово», а покажет, где искать причину.
|
||||
|
||||
Восстанавливайте только из своей копии. Архив с чужими путями, ссылками, лишними настройками или значениями
|
||||
не в своём формате (порт – не число, путь со странными символами, непечатные байты в файлах настроек) скрипт не примет («Это не резервная копия 3X-UI KIT…») и ничего не изменит.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Что делает |
|
||||
@@ -214,4 +288,4 @@ x-ui
|
||||
|
||||
---
|
||||
|
||||
[← На главную](../README.md) · [Дальше: 🚀 Hysteria2 →](hysteria2.md)
|
||||
[← На главную](../README.md)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="190" viewBox="0 0 860 190" role="img" aria-label="3X-UI autoVPS — сборка форка">
|
||||
<style>.t{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
|
||||
.m{font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}</style>
|
||||
<rect x="0.5" y="0.5" width="859" height="189" rx="16" fill="#15110f" stroke="#43372e"/>
|
||||
<circle cx="748" cy="46" r="58" fill="#93e06f" opacity="0.07"/>
|
||||
<circle cx="808" cy="140" r="34" fill="#93e06f" opacity="0.05"/>
|
||||
<text x="44" y="86" class="t" font-size="46" font-weight="700" fill="#fbf6ec">3X-UI <tspan fill="#93e06f">autoVPS</tspan></text>
|
||||
<text x="44" y="118" class="t" font-size="16" fill="#cbbfa9">сервер одной командой: панель 3X-UI, VPN-протоколы, один порт 443, одна подписка</text>
|
||||
<text x="44" y="152" class="m" font-size="13" fill="#8a8073">свой канал обновлений · подписанные релизы · 13 протоколов · 10 сайтов-заглушек</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="305" viewBox="0 0 860 305" role="img" aria-label="root@vps: ~"><title>root@vps: ~</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="859" height="304" rx="14" fill="#15110f" stroke="#43372e" stroke-width="1" /><path d="M1 44 H859" stroke="#43372e"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="430.0" y="27" class="m" font-size="13" fill="#cbbfa9" font-weight="500" text-anchor="middle">root@vps: ~</text><text x="22" y="70" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="70" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 add friend</text><text x="22" y="91" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="91" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователь friend добавлен.</text><text x="22" y="112" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2://friend:4b7d…@hy.example.com:443/?sni=hy.example.com#friend</text><text x="22" y="133" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="154" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="154" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 list</text><text x="22" y="175" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователи:</text><text x="22" y="196" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"> • admin</text><text x="22" y="217" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"> • friend</text><text x="22" y="238" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="259" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="259" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 del friend</text><text x="22" y="280" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="280" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователь friend удалён, его ссылка больше не работает.</text></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="368" viewBox="0 0 860 368" role="img" aria-label="root@vps: ~"><title>root@vps: ~</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="859" height="367" rx="14" fill="#15110f" stroke="#43372e" stroke-width="1" /><path d="M1 44 H859" stroke="#43372e"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="430.0" y="27" class="m" font-size="13" fill="#cbbfa9" font-weight="500" text-anchor="middle">root@vps: ~</text><text x="22" y="70" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="70" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">bash <(curl -fsSL https://raw.githubusercontent.com/…/scripts/hysteria2.sh)</text><text x="22" y="91" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="112" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Установка Hysteria2</text><rect x="10" y="118" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="128.5" r="11" fill="#93e06f"/><text x="832" y="133.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">1</text><text x="22" y="133" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Домен (Enter – без домена): </text><text x="249.64000000000001" y="133" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">hy.example.com</text><rect x="10" y="139" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="149.5" r="11" fill="#93e06f"/><text x="832" y="154.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">1</text><text x="22" y="154" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Почта для Let's Encrypt: </text><text x="225.25000000000003" y="154" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">you@example.com</text><text x="22" y="175" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="175" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Скачиваю Hysteria 2.12.3 (amd64) с GitHub</text><text x="22" y="196" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="196" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Контрольная сумма совпала: 8c7a68a906998b74…</text><text x="22" y="217" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="217" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Настраиваю ufw: SSH 22/tcp, Hysteria 443/udp, сайт 80/tcp и 443/tcp</text><text x="22" y="238" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="238" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Запускаю Hysteria и получаю сертификат для hy.example.com</text><text x="22" y="259" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="280" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">Готово! Hysteria2 работает.</text><text x="22" y="301" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><rect x="10" y="307" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="317.5" r="11" fill="#93e06f"/><text x="832" y="322.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">2</text><text x="22" y="322" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2://admin:9c1e…@hy.example.com:443/?sni=hy.example.com#admin</text><text x="22" y="343" class="m" font-size="13.5" fill="#cbbfa9" xml:space="preserve">[ здесь QR-код – отсканируйте его телефоном ]</text></svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,77 +0,0 @@
|
||||
# 🚀 Hysteria2
|
||||
|
||||
[← На главную](../README.md)
|
||||
|
||||
[Hysteria2](https://github.com/HyNetworks/hysteria) работает поверх UDP и
|
||||
хорошо держит скорость на плохих каналах – отличная пара к REALITY. Одна
|
||||
команда ставит сервер, сертификат и сайт-заглушку и выдаёт готовую ссылку.
|
||||
|
||||
**Что делает [скрипт](../scripts/hysteria2.sh):**
|
||||
|
||||
- ставит официальный Hysteria2 проверенной версии и сверяет контрольную сумму;
|
||||
- с доменом – получает сертификат Let's Encrypt, без домена – делает свой
|
||||
и закрепляет его отпечаток прямо в ссылке;
|
||||
- поднимает сайт-заглушку: при заходе на ваш домен откроется страница
|
||||
«RETRYING CONNECTION»;
|
||||
- создаёт пользователя со случайным паролем и открывает нужные порты в ufw;
|
||||
- выдаёт ссылку `hy2://` и QR-код;
|
||||
- ставит команду `hy2`, чтобы добавлять и удалять дополнительных пользователей без правки конфигов.
|
||||
|
||||
## Что понадобится
|
||||
|
||||
- VPS с Ubuntu 22.04/24.04 или Debian 12/13, доступ root по SSH
|
||||
- По желанию – домен, A-запись которого указывает на IP сервера
|
||||
|
||||
## Установка
|
||||
|
||||
Подключитесь к серверу по SSH и выполните:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/hysteria2.sh)
|
||||
```
|
||||
|
||||

|
||||
|
||||
1. Скрипт спросит **домен и почту**. Нет домена – просто нажмите Enter.
|
||||
2. В конце он покажет **ссылку** `hy2://` и QR-код.
|
||||
|
||||
> [!TIP]
|
||||
> Без домена всё тоже работает. Домен даёт настоящий сертификат и
|
||||
> сайт-заглушку, так что сервер снаружи выглядит обычным сайтом.
|
||||
|
||||
## Подключение
|
||||
|
||||
Ссылку `hy2://` поддерживают:
|
||||
|
||||
| Устройство | Приложение |
|
||||
|---|---|
|
||||
| iPhone, iPad | Streisand, Hiddify, Happ |
|
||||
| Android | Hiddify, NekoBox |
|
||||
| Windows, macOS, Linux | Hiddify, v2rayN |
|
||||
|
||||
## Дополнительные пользователи и управление
|
||||
|
||||

|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `hy2 add имя` | добавить пользователя и показать его ссылку |
|
||||
| `hy2 del имя` | удалить – его ссылка сразу перестанет работать |
|
||||
| `hy2 list` | список пользователей |
|
||||
| `hy2 link имя` | ссылка и QR-код ещё раз |
|
||||
| `hy2 status` | версия, адрес и состояние |
|
||||
| `hy2 restart` | перезапустить, например после исправления DNS |
|
||||
| `hy2 update` | обновить до новой проверенной версии |
|
||||
| `hy2 uninstall` | удалить всё |
|
||||
|
||||
## Если что-то пошло не так
|
||||
|
||||
- **«Не удалось получить сертификат»** – A-запись домена ещё не обновилась
|
||||
или порт 80 закрыт у хостера. Исправьте и выполните `hy2 restart`.
|
||||
- **Клиент не подключается** – проверьте, что у хостера открыт UDP-порт 443
|
||||
(у некоторых хостеров есть свой файрвол в личном кабинете).
|
||||
- **Порт 443 занят** – поставьте на другой: добавьте к команде установки `--port 8443`.
|
||||
|
||||
---
|
||||
|
||||
[← На главную](../README.md) · [Дальше: 📶 XKeen на Keenetic →](xkeen-keenetic.md)
|
||||
@@ -0,0 +1,604 @@
|
||||
#!/usr/bin/env bash
|
||||
# 3X-UI_autoVPS (форк hacker07) — установка сервера одной командой из нашего канала.
|
||||
#
|
||||
# Что делает (по шагам):
|
||||
# 1) проверяет сервер и наличие curl, ssh-keygen, openssl;
|
||||
# 2) берёт из нашего Gitea релиз своей же версии, проверяет ПОДПИСЬ SHA256SUMS и SHA256 установщика;
|
||||
# 3) запускает установщик автора (scripts/3x-ui.sh) — панель 3X-UI, протоколы, сайт-заглушка,
|
||||
# маскировка, сертификат, подписка, ufw. Флаги установщика можно передавать как есть,
|
||||
# а можно ничего не передавать — тогда он задаст вопросы сам (в том числе про домен);
|
||||
# 4) спрашивает, какие протоколы ставить: невыбранные не создаются вообще — ни подключения в панели,
|
||||
# ни записей в nginx, ни ссылок в подписке (за это отвечает флаг автора --protocols);
|
||||
# 5) ставит сайт-заглушку: случайный правдоподобный ИТ-сайт из нашего набора (10 штук;
|
||||
# --site N — конкретный, --no-site — не трогать авторскую страницу);
|
||||
# 6) спрашивает и делает то, чего у автора нет: закрыть ответ на ping, включить обновления
|
||||
# безопасности, перевести SSH на другой порт, сменить пароль root (поведение из StressOzz/vps-setup,
|
||||
# но безопаснее);
|
||||
# 7) в конце: kit fix (время и безопасные починки), kit check, kit local hardening;
|
||||
# 8) последним — подписка, QR-код и ссылки по протоколам: то, что нужно сохранить.
|
||||
#
|
||||
# Запуск:
|
||||
# # одна команда, дальше — вопросы:
|
||||
# bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
|
||||
# # или сразу с ответами, без вопросов:
|
||||
# ... 3x-ui-local.sh --domain vpn.example.com
|
||||
# ... 3x-ui-local.sh --sni dl.google.com --no-harden --ssh-port 2222
|
||||
# # без вопросов, всё по умолчанию (для автоматизации):
|
||||
# ... 3x-ui-local.sh --yes
|
||||
#
|
||||
# Флаги этого скрипта (остальные уходят установщику автора):
|
||||
# --domain ДОМЕН, --sni САЙТ, --panel-on ip|domain, --host IP, --user ИМЯ, --port N, --restore ФАЙЛ
|
||||
# --harden закрыть ping и включить автообновления, без вопросов
|
||||
# --no-harden не трогать ping и автообновления
|
||||
# --ssh-port N добавить порт SSH N (прежний порт продолжает работать)
|
||||
# --yes ни о чём не спрашивать (ping закрою, автообновления включу, порт и пароль — нет)
|
||||
# --no-time не запускать kit fix
|
||||
# --no-check не запускать в конце kit check
|
||||
# --clean убрать прошлую установку и поставить заново (SSH не трогает)
|
||||
# --auto-sni не предлагать выбор сайта маскировки (иначе покажем меню по скану подсети)
|
||||
# --verify-only только проверить релиз (подпись и SHA256) и выйти
|
||||
# --dry-run показать план и ничего не делать
|
||||
# -h | --help эта справка
|
||||
#
|
||||
# Наш файл: авторские релизы его не перезаписывают, а правки форка живут в scripts/kit-local.sh.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
KIT_VERSION="1.2.1.6" # версия сборки форка; должна совпадать с VERSION релиза
|
||||
GITEA="${KIT_FORK_BASE:-https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS}"
|
||||
NS="3x-ui-kit-release"
|
||||
SIG_ID="releases@3x-ui-kit"
|
||||
# Публичный ключ форка. Меняется только вместе с ключом подписи (тогда обновляется и здесь, и в kit).
|
||||
SIGNER_PUB='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)'
|
||||
|
||||
G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m'
|
||||
[[ -t 1 ]] || { G=; Y=; R=; B=; D=; N=; }
|
||||
say() { printf '%s\n' "${G}==>${N} $*"; }
|
||||
warn() { printf '%s\n' "${Y}!${N} $*" >&2; }
|
||||
die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
3X-UI_autoVPS (форк hacker07) — установка сервера одной командой из нашего канала.
|
||||
|
||||
bash <(curl -fsSL <наш Gitea>/raw/branch/main/scripts/3x-ui-local.sh) [флаги…]
|
||||
|
||||
Без флагов скрипт спрашивает: какие протоколы ставить (невыбранные не создаются вообще),
|
||||
маскировку и домен — это вопросы установщика автора; затем про ответ на ping, автообновления,
|
||||
второй порт SSH и пароль root — это наши вопросы.
|
||||
|
||||
Флаги, которые скрипт передаёт установщику автора как есть:
|
||||
--domain vpn.example.com свой домен для маскировки (A-запись на этот сервер, свободный 80)
|
||||
--sni сайт.com маскировка под чужой сайт
|
||||
--panel-on domain | ip где открывать панель и подписку
|
||||
--host IP, --user имя, --port N, --protocols …, --restore файл
|
||||
|
||||
Флаги этого скрипта:
|
||||
--harden закрыть ping и включить автообновления, без вопросов
|
||||
--no-harden не трогать ping и автообновления
|
||||
--ssh-port N перевести SSH на порт N (прежний закроется после проверки входа)
|
||||
--yes ни о чём не спрашивать (для автоматизации)
|
||||
--site N|random|off сайт-заглушка: конкретный из нашего набора, случайный (по умолчанию) или
|
||||
не трогать авторскую страницу (то же: --no-site)
|
||||
--no-sub не показывать в конце подписку, доступ к панели и QR-код
|
||||
--clean снести прошлую установку и поставить заново (если прошлый запуск оборвался)
|
||||
--auto-sni не предлагать выбор сайта маскировки (автоподбор автора)
|
||||
--no-time не запускать kit fix (время и безопасные починки)
|
||||
--no-check не запускать в конце kit check
|
||||
--verify-only только проверить релиз (подпись и SHA256) и выйти
|
||||
--dry-run показать план и ничего не делать
|
||||
-h | --help эта справка
|
||||
EOF
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ разбор флагов
|
||||
|
||||
INSTALLER_ARGS=(); harden=auto; ssh_port=""; fix_time=yes; do_check=yes; dry=no; verify_only=no; assume_yes=no
|
||||
site=random # заглушка: случайный сайт из нашего набора (off — не трогать)
|
||||
show_sub=yes # в самом конце показать подписку, QR-код и ссылки
|
||||
clean=no # --clean: убрать прошлую установку перед новой
|
||||
pick_sni=yes # показывать меню выбора сайта маскировки по результатам скана
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--harden) harden=yes ;;
|
||||
--no-harden) harden=no ;;
|
||||
--ssh-port) ssh_port=${2:-}; shift || die "--ssh-port: нужен номер порта" ;;
|
||||
--ssh-port=*) ssh_port=${1#*=} ;;
|
||||
--yes | -y) assume_yes=yes ;;
|
||||
--site) site=${2:-random}; shift || die "--site: нужен номер сайта (1..10), random или off" ;;
|
||||
--site=*) site=${1#*=} ;;
|
||||
--no-site) site=off ;;
|
||||
--no-sub) show_sub=no ;;
|
||||
--clean) clean=yes ;;
|
||||
--auto-sni) pick_sni=no ;;
|
||||
--no-time) fix_time=no ;;
|
||||
--no-check) do_check=no ;;
|
||||
--verify-only) verify_only=yes ;;
|
||||
--dry-run) dry=yes ;;
|
||||
-h | --help) usage; exit 0 ;;
|
||||
*) INSTALLER_ARGS+=("$1") ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[[ -z $ssh_port ]] || { [[ $ssh_port =~ ^[0-9]{1,5}$ ]] && ((10#$ssh_port >= 1024 && 10#$ssh_port <= 65535)); } \
|
||||
|| die "--ssh-port: нужно число 1024–65535, например 2222"
|
||||
if [[ $site != off && $site != random ]]; then
|
||||
# 10 — число сайтов в наборе (scripts/kit-local.sh, KL_SITE_TITLES); добавите сайт — поднимите границу
|
||||
[[ $site =~ ^[0-9]{1,2}$ ]] && ((10#$site >= 1 && 10#$site <= 10)) \
|
||||
|| die "--site: random, off или номер 1..10 (список: kit local site --list)"
|
||||
fi
|
||||
|
||||
# Если --protocols задан флагом — не спрашиваем и не дублируем его.
|
||||
proto_flag=""
|
||||
for ((_i = 0; _i < ${#INSTALLER_ARGS[@]}; _i++)); do
|
||||
[[ ${INSTALLER_ARGS[_i]} == --protocols ]] && proto_flag=${INSTALLER_ARGS[_i + 1]:-all}
|
||||
done
|
||||
|
||||
pre() { printf ' %s\n' "$*"; }
|
||||
ASK_SRC=${KIT_ASK_SRC:-/dev/tty} # откуда читать ответы (в тестах подменяется файлом)
|
||||
interactive() { [[ $assume_yes == no && -r $ASK_SRC ]]; }
|
||||
|
||||
if [[ $dry == yes ]]; then
|
||||
say "план установки (--dry-run: ничего не делаю)"
|
||||
pre "версия сборки: $KIT_VERSION"
|
||||
pre "откуда: $GITEA (тег v$KIT_VERSION), подпись проверяется ключом форка"
|
||||
[[ $clean == yes ]] && pre "прошлая установка: уберу её перед установкой (--clean), SSH не трону"
|
||||
if [[ $pick_sni == yes ]]; then pre "маскировка: просканирую подсеть и предложу выбрать сайт (--auto-sni — автоподбор)"
|
||||
else pre "маскировка: подберу сайт сам, без вопросов (--auto-sni)"; fi
|
||||
if ((${#INSTALLER_ARGS[@]})); then pre "установщик автора: bash scripts/3x-ui.sh ${INSTALLER_ARGS[*]}"; else pre "установщик автора: bash scripts/3x-ui.sh — спросит про маскировку и домен"; fi
|
||||
if [[ $harden == auto ]]; then
|
||||
if interactive; then pre "защита: спрошу про ответ на ping и автообновления"
|
||||
else pre "защита: без терминала — закрыть ping и включить автообновления"; fi
|
||||
elif [[ $harden == yes ]]; then pre "защита: закрыть ping и включить автообновления (--harden)"
|
||||
else pre "защита: пропускаю (--no-harden)"; fi
|
||||
if [[ -n $proto_flag ]]; then pre "протоколы: ${proto_flag} (задано флагом)"
|
||||
elif interactive; then pre "протоколы: спрошу (Enter — REALITY + AmneziaWG-3.1, «все» — набор автора)"
|
||||
else pre "протоколы: REALITY + AmneziaWG-3.1 (без терминала вопроса нет)"; fi
|
||||
if [[ -n $ssh_port ]]; then pre "порт SSH: перевожу на $ssh_port (прежний — после проверки входа)"
|
||||
elif interactive; then pre "порт SSH: спрошу (Enter — оставить текущий)"; fi
|
||||
if interactive; then pre "пароль root: спрошу (по умолчанию — не менять)"; fi
|
||||
case $site in
|
||||
off) pre "заглушка: оставляю авторскую страницу (--no-site)" ;;
|
||||
random) pre "заглушка: случайный ИТ-сайт из нашего набора (10 штук)" ;;
|
||||
*) pre "заглушка: сайт №$site из нашего набора (список: kit local site --list)" ;;
|
||||
esac
|
||||
[[ $fix_time == yes ]] && pre "после: kit fix (время, безопасные починки)"
|
||||
[[ $do_check == yes ]] && pre "проверка: kit check + kit local hardening"
|
||||
[[ $show_sub == yes ]] && pre "в самом конце: доступ к панели 3X-UI, подписка, QR-код и ссылки (--no-sub — не надо)"
|
||||
echo
|
||||
say "запуск без --dry-run выполнит всё это на сервере"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ релиз: скачать и проверить подпись
|
||||
|
||||
# Подпись проверяем ДО запуска чужого кода, поэтому здесь нужны только curl/ssh-keygen/openssl.
|
||||
for c in curl ssh-keygen openssl; do command -v "$c" >/dev/null || die "нет $c — поставьте: apt-get update && apt-get install -y $c"; done
|
||||
|
||||
say "беру релиз v$KIT_VERSION и проверяю подпись"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
raw="$GITEA/raw/tag/v$KIT_VERSION"
|
||||
for f in SHA256SUMS SHA256SUMS.sig; do
|
||||
mkdir -p "$tmp/$(dirname "$f")"
|
||||
curl -fsSL --retry 3 -m 60 -o "$tmp/$f" "$raw/$f" || die "не скачался $f из $raw (Gitea доступна с сервера?)"
|
||||
done
|
||||
printf '%s namespaces="%s" %s\n' "$SIG_ID" "$NS" "$SIGNER_PUB" >"$tmp/allowed_signers"
|
||||
ssh-keygen -Y verify -f "$tmp/allowed_signers" -I "$SIG_ID" -n "$NS" -s "$tmp/SHA256SUMS.sig" <"$tmp/SHA256SUMS" >/dev/null 2>&1 \
|
||||
|| die "подпись релиза v$KIT_VERSION не сошлась с ключом форка — это не наш релиз, ничего не запускаю"
|
||||
sum=$(awk '{f = $2; sub(/^\*/, "", f)} f == "scripts/3x-ui.sh" {print $1}' "$tmp/SHA256SUMS")
|
||||
[[ $(printf '%s' "$sum" | wc -c) -eq 64 ]] || die "в SHA256SUMS нет установщика — релиз собран неверно"
|
||||
got=$(sha256sum "$tmp/scripts/3x-ui.sh" | awk '{print $1}')
|
||||
[[ $got == "$sum" ]] || die "SHA256 установщика не совпал с подписанным — не запускаю"
|
||||
# Качаем все остальные файлы релиза и сверяем каждый с подписанным списком. Раньше брали только
|
||||
# установщик, поэтому kit-local.sh (в нём наша страница-заглушка) отсутствовал, и подготовка
|
||||
# заглушки до установщика падала с «kl_site_html: command not found».
|
||||
while read -r sum2 name2; do
|
||||
case $sum2 in \#* | "") continue ;; esac
|
||||
[[ $name2 == scripts/3x-ui.sh ]] && continue
|
||||
mkdir -p "$tmp/$(dirname "$name2")"
|
||||
curl -fsSL --retry 3 -m 60 -o "$tmp/$name2" "$raw/$name2" || die "не скачался $name2 из $raw (Gitea доступна с сервера?)"
|
||||
got2=$(sha256sum "$tmp/$name2" | awk '{print $1}')
|
||||
[[ $got2 == "$sum2" ]] || die "SHA256 не совпал у $name2 — не запускаю"
|
||||
done <"$tmp/SHA256SUMS"
|
||||
bash -n "$tmp/scripts/3x-ui.sh" || die "установщик не проходит проверку синтаксиса"
|
||||
[[ -r $tmp/scripts/kit-local.sh ]] || die "в релизе нет scripts/kit-local.sh — сборка неполная"
|
||||
printf ' ✓ подпись сошлась, SHA256 установщика совпал\n'
|
||||
|
||||
if [[ $verify_only == yes ]]; then
|
||||
say "релиз v$KIT_VERSION проверен: подпись и SHA256 сошлись, установщик не запускался"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ предполётные проверки
|
||||
|
||||
say "проверяю сервер"
|
||||
[[ ${EUID:-0} -eq 0 ]] || die "нужен root: запустите от root (на чистом VPS обычно так и есть)"
|
||||
[[ -r /etc/os-release ]] || die "не вижу /etc/os-release — это не Debian/Ubuntu?"
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
case ${ID:-} in debian | ubuntu | raspbian) ;; *) warn "система ${PRETTY_NAME:-?}: поддерживаются Debian и Ubuntu" ;; esac
|
||||
printf ' %s\n' "${PRETTY_NAME:-Linux}, ядро $(uname -r), памяти $(awk '/MemTotal/ {printf "%.1f ГБ", $2 / 1048576}' /proc/meminfo 2>/dev/null || echo '?')"
|
||||
df -P / | awk 'NR==2 {gsub("%","",$5); if ($5 >= 95) exit 1}' || warn "диск занят почти полностью — установка может не пройти"
|
||||
for p in 443 80; do ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$p\$" && warn "порт $p уже кем-то занят"; done
|
||||
|
||||
# ------------------------------------------------------------------ какие протоколы ставить
|
||||
|
||||
# Список, порядок и набор по умолчанию — как у установщика автора (scripts/3x-ui.sh:
|
||||
# ALL_PROTOS / DEFAULT_PROTOS). Невыбранные протоколы не создаются вообще: под них не будет
|
||||
# ни подключения в панели, ни записей в nginx, ни ссылок в подписке.
|
||||
ALL_PROTOS=(reality reality2 hy2 xhttp ws trojan vmess ss tuic wg awg awg3 mtproto)
|
||||
PROTO_HINT=(
|
||||
"REALITY основной: VLESS + Reality, порт 443"
|
||||
"REALITY-2 второй REALITY на случайном высоком порту"
|
||||
"Hysteria2 быстрее на плохой сети (UDP 443)"
|
||||
"XHTTP VLESS + Reality под другим именем маскировки"
|
||||
"VLESS-WS VLESS через WebSocket"
|
||||
"Trojan-gRPC Trojan через gRPC"
|
||||
"VMess-WS VMess через WebSocket"
|
||||
"Shadowsocks Shadowsocks 2022"
|
||||
"TUIC быстрый UDP-протокол"
|
||||
"WireGuard обычный WireGuard: легко распознаётся, обычно не ставят"
|
||||
"AmneziaWG AmneziaWG: Mihomo, роутеры Keenetic"
|
||||
"AmneziaWG-3.1 AmneziaWG с защитой заголовков: AmneziaVPN"
|
||||
"MTProto прокси для Telegram"
|
||||
)
|
||||
# Наш набор по умолчанию: VLESS REALITY и AmneziaWG-3.1 — самый совместимый минимум.
|
||||
# Набор автора (флаг --protocols all) доступен ответом «все».
|
||||
AUTHOR_PROTOS=(reality hy2 xhttp ws trojan vmess ss tuic awg awg3 mtproto)
|
||||
DEFAULT_PROTOS=(reality awg3)
|
||||
|
||||
proto_menu() {
|
||||
local i n mark
|
||||
say "какие протоколы поставить?" >&2
|
||||
for i in "${!ALL_PROTOS[@]}"; do
|
||||
n=$((i + 1)) mark=" "
|
||||
[[ " ${DEFAULT_PROTOS[*]} " == *" ${ALL_PROTOS[i]} "* ]] && mark="*"
|
||||
printf ' %s %2d. %s\n' "$mark" "$n" "${PROTO_HINT[i]}" >&2
|
||||
done
|
||||
printf ' %s\n' "${D}[* — по умолчанию: REALITY + AmneziaWG-3.1. «все» — набор автора (всё, кроме WireGuard).${N}" >&2
|
||||
printf ' %s\n' "${D}[Невыбранные протоколы не создаются вообще: ни подключения, ни записей в nginx, ни ссылок в подписке.]${N}" >&2
|
||||
}
|
||||
|
||||
proto_parse() { # ответ → выбранные имена через запятую; код 1, если не разобрал
|
||||
local ans=${1,,} tok i all=" ${ALL_PROTOS[*]} " picked=""
|
||||
ans=${ans//[[:space:]]/,} # «1 3 11» и «1,3,11» — одно и то же
|
||||
[[ -n $ans ]] || { printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"; return 0; }
|
||||
case $ans in
|
||||
all | все | всё) printf '%s' "$(IFS=,; echo "${AUTHOR_PROTOS[*]}")"; return 0 ;;
|
||||
minimal | минимум) printf '%s' "reality"; return 0 ;;
|
||||
esac
|
||||
local IFS=','
|
||||
for tok in $ans; do
|
||||
[[ -n $tok ]] || continue # двойные запятые и пробелы — не ошибка
|
||||
if [[ $tok =~ ^[0-9]{1,2}$ ]]; then
|
||||
i=$((10#$tok - 1))
|
||||
((i >= 0 && i < ${#ALL_PROTOS[@]})) || return 1
|
||||
tok=${ALL_PROTOS[i]}
|
||||
fi
|
||||
[[ $all == *" $tok "* ]] || return 1 # только имена из списка автора
|
||||
[[ ,$picked, == *",$tok,"* ]] || picked+="${picked:+,}$tok"
|
||||
done
|
||||
[[ -n $picked ]] || return 1
|
||||
printf '%s' "$picked"
|
||||
}
|
||||
|
||||
ask_protocols() { # печатает итоговый список через запятую
|
||||
local i ans picked
|
||||
interactive || { printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"; return 0; }
|
||||
proto_menu
|
||||
for i in 1 2 3; do
|
||||
printf '\n%sНомера через пробел (например 1 12) или «все»; Enter — REALITY + AmneziaWG-3.1: %s' "$B" "$N" >&2
|
||||
ans=""
|
||||
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
|
||||
if picked=$(proto_parse "$ans"); then printf '%s' "$picked"; return 0; fi
|
||||
warn "не понял «$ans» — нужны номера из списка через пробел или «все»"
|
||||
done
|
||||
warn "оставляю набор по умолчанию"
|
||||
printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ снос прошлой установки (--clean)
|
||||
|
||||
# Нужен, когда прошлая установка оборвалась: авторский установщик в таком случае отказывается работать
|
||||
# («3X-UI уже установлена этим скриптом»). Убираем то, что создаёт установка, и ставим заново.
|
||||
# SSH не трогаем: ни порт, ни конфиги — иначе можно потерять доступ к серверу.
|
||||
clean_previous() {
|
||||
say "убираю прошлую установку (--clean)"
|
||||
local s p
|
||||
for s in x-ui kit-sub kit-update.timer kit-update.service kit-limit.timer kit-limit.service \
|
||||
$(systemctl list-unit-files --no-legend 2>/dev/null | awk '{print $1}' | grep -E '^(x-ui|kit-|kit\.)' | grep -E '\.(service|timer)$'); do
|
||||
systemctl list-unit-files 2>/dev/null | grep -q "^$s" || continue
|
||||
systemctl disable --now "$s" >/dev/null 2>&1 || true
|
||||
printf ' остановлено: %s\n' "$s"
|
||||
done
|
||||
for p in /usr/local/x-ui /etc/x-ui /usr/bin/x-ui /usr/bin/x-ui.new /etc/systemd/system/x-ui.service \
|
||||
/usr/local/bin/kit /usr/local/lib/kit /usr/local/lib/kit-sub /etc/kit /etc/kit-sub \
|
||||
/etc/systemd/system/kit-sub.service /etc/systemd/system/kit-update.service \
|
||||
/etc/systemd/system/kit-update.timer /etc/systemd/system/kit-limit.service \
|
||||
/etc/systemd/system/kit-limit.timer \
|
||||
/etc/cron.d/kit-nginx-reload /etc/cron.d/kit-xui-menu /etc/cron.d/kit-sub-cert \
|
||||
/etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf /var/www/kit \
|
||||
/root/3x-ui.txt /root/cert /var/log/kit-install-check.log \
|
||||
/root/kit-local-backup-* /root/kit-backup-* /root/x-ui-before-sni-* /root/x-ui-before-update-*; do
|
||||
[[ -e $p ]] || continue
|
||||
rm -rf $p && printf ' удалено: %s\n' "$p"
|
||||
done
|
||||
if [[ -f /etc/nginx/nginx.conf ]] && grep -q 'kit-stream\.conf' /etc/nginx/nginx.conf; then
|
||||
sed -i '/kit-stream\.conf/d' /etc/nginx/nginx.conf
|
||||
printf ' из nginx.conf убрана наша строка включения\n'
|
||||
fi
|
||||
if id -u kit-sub >/dev/null 2>&1; then
|
||||
pkill -u kit-sub >/dev/null 2>&1 || true
|
||||
userdel -r kit-sub >/dev/null 2>&1 || true
|
||||
printf ' удалён пользователь kit-sub\n'
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
if [[ -f /root/3x-ui.txt && -x /usr/local/x-ui/x-ui ]]; then
|
||||
warn "часть файлов прошлой установки осталась — проверьте: ls -la /usr/local/x-ui /root/3x-ui.txt"
|
||||
else
|
||||
printf ' готово: место под установку свободно\n'
|
||||
fi
|
||||
}
|
||||
|
||||
installer_failed() {
|
||||
if grep -q "likely firewall problem" /var/log/kit-domain-cert.log 2>/dev/null \
|
||||
|| grep -q "Timeout during connect" /var/log/3x-ui-install.log 2>/dev/null; then
|
||||
printf '\n%s!%s %s\n' "$Y" "$N" \
|
||||
"Let's Encrypt не смог достучаться до порта 80 (в логе acme «Timeout during connect»)." >&2
|
||||
printf ' Проверьте по порядку: открыт ли 80 в ufw (скрипт делает это сам) и не режет ли его хостер.\n' >&2
|
||||
printf ' Либо ставьте без своего домена: в меню маскировки выберите «общедоступный сайт» —\n' >&2
|
||||
printf ' сертификат для домена тогда не нужен, панель останется на самоподписанном.\n' >&2
|
||||
fi
|
||||
if [[ -f /root/3x-ui.txt && -x /usr/local/x-ui/x-ui ]]; then
|
||||
printf '\n%s!%s %s\n' "$Y" "$N" \
|
||||
"На сервере осталась прошлая установка (установщик так и пишет). Запустите ещё раз с флагом --clean — он уберёт старое и поставит заново." >&2
|
||||
fi
|
||||
die "установщик остановился с ошибкой — смотрите его вывод выше"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ установка
|
||||
|
||||
if [[ -z $proto_flag ]]; then
|
||||
protos=$(ask_protocols)
|
||||
INSTALLER_ARGS+=(--protocols "$protos")
|
||||
else
|
||||
protos=$proto_flag
|
||||
fi
|
||||
say "протоколы: $protos — под остальные не создаётся ничего"
|
||||
|
||||
# ------------------------------------------------------------------ вопросы (всё спросим ДО установки)
|
||||
# После установщика на экране появляются доступы к панели: там вопросы уже неуместны. Поэтому
|
||||
# спрашиваем всё заранее, а действия выполняем после установки (см. «наши дополнения» ниже).
|
||||
|
||||
ask_yes() { # "вопрос" y|n — по умолчанию
|
||||
local q=$1 def=$2 ans=""
|
||||
interactive || { [[ $def == y ]]; return; }
|
||||
printf '\n%s [%s]: ' "$q" "$([[ $def == y ]] && echo 'Y/n' || echo 'y/N'])"
|
||||
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
|
||||
ans=${ans,,}; ans=${ans//[[:space:]]/}
|
||||
[[ -z $ans && $def == y ]] && return 0
|
||||
[[ $ans =~ ^(y|yes|д|да)$ ]]
|
||||
}
|
||||
|
||||
# порт SSH: по флагу или по вопросу. Смена делается в два шага: сначала ДОБАВИТЬ новый порт
|
||||
# (прежний продолжает работать), потом закрыть прежние — иначе одна опечатка оставляет сервер без доступа.
|
||||
ssh_ports_now() {
|
||||
local p
|
||||
p=$(kit local ssh --status 2>/dev/null | sed -n 's/^порты SSH сейчас: //p')
|
||||
[[ -n $p ]] || p=$(sshd -T 2>/dev/null | awk '$1 == "port" {print $2}' | paste -sd, -)
|
||||
[[ -n $p ]] || p=$(grep -rhiE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config /etc/ssh/sshd_config.d 2>/dev/null | awk '{print $2}' | paste -sd, -)
|
||||
printf '%s' "${p:-22}"
|
||||
}
|
||||
|
||||
ask_ssh_port() { # пусто — оставить как есть; иначе номер нового порта
|
||||
local cur ans try
|
||||
cur=$(ssh_ports_now)
|
||||
interactive || return 0
|
||||
for try in 1 2 3; do
|
||||
printf '\n%sСменить порт SSH? Сейчас %s. Введите новый номер, Enter — оставить как есть: %s' "$B" "$cur" "$N" >&2
|
||||
ans=""
|
||||
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
|
||||
ans=${ans//[[:space:]]/}
|
||||
[[ -n $ans ]] || { say "порт SSH оставляю как есть ($cur)" >&2; return 0; }
|
||||
if ! [[ $ans =~ ^[0-9]{1,5}$ ]] || ((10#$ans < 1024 || 10#$ans > 65535)); then
|
||||
warn "«$ans» не похоже на порт (нужно число 1024–65535)"
|
||||
continue
|
||||
fi
|
||||
if [[ ,$cur, == *",$ans,"* ]]; then
|
||||
say "порт $ans уже слушает SSH — менять нечего" >&2; return 0
|
||||
fi
|
||||
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$ans\$"; then
|
||||
warn "порт $ans/tcp занят другой программой"
|
||||
continue
|
||||
fi
|
||||
printf '%s' "$ans"; return 0
|
||||
done
|
||||
warn "оставляю прежний порт ($cur)"
|
||||
}
|
||||
|
||||
want_ping=$harden; want_update=$harden
|
||||
[[ $want_ping == auto ]] && want_ping=yes
|
||||
[[ $want_update == auto ]] && want_update=yes
|
||||
if [[ $harden == auto ]] && interactive; then
|
||||
if ask_yes "Закрыть ответ сервера на ping? Снаружи пинг перестанет отвечать (подписка и VPN не затрагиваются)." y; then want_ping=yes; else want_ping=no; fi
|
||||
if ask_yes "Включить автообновления безопасности? Только security-обновления, без перезагрузки." y; then want_update=yes; else want_update=no; fi
|
||||
fi
|
||||
|
||||
[[ -n $ssh_port ]] || ssh_port=$(ask_ssh_port)
|
||||
ssh_ports_before=$(ssh_ports_now) # для подсказки в конце: какие порты закрывать
|
||||
want_passwd=no
|
||||
if interactive; then
|
||||
if ask_yes "Сменить пароль root прямо сейчас? Новый пароль введёте в следующем окне." n; then want_passwd=yes; fi
|
||||
fi
|
||||
|
||||
[[ $clean == yes ]] && clean_previous
|
||||
|
||||
# Порты для сертификата и маскировки: открываем правила ufw ДО установщика и говорим, где смотреть,
|
||||
# если сертификат не выпустится (частая причина — свой файрвол у хостера, а не ufw).
|
||||
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "^Status: active"; then
|
||||
for _p in 80/tcp 443/tcp; do ufw allow "$_p" >/dev/null 2>&1 || true; done
|
||||
say "порты 80 и 443 открыты в ufw (нужны для сертификата и работы маскировки)"
|
||||
fi
|
||||
printf ' %sесли у хостера свой файрвол — 80 и 443 должны быть разрешены и там.%s\n' "$D" "$N"
|
||||
printf ' %sпризнак закрытого 80: сертификат не выпускается, в логе acme «Timeout during connect (likely firewall problem)»%s\n' "$D" "$N"
|
||||
|
||||
# Меню выбора сайта маскировки: сканируем подсеть один раз и предлагаем выбрать сайт отдельно для
|
||||
# REALITY и (если выбраны) для XHTTP/MTProto. Отключается флагом --auto-sni (тогда автоподбор автора).
|
||||
if [[ $pick_sni == yes ]]; then export KL_PICK_SNI=1; else export KL_PICK_SNI=0; fi
|
||||
|
||||
say "запускаю установщик автора (панель 3X-UI, протоколы, сайт-заглушка, подписка)"
|
||||
if ((${#INSTALLER_ARGS[@]})); then printf ' флаги: %s\n' "${INSTALLER_ARGS[*]}"; else printf ' флагов нет: установщик задаст вопросы (маскировка, домен)\n'; fi
|
||||
# stdin: если есть терминал — отдаём его установщику (там есть вопросы), иначе /dev/null,
|
||||
# иначе установщик начнёт читать наш собственный скрипт вместо ответов.
|
||||
if [[ -r /dev/tty ]]; then
|
||||
bash "$tmp/scripts/3x-ui.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"} </dev/tty || installer_failed
|
||||
else
|
||||
bash "$tmp/scripts/3x-ui.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"} </dev/null || installer_failed
|
||||
fi
|
||||
command -v kit >/dev/null || die "после установки нет команды kit — что-то пошло не так, смотрите вывод выше"
|
||||
|
||||
# Ставим нашу заглушку СРАЗУ (а не поверх авторской): страницу кладём до установщика, а его собственную
|
||||
# страницу из релиза убираем — иначе она перезапишет нашу на шаге сборки сайта.
|
||||
stub_staged=no
|
||||
if [[ $site != off ]]; then
|
||||
stub_n=""
|
||||
case $site in
|
||||
random) stub_n=$(( RANDOM % 10 )) ;;
|
||||
*) [[ $site =~ ^[0-9]+$ ]] && stub_n=$((10#$site - 1)) ;;
|
||||
esac
|
||||
if [[ -n $stub_n ]]; then
|
||||
install -d -m 755 /var/www/kit
|
||||
if ( source "$tmp/scripts/kit-local.sh" 2>/dev/null; kl_site_html "$stub_n" ) >/var/www/kit/index.html 2>"$tmp/stub.err" \
|
||||
&& [[ -s /var/www/kit/index.html ]]; then
|
||||
stub_staged=yes
|
||||
rm -f "$tmp/var/www/kit/index.html"
|
||||
say "заглушка: наш сайт №$((stub_n + 1)) — ставится сразу, авторская страница не используется"
|
||||
else
|
||||
warn "не смог подготовить нашу заглушку — поставлю после установки (kit local site). Причина: $(head -c 200 "$tmp/stub.err" 2>/dev/null | tr '\n' ' ')"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Запоминаем, какие протоколы выбраны: наши команды (kit local site и др.) смотрят сюда и не
|
||||
# делают ничего для протоколов, которых в этой установке нет.
|
||||
printf '%s\n' "${protos//,/ }" | tr ' ' '\n' | sed '/^$/d' > /etc/kit/kl-protos 2>/dev/null || true
|
||||
|
||||
# ------------------------------------------------------------------ наши дополнения (только действия)
|
||||
|
||||
# Ничего не спрашиваем: все ответы собраны до установщика. Здесь только выполняем.
|
||||
|
||||
# 443 нужен для маскировки и VPN, 80 — для выпуска сертификата: проверяем правила ufw ещё раз.
|
||||
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "^Status: active"; then
|
||||
for _p in 80/tcp 443/tcp; do ufw allow "$_p" >/dev/null 2>&1 || true; done
|
||||
fi
|
||||
|
||||
# Сайт-заглушка: у автора это случайные «кофейни и пекарни», у нас — правдоподобный ИТ-сайт.
|
||||
# Меняем не файл вручную, а командой форка: она же обновляет копию страницы в ответе Hysteria2,
|
||||
# иначе 443/tcp и UDP-порт начнут отвечать разными страницами (это видно снаружи).
|
||||
case ${stub_staged:-no} in
|
||||
yes) say "заглушка уже стоит (поставлена при установке, Hysteria2 синхронизирован тем же файлом)" ;;
|
||||
*) case $site in
|
||||
off) say "заглушку не трогаю (--no-site): остаётся страница автора" ;;
|
||||
random)
|
||||
say "ставлю сайт-заглушку: случайный ИТ-сайт из нашего набора"
|
||||
kit local site || warn "не удалось поставить заглушку — позже: kit local site"
|
||||
;;
|
||||
*)
|
||||
say "ставлю сайт-заглушку №$site"
|
||||
kit local site "$site" || warn "не удалось поставить заглушку — позже: kit local site $site"
|
||||
;;
|
||||
esac ;;
|
||||
esac
|
||||
|
||||
if [[ $fix_time == yes ]]; then
|
||||
say "kit fix: синхронизация времени и безопасные починки (авторская команда, ничего необратимого)"
|
||||
kit fix || warn "kit fix завершился с замечаниями — посмотрите вывод"
|
||||
fi
|
||||
|
||||
# Действия по ответам, которые были заданы ДО установщика (см. блок вопросов выше).
|
||||
if [[ $want_ping == yes ]]; then
|
||||
say "закрываю ответ на ping (правилом ufw)"
|
||||
kit local ping off || warn "не удалось — повторите: kit local ping off"
|
||||
fi
|
||||
if [[ $want_update == yes ]]; then
|
||||
say "включаю автообновления безопасности"
|
||||
kit local upgrades on || warn "не удалось — повторите: kit local upgrades on"
|
||||
fi
|
||||
|
||||
if [[ $want_passwd == yes ]]; then
|
||||
say "меняю пароль root"
|
||||
kit local passwd || warn "не получилось — повторите: kit local passwd"
|
||||
fi
|
||||
|
||||
# порт SSH: новый порт добавляем, прежний остаётся рабочим до вашей проверки входа.
|
||||
if [[ -n $ssh_port ]]; then
|
||||
say "перевожу SSH на порт $ssh_port: новый добавляю, прежний пока остаётся рабочим"
|
||||
kit local ssh "$ssh_port" || warn "порт не добавился — сделайте отдельно: kit local ssh $ssh_port"
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ итог: коротко и по делу
|
||||
|
||||
if [[ $do_check == yes || $show_sub == yes ]]; then
|
||||
# kit check печатает много строк: человеку нужны только замечания и одна строка «всё ли в порядке».
|
||||
chk=$(kit check 2>&1 || true)
|
||||
problems=$(printf '%s\n' "$chk" | grep -cE '^❌|^⚠|^!' || true)
|
||||
[[ $problems == 0 ]] || printf '%s\n' "$chk" | grep -E '^❌|^⚠|^!' | sed 's/^/ /'
|
||||
ports_open=""
|
||||
command -v ufw >/dev/null 2>&1 && ports_open=$(ufw status 2>/dev/null | awk '$2 == "ALLOW" { printf "%s ", $1 }')
|
||||
ip_show=$(hostname -I 2>/dev/null | awk '{ print $1 }')
|
||||
os_show=$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}" )
|
||||
cert_show="без сертификата: панель открывается через SSH-туннель"
|
||||
if [[ -s /root/cert/ip/fullchain.pem ]]; then
|
||||
if openssl x509 -in /root/cert/ip/fullchain.pem -noout -issuer 2>/dev/null | grep -q "Let's Encrypt"; then
|
||||
cert_show="${G}Let's Encrypt на IP — доверенный, браузер не ругается${N}"
|
||||
else
|
||||
cert_show="${Y}самоподписанный — браузер предупредит${N}"
|
||||
fi
|
||||
fi
|
||||
protos_show=$(tr '\n' ' ' </etc/kit/kl-protos 2>/dev/null || true)
|
||||
cat <<EOF
|
||||
|
||||
${B}────────────── Установка завершена ──────────────${N}
|
||||
сборка: 3X-UI_autoVPS $KIT_VERSION (канал: наш Gitea)
|
||||
сервер: ${ip_show:-?}${os_show:+ · $os_show}
|
||||
протоколы: ${protos_show:-—}
|
||||
сертификат: $cert_show
|
||||
проверка: $([[ $problems == 0 ]] && echo 'замечаний нет' || echo "замечаний: $problems (подробности: kit check)")
|
||||
${B}───────────────────────────────────────────────────${N}
|
||||
|
||||
${B}Доступ к панели${N} (файл /root/3x-ui.txt, виден только root)
|
||||
EOF
|
||||
# Автор пишет эти строки без ведущих пробелов (RESULT=/root/3x-ui.txt), поэтому ищем их как есть.
|
||||
if grep -aqE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt 2>/dev/null; then
|
||||
grep -aE '^[[:space:]]*(Панель|Логин|Пароль):' /root/3x-ui.txt | sed 's/^[[:space:]]*/ /'
|
||||
printf ' файл целиком (там же подписка и ссылки по протоколам): /root/3x-ui.txt\n'
|
||||
else
|
||||
warn "данных панели в /root/3x-ui.txt нет — смотрите вывод установщика выше"
|
||||
fi
|
||||
cat <<EOF
|
||||
|
||||
${B}Ссылки для подключения и QR-код${N} — в меню kit
|
||||
kit → Пользователи → Показать ссылки из консоли: kit local links
|
||||
EOF
|
||||
if [[ -n $ports_open ]]; then
|
||||
cat <<EOF
|
||||
|
||||
${B}Порты${N}
|
||||
открыто: $ports_open
|
||||
что нужно и что лишнее: kit local ports закрыть лишние: kit local ports --close-extra
|
||||
EOF
|
||||
fi
|
||||
if [[ -n ${ssh_port:-} ]]; then
|
||||
cat <<EOF
|
||||
|
||||
${Y}SSH переведён на порт $ssh_port.${N} Прежние (${ssh_ports_before:-22}) пока открыты намеренно:
|
||||
1) проверьте вход во втором окне: ssh -p $ssh_port root@${ip_show:-<IP>}
|
||||
2) закройте прежние порты: kit local ssh --close-old $ssh_port
|
||||
EOF
|
||||
fi
|
||||
cat <<EOF
|
||||
|
||||
если что-то не так: kit check --deep, затем kit fix
|
||||
EOF
|
||||
fi
|
||||
+1361
-188
File diff suppressed because it is too large
Load Diff
+16
-545
@@ -1,549 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hysteria2 одной командой – https://github.com/itsnotkubrick/3X-UI_KIT
|
||||
# Hysteria2 – https://github.com/itsnotkubrick/3X-UI_KIT
|
||||
#
|
||||
# Установка: bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/hysteria2.sh)
|
||||
# Управление: hy2 help
|
||||
#
|
||||
# Ставит официальный бинарник Hysteria2 (версия закреплена ниже, контрольная
|
||||
# сумма проверяется), настраивает сертификат, сайт-заглушку, ufw и выдаёт
|
||||
# ссылку hy2:// с QR-кодом.
|
||||
# Отдельный установщик Hysteria2 больше не поддерживается: Hysteria2 входит в основную
|
||||
# установку 3X-UI_autoVPS (scripts/3x-ui.sh). Файл оставлен, чтобы старая команда `hy2 update`
|
||||
# получила из подписанного релиза понятное сообщение. Ничего не ставит, не удаляет и не
|
||||
# останавливает: сервер Hysteria2, установленный раньше, продолжает работать как был.
|
||||
# Код выхода 1 при любом запуске (и при `__update_binary`) – старый `hy2 update` на нём
|
||||
# останавливается и не заменяет команду hy2.
|
||||
|
||||
set -Eeuo pipefail
|
||||
KIT_VERSION="1.2.1.6"
|
||||
|
||||
# На свежем VPS в фоне идут автообновления системы и держат замок dpkg: ждём его, а не падаем.
|
||||
apt-get() { command apt-get -o DPkg::Lock::Timeout=900 "$@"; }
|
||||
|
||||
HY_VERSION="2.12.3"
|
||||
HY_REPO="HyNetworks/hysteria"
|
||||
# SHA256 бинарников этой версии, записанные в сам скрипт: проверка не зависит от файла
|
||||
# hashes.txt, который лежит там же, где бинарник (2026-09-30, hashes.txt релиза app/v2.12.3).
|
||||
declare -A HY_SHA256=(
|
||||
[amd64]=8c7a68a906998b747a0db87586e364f995fbfddb95693ae6e2fdb68a6e920d3e
|
||||
[arm64]=c8dc653c3ba0a28d29a26b8fa52d2086f27c0927afddce95c09965e7174e78b0
|
||||
)
|
||||
KIT_VERSION="1.1.1"
|
||||
KIT_REPO_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT"
|
||||
# Скрипт берём из тега релиза, а не из меняющейся ветки main.
|
||||
SELF_URL="$KIT_REPO_RAW/v$KIT_VERSION/scripts/hysteria2.sh"
|
||||
|
||||
BIN=/usr/local/bin/hysteria
|
||||
CLI=/usr/local/bin/hy2
|
||||
CONF_DIR=/etc/hysteria
|
||||
CONF=$CONF_DIR/config.yaml
|
||||
STATE=$CONF_DIR/install.env # настройки установки
|
||||
USERS=$CONF_DIR/users # строки «имя пароль»
|
||||
MASQ_DIR=/var/www/masq
|
||||
DATA_DIR=/var/lib/hysteria
|
||||
UNIT=/etc/systemd/system/hysteria-server.service
|
||||
SYSCTL=/etc/sysctl.d/99-hysteria.conf
|
||||
|
||||
if [[ -t 1 ]]; then
|
||||
G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; B=$'\e[1m'; D=$'\e[2m'; N=$'\e[0m'
|
||||
else
|
||||
G=; Y=; R=; B=; D=; N=
|
||||
fi
|
||||
say() { printf '%s\n' "${G}==>${N} $*"; }
|
||||
warn() { printf '%s\n' "${Y}!${N} $*" >&2; }
|
||||
die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
|
||||
|
||||
trap 'die "Ошибка в строке $LINENO. Если это установка – исправьте причину и запустите скрипт ещё раз."' ERR
|
||||
|
||||
need_root() { [[ $EUID -eq 0 ]] || die "Запустите от root: sudo -i, затем команду ещё раз."; }
|
||||
|
||||
# Прежнее имя файла настроек – переносим, чтобы старые установки не сломались.
|
||||
if [[ -f $CONF_DIR/pinkman.env && ! -f $STATE && -w $CONF_DIR ]]; then
|
||||
mv "$CONF_DIR/pinkman.env" "$STATE"
|
||||
fi
|
||||
|
||||
# ---------- проверки ----------
|
||||
|
||||
check_os() {
|
||||
[[ -r /etc/os-release ]] || die "Не удалось определить систему."
|
||||
. /etc/os-release
|
||||
case "${ID:-}:${VERSION_ID:-}" in
|
||||
ubuntu:22.04|ubuntu:24.04|debian:12|debian:13) ;;
|
||||
*) warn "Проверено на Ubuntu 22.04/24.04 и Debian 12/13, у вас ${PRETTY_NAME:-неизвестно}. Продолжаю." ;;
|
||||
esac
|
||||
command -v systemctl >/dev/null || die "Нужен systemd."
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo amd64 ;;
|
||||
aarch64|arm64) echo arm64 ;;
|
||||
*) die "Архитектура $(uname -m) не поддерживается." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
port_busy() { # port proto(tcp|udp)
|
||||
ss -H -ln"${2:0:1}" "sport = :$1" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
public_ip() {
|
||||
local ip
|
||||
for u in https://api.ipify.org https://ifconfig.me/ip https://ipv4.icanhazip.com; do
|
||||
ip=$(curl -4 -fsS -m 6 "$u" 2>/dev/null | tr -d '[:space:]') || true
|
||||
[[ $ip =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] && { echo "$ip"; return; }
|
||||
done
|
||||
ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}'
|
||||
}
|
||||
|
||||
rand() { openssl rand -hex "${1:-16}"; }
|
||||
|
||||
# ---------- установка ----------
|
||||
|
||||
install_packages() {
|
||||
say "Ставлю пакеты: curl, openssl, qrencode, ufw"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq curl openssl qrencode ca-certificates iproute2 ufw >/dev/null
|
||||
}
|
||||
|
||||
install_binary() {
|
||||
local arch tmp url expected actual
|
||||
arch=$(detect_arch)
|
||||
url="https://github.com/$HY_REPO/releases/download/app/v$HY_VERSION"
|
||||
tmp=$(mktemp -d)
|
||||
say "Скачиваю Hysteria $HY_VERSION ($arch) с GitHub"
|
||||
curl -fsSL --retry 3 -o "$tmp/hysteria" "$url/hysteria-linux-$arch"
|
||||
expected=${HY_SHA256[$arch]:-}
|
||||
actual=$(sha256sum "$tmp/hysteria" | awk '{print $1}')
|
||||
[[ -n $expected && $expected == "$actual" ]] || { rm -rf "$tmp"; die "Контрольная сумма не совпала – файл повреждён или подменён."; }
|
||||
install -m 755 "$tmp/hysteria" "$BIN"
|
||||
rm -rf "$tmp"
|
||||
say "Контрольная сумма совпала: ${D}${actual:0:16}…${N}"
|
||||
}
|
||||
|
||||
install_cli() {
|
||||
# Скрипт копирует сам себя, чтобы работала команда hy2.
|
||||
local src="${BASH_SOURCE[0]}"
|
||||
if [[ -f $src && $src != /dev/fd/* && $src != /proc/* ]]; then
|
||||
install -m 755 "$src" "$CLI"
|
||||
else
|
||||
curl -fsSL --retry 3 -o "$CLI" "$SELF_URL" && chmod 755 "$CLI"
|
||||
fi
|
||||
}
|
||||
|
||||
write_masq() {
|
||||
mkdir -p "$MASQ_DIR"
|
||||
[[ -f $MASQ_DIR/index.html ]] && return
|
||||
cat >"$MASQ_DIR/index.html" <<'HTML'
|
||||
<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Please wait</title><style>body{background:#080808;height:100vh;margin:0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:sans-serif}.dots{display:flex;gap:15px;margin-bottom:30px}.d{width:20px;height:20px;background:#fff;border-radius:50%;animation:b 1.4s infinite ease-in-out both}.d:nth-child(1){animation-delay:-.32s}.d:nth-child(2){animation-delay:-.16s}@keyframes b{0%,80%,100%{transform:scale(0);opacity:.2}40%{transform:scale(1);opacity:1}}.t{color:#555;font-size:14px;letter-spacing:2px;font-weight:600}</style></head><body><div class="dots"><div class="d"></div><div class="d"></div><div class="d"></div></div><div class="t">RETRYING CONNECTION</div></body></html>
|
||||
HTML
|
||||
}
|
||||
|
||||
make_self_signed() {
|
||||
local sni=$1
|
||||
openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-keyout "$CONF_DIR/server.key" -out "$CONF_DIR/server.crt" \
|
||||
-subj "/CN=$sni" -addext "subjectAltName=DNS:$sni" -days 3650 2>/dev/null
|
||||
chown hysteria:hysteria "$CONF_DIR/server.key" "$CONF_DIR/server.crt"
|
||||
chmod 600 "$CONF_DIR/server.key"
|
||||
}
|
||||
|
||||
cert_pin() {
|
||||
openssl x509 -in "$CONF_DIR/server.crt" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f'
|
||||
}
|
||||
|
||||
render_config() {
|
||||
local u p
|
||||
. "$STATE"
|
||||
{
|
||||
echo "# Сгенерировано hy2 – правьте через команду hy2, ручные изменения перезапишутся."
|
||||
echo "listen: :$PORT"
|
||||
echo
|
||||
if [[ -n ${DOMAIN:-} ]]; then
|
||||
echo "acme:"
|
||||
echo " type: http"
|
||||
echo " domains:"
|
||||
echo " - $DOMAIN"
|
||||
echo " email: $EMAIL"
|
||||
echo " dir: $DATA_DIR/acme"
|
||||
else
|
||||
echo "tls:"
|
||||
echo " cert: $CONF_DIR/server.crt"
|
||||
echo " key: $CONF_DIR/server.key"
|
||||
fi
|
||||
echo
|
||||
echo "auth:"
|
||||
echo " type: userpass"
|
||||
echo " userpass:"
|
||||
while read -r u p; do
|
||||
[[ -n $u ]] && echo " \"$u\": \"$p\""
|
||||
done <"$USERS"
|
||||
echo
|
||||
echo "masquerade:"
|
||||
echo " type: file"
|
||||
echo " file:"
|
||||
echo " dir: $MASQ_DIR"
|
||||
if [[ -n ${DOMAIN:-} ]]; then
|
||||
echo " listenHTTP: :80"
|
||||
echo " listenHTTPS: :$PORT"
|
||||
echo " forceHTTPS: true"
|
||||
fi
|
||||
} >"$CONF.new"
|
||||
chown root:hysteria "$CONF.new"
|
||||
chmod 640 "$CONF.new"
|
||||
mv "$CONF.new" "$CONF"
|
||||
}
|
||||
|
||||
write_unit() {
|
||||
cat >"$UNIT" <<EOF
|
||||
[Unit]
|
||||
Description=Hysteria2 Server (3X-UI_KIT)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=600
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$BIN server --config $CONF
|
||||
WorkingDirectory=$DATA_DIR
|
||||
User=hysteria
|
||||
Group=hysteria
|
||||
Environment=HYSTERIA_LOG_LEVEL=info
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||
NoNewPrivileges=true
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
tune_sysctl() {
|
||||
# Буферы UDP побольше – рекомендация разработчиков Hysteria для скорости.
|
||||
printf 'net.core.rmem_max=16777216\nnet.core.wmem_max=16777216\n' >"$SYSCTL"
|
||||
sysctl -q -p "$SYSCTL" 2>/dev/null || warn "Не удалось применить sysctl (так бывает в контейнерах) – на работу не влияет."
|
||||
}
|
||||
|
||||
setup_ufw() {
|
||||
. "$STATE"
|
||||
[[ ${UFW:-yes} == yes ]] || return 0
|
||||
local ssh_port
|
||||
ssh_port=$(ss -H -ltnp 2>/dev/null | awk '/sshd/ {sub(/.*:/,"",$4); print $4; exit}')
|
||||
ssh_port=${ssh_port:-22}
|
||||
say "Настраиваю ufw: SSH $ssh_port/tcp, Hysteria $PORT/udp${DOMAIN:+, сайт 80/tcp и $PORT/tcp}"
|
||||
ufw allow "$ssh_port/tcp" >/dev/null
|
||||
ufw allow "$PORT/udp" >/dev/null
|
||||
if [[ -n ${DOMAIN:-} ]]; then
|
||||
ufw allow 80/tcp >/dev/null
|
||||
ufw allow "$PORT/tcp" >/dev/null
|
||||
fi
|
||||
ufw --force enable >/dev/null || warn "ufw не включился (так бывает в контейнерах) – откройте порты у хостера вручную."
|
||||
}
|
||||
|
||||
# Ждём строку «server up and running» в логе с момента запуска: с доменом
|
||||
# Hysteria сначала получает сертификат, это занимает до минуты.
|
||||
start_and_wait() {
|
||||
local since i log
|
||||
since=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
systemctl restart hysteria-server
|
||||
for i in $(seq 1 90); do
|
||||
log=$(journalctl -u hysteria-server --since "$since" --no-pager -o cat 2>/dev/null || true)
|
||||
grep -q 'server up and running' <<<"$log" && return 0
|
||||
if grep -q 'FATAL' <<<"$log"; then
|
||||
systemctl stop hysteria-server || true
|
||||
echo >&2
|
||||
grep 'FATAL' <<<"$log" | tail -1 | cut -c1-400 >&2
|
||||
echo >&2
|
||||
if grep -qi 'acme\|certificate' <<<"$log"; then
|
||||
die "Не удалось получить сертификат. Проверьте, что A-запись домена указывает на этот сервер, а порт 80/tcp открыт у хостера. Потом: hy2 restart"
|
||||
fi
|
||||
die "Hysteria не запустилась – причина выше. После исправления: hy2 restart"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
die "Hysteria не ответила за 90 секунд. Лог: journalctl -u hysteria-server -n 50"
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
need_root
|
||||
check_os
|
||||
[[ -f $STATE ]] && die "Hysteria уже установлена этим скриптом. Команды управления: hy2 help"
|
||||
|
||||
local DOMAIN="" EMAIL="" PORT=443 USERNAME="admin" HOST="" UFW=yes SNI="" yes=no
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--domain) DOMAIN=$2; shift 2 ;;
|
||||
--email) EMAIL=$2; shift 2 ;;
|
||||
--port) PORT=$2; shift 2 ;;
|
||||
--user) USERNAME=$2; shift 2 ;;
|
||||
--host) HOST=$2; shift 2 ;;
|
||||
--sni) SNI=$2; shift 2 ;;
|
||||
--no-ufw) UFW=no; shift ;;
|
||||
-y|--yes) yes=yes; shift ;;
|
||||
*) die "Неизвестный параметр: $1 (см. hy2 help)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $yes == no && -t 0 && -z $DOMAIN ]]; then
|
||||
echo
|
||||
echo "${B}Установка Hysteria2${N}"
|
||||
echo "С доменом сертификат выпустит Let's Encrypt, а по адресу домена откроется сайт-заглушка."
|
||||
echo "Без домена будет самоподписанный сертификат – это тоже работает."
|
||||
read -rp "Домен (Enter – без домена): " DOMAIN
|
||||
if [[ -n $DOMAIN ]]; then read -rp "Почта для Let's Encrypt: " EMAIL; fi
|
||||
fi
|
||||
# Значения попадают в install.env и конфиг Hysteria – пропускаем только допустимые символы.
|
||||
local re_host='^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$'
|
||||
if [[ -n $DOMAIN ]]; then
|
||||
[[ $DOMAIN =~ $re_host ]] || die "Похоже, это не домен: $DOMAIN"
|
||||
[[ $EMAIL =~ ^[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,63}$ ]] || die "Для Let's Encrypt нужна почта: --email you@example.com"
|
||||
fi
|
||||
[[ -z $SNI || $SNI =~ $re_host ]] || die "--sni: нужно имя сайта, например www.bing.com"
|
||||
[[ -z $HOST || $HOST =~ $re_host || $HOST =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || die "--host: нужен IP или домен"
|
||||
[[ $PORT =~ ^[0-9]+$ ]] && ((PORT > 0 && PORT < 65536)) || die "Неверный порт: $PORT"
|
||||
[[ $USERNAME =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "Имя пользователя: латиница, цифры, _ . - (до 32 символов)."
|
||||
|
||||
port_busy "$PORT" udp && die "UDP-порт $PORT уже занят. Выберите другой: --port 8443"
|
||||
if [[ -n $DOMAIN ]]; then
|
||||
port_busy 80 tcp && die "TCP-порт 80 занят – он нужен Let's Encrypt для проверки домена."
|
||||
port_busy "$PORT" tcp && die "TCP-порт $PORT занят – на нём будет сайт-заглушка."
|
||||
fi
|
||||
|
||||
install_packages
|
||||
HOST=${HOST:-${DOMAIN:-$(public_ip)}}
|
||||
[[ -n $HOST ]] || die "Не удалось узнать внешний IP. Укажите его: --host 1.2.3.4"
|
||||
if [[ -n $DOMAIN ]]; then
|
||||
local resolved my_ip
|
||||
resolved=$(getent ahostsv4 "$DOMAIN" 2>/dev/null | awk 'NR==1{print $1}') || true
|
||||
[[ -n $resolved ]] || die "Домен $DOMAIN не найден в DNS. Создайте A-запись на IP сервера и подождите пару минут."
|
||||
my_ip=$(public_ip)
|
||||
[[ $resolved == "$my_ip" ]] || warn "Домен $DOMAIN указывает на $resolved, а IP сервера $my_ip. Если сертификат не выпустится – проверьте A-запись."
|
||||
fi
|
||||
SNI=${SNI:-${DOMAIN:-www.bing.com}}
|
||||
|
||||
id hysteria &>/dev/null || useradd --system --no-create-home --home-dir "$DATA_DIR" --shell /usr/sbin/nologin hysteria
|
||||
install -d -o hysteria -g hysteria -m 750 "$DATA_DIR"
|
||||
install -d -o root -g hysteria -m 750 "$CONF_DIR"
|
||||
|
||||
install_binary
|
||||
write_masq
|
||||
[[ -z $DOMAIN ]] && make_self_signed "$SNI"
|
||||
|
||||
# printf %q: файл потом читается через source, значения не должны превратиться в команды.
|
||||
{
|
||||
printf 'DOMAIN=%q\n' "$DOMAIN"
|
||||
printf 'EMAIL=%q\n' "$EMAIL"
|
||||
printf 'PORT=%q\n' "$PORT"
|
||||
printf 'HOST=%q\n' "$HOST"
|
||||
printf 'SNI=%q\n' "$SNI"
|
||||
printf 'UFW=%q\n' "$UFW"
|
||||
} >"$STATE"
|
||||
chmod 600 "$STATE"
|
||||
printf '%s %s\n' "$USERNAME" "$(rand 16)" >"$USERS"
|
||||
chmod 600 "$USERS"
|
||||
|
||||
render_config
|
||||
write_unit
|
||||
tune_sysctl
|
||||
install_cli
|
||||
setup_ufw
|
||||
|
||||
say "Запускаю Hysteria${DOMAIN:+ и получаю сертификат для $DOMAIN}"
|
||||
systemctl daemon-reload
|
||||
systemctl enable hysteria-server >/dev/null 2>&1
|
||||
start_and_wait
|
||||
|
||||
echo
|
||||
echo "${G}${B}Готово! Hysteria2 работает.${N}"
|
||||
show_link "$USERNAME"
|
||||
echo
|
||||
echo "Добавить пользователя: ${B}hy2 add имя${N} · все команды: ${B}hy2 help${N}"
|
||||
}
|
||||
|
||||
# ---------- управление ----------
|
||||
|
||||
require_installed() { need_root; [[ -f $STATE ]] || die "Hysteria не установлена этим скриптом."; }
|
||||
|
||||
user_link() {
|
||||
. "$STATE"
|
||||
local u=$1 p q
|
||||
p=$(awk -v u="$u" '$1==u {print $2}' "$USERS")
|
||||
[[ -n $p ]] || die "Нет пользователя $u"
|
||||
q="sni=$SNI"
|
||||
if [[ -z ${DOMAIN:-} ]]; then q="$q&insecure=1&pinSHA256=$(cert_pin)"; fi
|
||||
echo "hy2://$u:$p@$HOST:$PORT/?$q#$u@$HOST"
|
||||
}
|
||||
|
||||
show_link() {
|
||||
local link
|
||||
link=$(user_link "$1")
|
||||
echo
|
||||
echo "Ссылка для ${B}$1${N} – вставьте в Hiddify, v2rayN, Streisand или NekoBox:"
|
||||
echo
|
||||
echo "$link"
|
||||
echo
|
||||
command -v qrencode >/dev/null && qrencode -t ANSIUTF8 -m 1 "$link"
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
render_config
|
||||
start_and_wait
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
require_installed
|
||||
systemctl reset-failed hysteria-server 2>/dev/null || true
|
||||
reload_service
|
||||
say "Hysteria перезапущена."
|
||||
}
|
||||
|
||||
cmd_add() {
|
||||
require_installed
|
||||
local u=${1:-}
|
||||
[[ $u =~ ^[A-Za-z0-9_.-]{1,32}$ ]] || die "Использование: hy2 add имя (латиница, цифры, _ . -)"
|
||||
awk -v u="$u" '$1==u {f=1} END {exit !f}' "$USERS" && die "Пользователь $u уже есть. Ссылка: hy2 link $u"
|
||||
printf '%s %s\n' "$u" "$(rand 16)" >>"$USERS"
|
||||
reload_service
|
||||
say "Пользователь $u добавлен."
|
||||
show_link "$u"
|
||||
}
|
||||
|
||||
cmd_del() {
|
||||
require_installed
|
||||
local u=${1:-}
|
||||
[[ -n $u ]] || die "Использование: hy2 del имя"
|
||||
awk -v u="$u" '$1==u {f=1} END {exit !f}' "$USERS" || die "Нет пользователя $u"
|
||||
[[ $(grep -c . "$USERS") -gt 1 ]] || die "Это последний пользователь – сначала добавьте другого."
|
||||
awk -v u="$u" '$1!=u' "$USERS" >"$USERS.new" && mv "$USERS.new" "$USERS" && chmod 600 "$USERS"
|
||||
reload_service
|
||||
say "Пользователь $u удалён, его ссылка больше не работает."
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
require_installed
|
||||
echo "Пользователи:"
|
||||
awk '{print " • " $1}' "$USERS"
|
||||
}
|
||||
|
||||
cmd_link() {
|
||||
require_installed
|
||||
local u=${1:-$(awk 'NR==1{print $1}' "$USERS")}
|
||||
show_link "$u"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
require_installed
|
||||
. "$STATE"
|
||||
echo "Версия: $("$BIN" version 2>/dev/null | awk '/^Version:/ {print $2}')"
|
||||
echo "Адрес: $HOST:$PORT/udp"
|
||||
if [[ -n ${DOMAIN:-} ]]; then echo "Сертификат: Let's Encrypt для $DOMAIN"; else echo "Сертификат: самоподписанный ($SNI)"; fi
|
||||
echo "Пользователей: $(grep -c . "$USERS")"
|
||||
systemctl --no-pager --lines=5 status hysteria-server || true
|
||||
}
|
||||
|
||||
# Открытый ключ, которым автор подписывает релизы (тот же, что в kit). Обновление ставится,
|
||||
# только если SHA256SUMS релиза подписан этим ключом и скрипт совпал с подписанным хешем.
|
||||
KIT_SIGNERS=(
|
||||
# SHA256:VDuGgJ8dOeCNXB4nBZf3+kRlWthw3+vh8rfMxzMSRIM (itsnotkubrick, 2026-09-30)
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGrTGCDhhnm8XO1ekpPJuSWRVCJiFiupEspfQxcbEBmz 3X-UI KIT releases"
|
||||
)
|
||||
KIT_SIG_NS="3x-ui-kit-release"
|
||||
KIT_SIG_ID="releases@3x-ui-kit"
|
||||
|
||||
cmd_update() {
|
||||
require_installed
|
||||
local tmp latest raw sum k
|
||||
latest=$(curl -fsS -m 8 "$KIT_REPO_RAW/main/VERSION" | tr -d '[:space:]') || true
|
||||
[[ $latest =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "Не удалось узнать последнюю версию: GitHub недоступен с сервера."
|
||||
[[ $latest != "$KIT_VERSION" && $(printf '%s\n%s\n' "$latest" "$KIT_VERSION" | sort -V | tail -1) == "$KIT_VERSION" ]] \
|
||||
&& die "На GitHub версия $latest старше вашей $KIT_VERSION – ничего не делаю."
|
||||
((${#KIT_SIGNERS[@]})) || die "В этой сборке нет ключа подписи – проверить обновление нечем."
|
||||
command -v ssh-keygen >/dev/null || apt-get install -y -qq openssh-client >/dev/null
|
||||
tmp=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет
|
||||
trap "rm -rf -- '$tmp'" EXIT
|
||||
raw="$KIT_REPO_RAW/v$latest"
|
||||
say "Скачиваю и проверяю подпись версии $latest"
|
||||
curl -fsSL --retry 3 -o "$tmp/SHA256SUMS" "$raw/SHA256SUMS" && curl -fsSL --retry 3 -o "$tmp/SHA256SUMS.sig" "$raw/SHA256SUMS.sig" \
|
||||
|| die "Не удалось скачать подпись релиза – ничего не менял."
|
||||
for k in "${KIT_SIGNERS[@]}"; do printf '%s namespaces="%s" %s\n' "$KIT_SIG_ID" "$KIT_SIG_NS" "$k" >>"$tmp/allowed_signers"; done
|
||||
ssh-keygen -Y verify -f "$tmp/allowed_signers" -I "$KIT_SIG_ID" -n "$KIT_SIG_NS" -s "$tmp/SHA256SUMS.sig" <"$tmp/SHA256SUMS" >/dev/null 2>&1 \
|
||||
|| die "Подпись релиза $latest не сошлась с ключом автора – это не наш релиз. Ничего не менял."
|
||||
grep -qx "# 3X-UI KIT $latest" "$tmp/SHA256SUMS" || die "Подписанный релиз не той версии – ничего не менял."
|
||||
sum=$(awk '$2 == "scripts/hysteria2.sh" {print $1}' "$tmp/SHA256SUMS")
|
||||
curl -fsSL --retry 3 -o "$tmp/hysteria2.sh" "$raw/scripts/hysteria2.sh" || die "Не удалось скачать скрипт – ничего не менял."
|
||||
[[ $sum =~ ^[0-9a-f]{64}$ && $(sha256sum "$tmp/hysteria2.sh" | awk '{print $1}') == "$sum" ]] \
|
||||
|| die "Скрипт не совпал с подписанным SHA256 – ничего не менял."
|
||||
bash -n "$tmp/hysteria2.sh" || die "Скачанный скрипт повреждён – ничего не менял."
|
||||
bash "$tmp/hysteria2.sh" __update_binary
|
||||
install -m 755 "$tmp/hysteria2.sh" "$CLI"
|
||||
}
|
||||
|
||||
cmd_update_binary() {
|
||||
require_installed
|
||||
local cur
|
||||
cur=$("$BIN" version 2>/dev/null | awk '/^Version:/ {print $2}')
|
||||
if [[ $cur == "v$HY_VERSION" ]]; then say "Уже стоит последняя проверенная версия $HY_VERSION."; return; fi
|
||||
install_binary
|
||||
reload_service
|
||||
say "Hysteria обновлена: $cur → v$HY_VERSION"
|
||||
}
|
||||
|
||||
cmd_uninstall() {
|
||||
require_installed
|
||||
local ans=""
|
||||
if [[ ${1:-} != -y && -t 0 ]]; then
|
||||
read -rp "Удалить Hysteria и всех пользователей? [y/N] " ans
|
||||
[[ $ans =~ ^[yYдД]$ ]] || { echo "Отменено."; return; }
|
||||
fi
|
||||
. "$STATE"
|
||||
systemctl disable --now hysteria-server >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT" "$BIN" "$SYSCTL"
|
||||
systemctl daemon-reload
|
||||
if [[ ${UFW:-yes} == yes ]] && command -v ufw >/dev/null; then
|
||||
ufw delete allow "$PORT/udp" >/dev/null 2>&1 || true
|
||||
if [[ -n ${DOMAIN:-} ]]; then
|
||||
ufw delete allow 80/tcp >/dev/null 2>&1 || true
|
||||
ufw delete allow "$PORT/tcp" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
rm -rf "$CONF_DIR" "$DATA_DIR" "$MASQ_DIR"
|
||||
userdel hysteria 2>/dev/null || true
|
||||
rm -f "$CLI"
|
||||
say "Hysteria удалена."
|
||||
}
|
||||
|
||||
cmd_help() {
|
||||
cat <<EOF
|
||||
${B}hy2${N} – управление Hysteria2
|
||||
|
||||
hy2 add имя добавить пользователя и показать его ссылку
|
||||
hy2 del имя удалить пользователя
|
||||
hy2 list список пользователей
|
||||
hy2 link [имя] ссылка и QR-код
|
||||
hy2 status версия, адрес, состояние сервиса
|
||||
hy2 restart перезапустить (например, после исправления DNS)
|
||||
hy2 update обновить скрипт и Hysteria до проверенной версии
|
||||
hy2 uninstall удалить всё
|
||||
|
||||
Параметры установки (для запуска без вопросов):
|
||||
--domain example.com --email you@example.com сертификат Let's Encrypt
|
||||
--port 443 UDP-порт (по умолчанию 443)
|
||||
--user admin имя первого пользователя
|
||||
--sni www.bing.com SNI для самоподписанного сертификата
|
||||
--host 1.2.3.4 адрес в ссылке, если IP определился неверно
|
||||
--no-ufw не трогать файрвол
|
||||
-y не задавать вопросов
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
local cmd=${1:-}
|
||||
case $cmd in
|
||||
add|del|list|link|status|restart|update|uninstall|help) shift; "cmd_$cmd" "$@" ;;
|
||||
__update_binary) cmd_update_binary ;;
|
||||
install) shift; cmd_install "$@" ;;
|
||||
-h|--help) cmd_help ;;
|
||||
""|-*)
|
||||
if [[ -f $STATE && $# -eq 0 ]]; then cmd_help; else cmd_install "$@"; fi ;;
|
||||
*) die "Неизвестная команда: $cmd (см. hy2 help)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
cat >&2 <<'MSG'
|
||||
Отдельный установщик Hysteria2 больше не поддерживается.
|
||||
Сервер Hysteria2 продолжает работать как раньше, но не обновляется этим скриптом.
|
||||
Hysteria2 входит в основную установку 3X-UI_autoVPS (один из 11 протоколов).
|
||||
Если нужен только Hysteria2 без панели – официальный установщик:
|
||||
bash <(curl -fsSL https://get.hy2.sh/)
|
||||
MSG
|
||||
exit 1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+383
-19
@@ -14,11 +14,14 @@ https://github.com/itsnotkubrick/3X-UI_KIT
|
||||
"""
|
||||
|
||||
import base64
|
||||
import http.client
|
||||
import http.server
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
@@ -36,7 +39,19 @@ CLASH_UA = re.compile(r"clash|mihomo|flclash|stash|nyanpasu|meta", re.I)
|
||||
# AmneziaWG добавляем только приложениям на ядре Mihomo. Karing, Hiddify и другие на sing-box
|
||||
# тоже могут просить формат Clash (Karing так и делает), но AmneziaWG не умеют.
|
||||
NO_AWG_UA = re.compile(r"karing|hiddify|nekobox|sing-?box|husi|stash|shadowrocket|v2box|streisand|happ|loon|surge|quantumult", re.I)
|
||||
# Hiddify 4.x в строке приложения называет себя и Clash, поэтому панель отдаёт ему Clash-YAML, а он обрезает в нём пробелы и
|
||||
# выдаёт «Unable to determine config format». Панели такой клиент представляем без слов про Clash: она отдаст обычный
|
||||
# список ссылок (base64), который Hiddify читает без проблем.
|
||||
HIDDIFY_UA = re.compile(r"hiddify", re.I)
|
||||
|
||||
|
||||
def upstream_ua(ua):
|
||||
return CLASH_UA.sub("", ua).strip() if HIDDIFY_UA.search(ua) else ua
|
||||
|
||||
|
||||
SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$")
|
||||
# Страница подписки для браузера (React от 3X-UI) подгружает скрипты и стили из «<путь>/assets/…»: пропускаем только такие файлы.
|
||||
ASSET = re.compile(r"^assets/[A-Za-z0-9_.-]{1,128}\.(js|css|woff2?|svg|png|ico|map)$")
|
||||
# Заголовки Happ, которые 3X-UI отдаёт в подписке (маршрутизация, баннеры, настройки клиента).
|
||||
HAPP_HEADERS = ("routing", "routing-enable", "announce", "providerid", "new-url", "fallback-url",
|
||||
"hide-settings", "no-limit-enabled", "ping-type", "color-profile", "tun-mode", "tun-type",
|
||||
@@ -50,22 +65,37 @@ PASS_HEADERS = ("content-type", "content-disposition", "profile-title", "profile
|
||||
with open(CONFIG, encoding="utf-8") as f:
|
||||
CONF = json.load(f)
|
||||
PATH = "/" + CONF["path"].strip("/") + "/"
|
||||
# Раздельная маршрутизация (необязательно): /etc/kit-sub/rules.yaml – через VPN только перечисленное, остальное напрямую.
|
||||
# Файл читается при каждом запросе подписки, перезапуск не нужен. Нет файла или он битый – подписка как обычно (всё через VPN).
|
||||
RULES_FILE = os.environ.get("KIT_SUB_RULES") or "/etc/kit-sub/rules.yaml"
|
||||
RULES_MAX_BYTES, RULES_MAX_COUNT = 65536, 300
|
||||
RULE_RE = re.compile(r"^(DOMAIN|DOMAIN-SUFFIX|DOMAIN-KEYWORD|GEOSITE|IP-CIDR|IP-CIDR6|GEOIP),([A-Za-z0-9._:/!@+-]{1,120})$")
|
||||
DOH_RE = re.compile(r"^https://[A-Za-z0-9.-]{1,100}(:\d{1,5})?/[A-Za-z0-9._/-]{0,60}$")
|
||||
DOH_DEFAULT = ["https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"]
|
||||
PRIVATE_NETS = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16"]
|
||||
# Дополнительные пути, которые панель показывает на странице подписки (Clash и JSON): обрабатываются так же, как основной.
|
||||
EXTRA = ["/" + x.strip("/") + "/" for x in CONF.get("extra_paths", []) if isinstance(x, str) and re.fullmatch(r"/?[A-Za-z0-9_-]+/?", x)]
|
||||
# После kit net site прежнее имя сайта REALITY ещё неделю принимается сервером (льготный срок), а панель берёт имя для ссылки
|
||||
# случайно из списка. Карта «прежнее имя → нынешнее» от kit: в подписку уходит только нынешнее. Нет файла – ничего не меняем.
|
||||
GRACE_FILE = os.environ.get("KIT_SUB_GRACE") or "/etc/kit-sub/sni-grace.json"
|
||||
SITE_NAME = re.compile(r"[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+")
|
||||
LINK_SNI = re.compile(r"([?&]sni=)([^&#\s]*)")
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def upstream(sub_id, ua, host, accept):
|
||||
def upstream(sub_id, ua, host, accept, prefix=None):
|
||||
"""GET к подписке 3X-UI. Возвращает (код, заголовки, тело) или (None, {}, b"")."""
|
||||
req = urllib.request.Request(CONF["upstream"].rstrip("/") + PATH + sub_id, headers={
|
||||
req = urllib.request.Request(CONF["upstream"].rstrip("/") + (prefix or PATH) + sub_id, headers={
|
||||
"User-Agent": ua, "Host": host, "Accept": accept or "*/*"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
|
||||
except (urllib.error.URLError, OSError, socket.timeout) as e:
|
||||
except (urllib.error.URLError, http.client.HTTPException, OSError, socket.timeout) as e:
|
||||
log(f"upstream недоступен: {e}")
|
||||
return None, {}, b""
|
||||
|
||||
@@ -76,9 +106,72 @@ def fix_userinfo(value):
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def strip_links(body):
|
||||
JSON_FIX_MAX = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def load_grace():
|
||||
"""Карта «прежнее имя сайта → нынешнее» (файл от kit). Только имена сайтов, остальное отбрасываем."""
|
||||
try:
|
||||
with open(GRACE_FILE, "rb") as f:
|
||||
raw = f.read(65537)
|
||||
data = json.loads(raw.decode("utf-8")) if len(raw) <= 65536 else {}
|
||||
except (OSError, ValueError, UnicodeError, RecursionError):
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {k: v for k, v in list(data.items())[:100] if isinstance(k, str) and isinstance(v, str)
|
||||
and len(k) <= 253 and len(v) <= 253 and SITE_NAME.fullmatch(k) and SITE_NAME.fullmatch(v)}
|
||||
|
||||
|
||||
def grace_link(line, grace):
|
||||
"""Ссылка REALITY с прежним именем сайта → с нынешним; остальные ссылки как есть."""
|
||||
if not grace or not line.startswith("vless://") or "security=reality" not in line:
|
||||
return line
|
||||
return LINK_SNI.sub(lambda m: m.group(1) + grace.get(m.group(2), m.group(2)), line)
|
||||
|
||||
|
||||
def fix_vless_encryption(body, grace=None):
|
||||
"""JSON-подписка панели: у vless-пользователя без encryption (нет поля или пусто) ставим "none" – Xray иначе
|
||||
не принимает конфиг. Две формы: vnext[].users[] и плоская (settings.id/encryption). Непустое значение
|
||||
(например, mlkem) не трогаем. Прежнее (льготное) имя сайта REALITY – на нынешнее (grace).
|
||||
Не разобралось, слишком большое или менять нечего – тело как пришло."""
|
||||
if len(body) > JSON_FIX_MAX:
|
||||
return body
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8"))
|
||||
except (ValueError, UnicodeError, RecursionError):
|
||||
log("JSON-подписка: тело не разобралось, отдаю как есть")
|
||||
return body
|
||||
changed = False
|
||||
|
||||
def fix(user):
|
||||
nonlocal changed
|
||||
if isinstance(user, dict) and (user.get("encryption") is None or user.get("encryption") == ""):
|
||||
user["encryption"] = "none"
|
||||
changed = True
|
||||
|
||||
for cfg in (data if isinstance(data, list) else [data]):
|
||||
outs = cfg.get("outbounds") if isinstance(cfg, dict) else None
|
||||
for o in outs if isinstance(outs, list) else []:
|
||||
ss = o.get("streamSettings") if isinstance(o, dict) else None
|
||||
rs = ss.get("realitySettings") if isinstance(ss, dict) else None
|
||||
if grace and isinstance(rs, dict) and rs.get("serverName") in grace:
|
||||
rs["serverName"] = grace[rs["serverName"]]
|
||||
changed = True
|
||||
st = o.get("settings") if isinstance(o, dict) and o.get("protocol") == "vless" else None
|
||||
if not isinstance(st, dict):
|
||||
continue
|
||||
for v in st.get("vnext") if isinstance(st.get("vnext"), list) else []:
|
||||
for u in v.get("users") if isinstance(v, dict) and isinstance(v.get("users"), list) else []:
|
||||
fix(u)
|
||||
if "id" in st:
|
||||
fix(st)
|
||||
return json.dumps(data, ensure_ascii=False).encode() if changed else body
|
||||
|
||||
|
||||
def strip_links(body, grace=None):
|
||||
"""Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение
|
||||
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram."""
|
||||
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram. Прежнее имя сайта REALITY – на нынешнее."""
|
||||
text = body.decode("utf-8", "replace").strip()
|
||||
encoded = "://" not in text
|
||||
if encoded:
|
||||
@@ -86,7 +179,7 @@ def strip_links(body):
|
||||
text = base64.b64decode(text + "=" * (-len(text) % 4)).decode("utf-8", "replace")
|
||||
except ValueError:
|
||||
return body
|
||||
lines = [l for l in text.splitlines() if l.strip() and not l.startswith(("vpn://", "tg://"))]
|
||||
lines = [grace_link(l, grace) for l in text.splitlines() if l.strip() and not l.startswith(("vpn://", "tg://"))]
|
||||
out = "\n".join(lines)
|
||||
return base64.b64encode(out.encode()).decode().encode() if encoded else out.encode()
|
||||
|
||||
@@ -134,11 +227,165 @@ def merge_awg(main_yaml, awg_yaml):
|
||||
return yaml.safe_dump(main, allow_unicode=True, sort_keys=False).encode()
|
||||
|
||||
|
||||
AUTO_GROUP = "Авто"
|
||||
|
||||
|
||||
def add_auto(clash_yaml):
|
||||
"""Группа «Авто» (url-test): клиент сам выбирает самый быстрый из рабочих протоколов и переключается,
|
||||
когда один из них перестаёт отвечать. Ставится первой в выбор, поэтому берётся по умолчанию;
|
||||
вручную по-прежнему можно выбрать любой протокол."""
|
||||
cfg = yaml.safe_load(clash_yaml)
|
||||
if not isinstance(cfg, dict):
|
||||
return clash_yaml
|
||||
allp = [p for p in cfg.get("proxies") or [] if isinstance(p, dict) and p.get("name")]
|
||||
names = [p["name"] for p in allp]
|
||||
# WireGuard и AmneziaWG разрешают имена сайтов на самом клиенте (по обычному UDP внутри туннеля), поэтому в автовыбор
|
||||
# не входят: по умолчанию клиент идёт протоколами, где имя уходит на сервер. Вручную их выбрать по-прежнему можно.
|
||||
auto_names = [p["name"] for p in allp if p.get("type") != "wireguard" and "amnezia-wg-option" not in p]
|
||||
groups = cfg.get("proxy-groups")
|
||||
if len(auto_names) < 2 or not isinstance(groups, list) or any(g.get("name") == AUTO_GROUP for g in groups if isinstance(g, dict)):
|
||||
return clash_yaml
|
||||
groups.insert(0, {"name": AUTO_GROUP, "type": "url-test", "url": "http://www.gstatic.com/generate_204",
|
||||
"interval": 300, "tolerance": 100, "lazy": True, "proxies": auto_names})
|
||||
for g in groups[1:]:
|
||||
lst = g.get("proxies") if isinstance(g, dict) else None
|
||||
if isinstance(g, dict) and g.get("type") == "select" and isinstance(lst, list) and any(x in names for x in lst):
|
||||
g["proxies"] = [AUTO_GROUP] + lst
|
||||
break
|
||||
return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode()
|
||||
|
||||
|
||||
# DNS в Clash-подписке: 3X-UI профиль без раздела dns не присылает, и как клиент будет разрешать имена, зависит от
|
||||
# самого приложения (системный DNS, возможны утечки). Добавляем безопасный вариант, если своего dns в профиле нет:
|
||||
# fake-ip, запросы идут как обычный трафик по правилам (то есть через прокси) и по DoH, без запасного локального DNS.
|
||||
# Адреса серверов имён – числовые, чтобы не нужен был ещё один запрос для их поиска.
|
||||
DNS_BLOCK = {
|
||||
"enable": True,
|
||||
"ipv6": False,
|
||||
"enhanced-mode": "fake-ip",
|
||||
"fake-ip-range": "198.18.0.1/16",
|
||||
"fake-ip-filter": ["*.lan", "+.local"],
|
||||
"respect-rules": True,
|
||||
"default-nameserver": ["1.1.1.1", "8.8.8.8"],
|
||||
"nameserver": ["https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"],
|
||||
# Имена самих серверов (если сервер задан доменом) разрешаются напрямую, но по DoH.
|
||||
"proxy-server-nameserver": ["https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"],
|
||||
}
|
||||
|
||||
|
||||
def add_dns(clash_yaml):
|
||||
cfg = yaml.safe_load(clash_yaml)
|
||||
if not isinstance(cfg, dict) or "dns" in cfg:
|
||||
return clash_yaml
|
||||
cfg["dns"] = dict(DNS_BLOCK)
|
||||
return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode()
|
||||
|
||||
|
||||
def load_rules(report=None):
|
||||
"""Читает RULES_FILE: список via_vpn («ТИП,ЗНАЧЕНИЕ») и необязательный direct_dns (адреса DoH для остального).
|
||||
Возвращает (правила, direct_dns) или None, если файла нет или он непригоден: тогда подписка остаётся прежней."""
|
||||
say = report or log
|
||||
try:
|
||||
with open(RULES_FILE, "rb") as f:
|
||||
raw = f.read(RULES_MAX_BYTES + 1)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as e:
|
||||
say(f"правила: не удалось прочитать {RULES_FILE}: {e}")
|
||||
return None
|
||||
if len(raw) > RULES_MAX_BYTES:
|
||||
say(f"правила: файл больше {RULES_MAX_BYTES // 1024} КБ – не применяю")
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(raw.decode("utf-8-sig")) or {}
|
||||
except (yaml.YAMLError, UnicodeError) as e:
|
||||
say(f"правила: файл не разобран ({e}) – не применяю")
|
||||
return None
|
||||
items = data.get("via_vpn") if isinstance(data, dict) else None
|
||||
if not isinstance(items, list) or not items:
|
||||
say("правила: нет списка via_vpn – не применяю")
|
||||
return None
|
||||
rules, seen = [], set()
|
||||
for item in items[:RULES_MAX_COUNT + 50]:
|
||||
r = re.sub(r"\s*,\s*", ",", str(item).strip())
|
||||
kind, _, value = r.partition(",")
|
||||
r = kind.upper() + "," + value # «domain-suffix,…» тоже понимаем
|
||||
m = RULE_RE.match(r)
|
||||
if not m:
|
||||
say(f"правила: пропускаю непонятное правило {str(item)[:80]!r}")
|
||||
continue
|
||||
kind, value = m.group(1), m.group(2)
|
||||
if kind in ("IP-CIDR", "IP-CIDR6"):
|
||||
try:
|
||||
net = ipaddress.ip_network(value, strict=False)
|
||||
except ValueError:
|
||||
net = None
|
||||
if net is None or net.version != (6 if kind == "IP-CIDR6" else 4):
|
||||
say(f"правила: неверная подсеть {value!r} – пропускаю")
|
||||
continue
|
||||
if kind == "DOMAIN-KEYWORD" and len(value) < 4:
|
||||
say(f"правила: слово {value!r} слишком короткое (под него попадёт почти всё) – пропускаю")
|
||||
continue
|
||||
if (kind, value) not in seen and len(rules) < RULES_MAX_COUNT:
|
||||
seen.add((kind, value))
|
||||
rules.append((kind, value))
|
||||
if not rules:
|
||||
say("правила: ни одного пригодного – не применяю")
|
||||
return None
|
||||
direct = data.get("direct_dns")
|
||||
direct = [d for d in direct if isinstance(d, str) and DOH_RE.match(d)][:4] if isinstance(direct, list) else []
|
||||
return rules, direct
|
||||
|
||||
|
||||
def apply_rules(clash_yaml, loaded):
|
||||
"""Раздельная маршрутизация. Перечисленное идёт через VPN (первая группа прокси), остальное – напрямую.
|
||||
DNS при этом не течёт: имена из списка в fake-ip режиме у клиента не разрешаются вовсе (сайт называется серверу),
|
||||
запросы про них (на случай IP-подключений) – по DoH через VPN, а для остальных – системный DNS или direct_dns."""
|
||||
cfg = yaml.safe_load(clash_yaml)
|
||||
if not isinstance(cfg, dict):
|
||||
return clash_yaml
|
||||
groups = [g.get("name") for g in cfg.get("proxy-groups") or [] if isinstance(g, dict) and g.get("name")]
|
||||
proxies = [p.get("name") for p in cfg.get("proxies") or [] if isinstance(p, dict) and p.get("name")]
|
||||
target = groups[0] if groups else proxies[0] if proxies else None
|
||||
if target is None:
|
||||
return clash_yaml
|
||||
rules, direct_dns = loaded
|
||||
out = [f"IP-CIDR,{n},DIRECT,no-resolve" for n in PRIVATE_NETS]
|
||||
# DoH-серверы – через VPN, чтобы запросы про сайты из списка не выходили мимо него.
|
||||
out += [f"IP-CIDR,{h}/32,{target},no-resolve" for h in ("1.1.1.1", "8.8.8.8")]
|
||||
policy = {}
|
||||
for kind, value in rules:
|
||||
# Правилам по адресам не даём разрешать имена (иначе это обычный DNS-запрос мимо VPN).
|
||||
out.append(f"{kind},{value},{target}" + (",no-resolve" if kind in ("IP-CIDR", "IP-CIDR6", "GEOIP") else ""))
|
||||
if kind == "GEOSITE":
|
||||
policy["geosite:" + value] = DOH_DEFAULT
|
||||
elif kind == "DOMAIN-SUFFIX":
|
||||
policy["+." + value] = DOH_DEFAULT
|
||||
elif kind == "DOMAIN":
|
||||
policy[value] = DOH_DEFAULT
|
||||
out.append("MATCH,DIRECT")
|
||||
cfg["mode"] = "rule"
|
||||
cfg["rules"] = out
|
||||
dns = dict(DNS_BLOCK)
|
||||
dns["nameserver"] = direct_dns or ["system"]
|
||||
if policy:
|
||||
dns["nameserver-policy"] = policy
|
||||
cfg["dns"] = dns
|
||||
# Для подключений по IP (без имени) домен берётся из TLS/HTTP/QUIC – иначе правила по доменам их не увидят.
|
||||
cfg["sniffer"] = {"enable": True, "override-destination": False,
|
||||
"sniff": {"TLS": {"ports": [443, 8443]}, "HTTP": {"ports": [80, "8080-8880"]}, "QUIC": {"ports": [443]}}}
|
||||
return yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False).encode()
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
server_version = "nginx"
|
||||
sys_version = ""
|
||||
protocol_version = "HTTP/1.1" # как у nginx; ответы всегда с Content-Length
|
||||
timeout = 20 # зависшие соединения не держим
|
||||
|
||||
def version_string(self): # «Server: nginx» без хвостового пробела
|
||||
return self.server_version
|
||||
|
||||
def setup(self):
|
||||
# TLS-рукопожатие – в потоке запроса, а не в общем цикле приёма соединений.
|
||||
# Без сертификата (за nginx, на 127.0.0.1) работаем по обычному HTTP.
|
||||
@@ -156,33 +403,66 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args): # без IP клиентов в логах
|
||||
pass
|
||||
|
||||
def send_connection(self):
|
||||
# nginx на ответ с keep-alive пишет «Connection: keep-alive», перед закрытием – «Connection: close».
|
||||
self.send_header("Connection", "close" if self.close_connection else "keep-alive")
|
||||
|
||||
def send_plain(self, code, text=""):
|
||||
body = text.encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_connection()
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_error(self, code, message=None, explain=None):
|
||||
# Свои ответы вместо страницы ошибок Python, в том же виде, что у обычного nginx.
|
||||
# На мусорную строку запроса Python считает запрос HTTP/0.9 и пишет одно тело, без строки статуса;
|
||||
# nginx отвечает «HTTP/1.1 400 Bad Request» с заголовками.
|
||||
self.request_version = "HTTP/1.1"
|
||||
self.close_connection = True
|
||||
self.send_html_error(code)
|
||||
|
||||
def send_html_error(self, code):
|
||||
phrase = self.responses.get(code, ("Error",))[0]
|
||||
body = (f"<html>\r\n<head><title>{code} {phrase}</title></head>\r\n<body>\r\n<center><h1>{code} {phrase}</h1></center>\r\n"
|
||||
"<hr><center>nginx</center>\r\n</body>\r\n</html>\r\n").encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/html")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_connection()
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_other(self):
|
||||
# Тело запроса не читаем – значит, и соединение дальше не держим.
|
||||
self.close_connection = True
|
||||
self.send_html_error(404)
|
||||
|
||||
do_POST = do_PUT = do_DELETE = do_PATCH = do_OPTIONS = do_other
|
||||
|
||||
def do_HEAD(self):
|
||||
self.do_GET()
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?", 1)[0]
|
||||
if not path.startswith(PATH):
|
||||
return self.send_plain(404, "404 page not found")
|
||||
sub_id = path[len(PATH):]
|
||||
if not SUB_ID.match(sub_id):
|
||||
return self.send_plain(404, "404 page not found")
|
||||
prefix = next((x for x in (PATH, *EXTRA) if path.startswith(x)), None)
|
||||
if prefix is None:
|
||||
return self.send_html_error(404)
|
||||
sub_id = path[len(prefix):]
|
||||
if not SUB_ID.match(sub_id) and not (prefix == PATH and ASSET.match(sub_id)):
|
||||
return self.send_html_error(404)
|
||||
ua = self.headers.get("User-Agent", "")
|
||||
host = self.headers.get("Host", CONF.get("host", ""))
|
||||
host = CONF.get("link_host") or self.headers.get("Host", CONF.get("host", ""))
|
||||
accept = self.headers.get("Accept", "")
|
||||
code, headers, body = upstream(sub_id, ua, host, accept)
|
||||
code, headers, body = upstream(sub_id, upstream_ua(ua), host, accept, prefix)
|
||||
if code is None:
|
||||
return self.send_plain(502, "subscription backend is unavailable")
|
||||
return self.send_html_error(502)
|
||||
|
||||
clash = bool(CLASH_UA.search(ua)) and "yaml" in headers.get("content-type", "")
|
||||
clash = (bool(CLASH_UA.search(ua)) or prefix != PATH) and "yaml" in headers.get("content-type", "")
|
||||
awg = clash and not NO_AWG_UA.search(ua)
|
||||
# В журнал – только приложение и что ему отдали, без IP.
|
||||
log(f"{ua[:80]!r} → {'clash+awg' if awg else 'clash' if clash else headers.get('content-type', '?').split(';')[0]}")
|
||||
@@ -191,11 +471,25 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
body = strip_awg(body)
|
||||
elif code == 200 and awg and not sub_id.endswith(("-awg", "-tg")):
|
||||
# Установки до kit 1.1 держали AmneziaWG в подписке «<id>-awg» – подмешиваем её.
|
||||
acode, _, abody = upstream(sub_id + "-awg", ua, host, accept)
|
||||
acode, _, abody = upstream(sub_id + "-awg", ua, host, accept, prefix)
|
||||
if acode == 200 and abody:
|
||||
body = merge_awg(body, abody)
|
||||
elif code == 200 and ("json" in headers.get("content-type", "") or (
|
||||
"text/plain" in headers.get("content-type", "") and body.lstrip()[:1] in (b"[", b"{"))):
|
||||
# Панель 3.9 отдаёт JSON-подписку с Content-Type text/plain – определяем по телу.
|
||||
body = fix_vless_encryption(body, load_grace())
|
||||
elif code == 200 and "text/plain" in headers.get("content-type", ""):
|
||||
body = strip_links(body)
|
||||
body = strip_links(body, load_grace())
|
||||
if code == 200 and clash and CONF.get("auto", True):
|
||||
body = add_auto(body)
|
||||
if code == 200 and clash and CONF.get("dns", True):
|
||||
body = add_dns(body)
|
||||
if code == 200 and awg:
|
||||
# Правила – только приложениям на ядре Mihomo (проверено: geosite, sniffer, nameserver-policy). Karing, Hiddify
|
||||
# и другие на sing-box тоже могут просить формат Clash, но свою маршрутизацию они ведут сами: им подписка прежняя.
|
||||
loaded = load_rules()
|
||||
if loaded:
|
||||
body = apply_rules(body, loaded)
|
||||
except (yaml.YAMLError, UnicodeError) as e:
|
||||
log(f"не удалось обработать подписку: {e}")
|
||||
|
||||
@@ -206,6 +500,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if v and "\r" not in v and "\n" not in v:
|
||||
self.send_header(k.title(), v)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_connection()
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
@@ -220,17 +515,85 @@ class Server(http.server.ThreadingHTTPServer):
|
||||
address_family = socket.AF_INET6 if ":" in CONF.get("listen", "") else socket.AF_INET
|
||||
|
||||
|
||||
def routing_payload(kind, loaded):
|
||||
"""Профиль маршрутизации для самой панели 3X-UI (она раздаёт его приложениям на Xray): kind = happ | json.
|
||||
happ – заголовок Routing для Happ: через VPN только список, GlobalProxy=false.
|
||||
json – правила для JSON-подписки Xray (серверы DoH 1.1.1.1 и 8.8.8.8 – тоже через VPN); конструктор панели всегда ставит в конец «всё через прокси», поэтому остальное
|
||||
отправляем напрямую раньше (порядок block-proxy-direct, прямые regexp:.* и 0.0.0.0/0)."""
|
||||
rules, direct_dns = loaded
|
||||
sites, ips = [], []
|
||||
for k, v in rules:
|
||||
if k == "GEOSITE":
|
||||
sites.append("geosite:" + v)
|
||||
elif k == "DOMAIN-SUFFIX":
|
||||
sites.append("domain:" + v)
|
||||
elif k == "DOMAIN":
|
||||
sites.append("full:" + v)
|
||||
elif k == "DOMAIN-KEYWORD":
|
||||
sites.append(v)
|
||||
elif k == "GEOIP":
|
||||
ips.append("geoip:" + v)
|
||||
else:
|
||||
ips.append(v)
|
||||
if kind == "happ":
|
||||
data = {"Name": "3X-UI_autoVPS", "GlobalProxy": "false", "DomainStrategy": "IPIfNonMatch", "ProxySites": sites,
|
||||
"ProxyIp": ips, "DirectIp": ["geoip:private"]}
|
||||
else:
|
||||
# localhost – системный DNS клиента для прямых сайтов (как в подписке Mihomo); список direct_dns его заменяет.
|
||||
data = {"Name": "3X-UI_autoVPS", "DomainStrategy": "IPIfNonMatch", "RouteOrder": "block-proxy-direct",
|
||||
"ProxySites": sites, "ProxyIp": ["1.1.1.1", "8.8.8.8"] + ips, "DirectSites": ["regexp:.*"],
|
||||
"DirectIp": ["geoip:private", "0.0.0.0/0", "::/0"], "DomesticDNSDomain": direct_dns[0] if direct_dns else "localhost"}
|
||||
text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
# Happ ждёт в заголовке ссылку happ://routing/onadd/<base64>; панели для JSON-подписки нужен обычный JSON.
|
||||
return "happ://routing/onadd/" + base64.b64encode(text.encode()).decode() if kind == "happ" else text
|
||||
|
||||
|
||||
GEO_URL = "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/{}.dat"
|
||||
|
||||
|
||||
def unknown_geo(kind, names):
|
||||
"""Сверяет категории GEOSITE/GEOIP с базой, которую скачают приложения: неизвестное имя ломает запуск конфига у клиента.
|
||||
Нет доступа к GitHub – не проверяем."""
|
||||
if not names:
|
||||
return []
|
||||
try:
|
||||
with urllib.request.urlopen(GEO_URL.format(kind), timeout=30) as r:
|
||||
data = r.read(48 * 1024 * 1024)
|
||||
except (urllib.error.URLError, OSError) as e:
|
||||
print(f"Категории {kind.upper()} не проверил: база недоступна ({e}). Названия сверьте по github.com/MetaCubeX/meta-rules-dat.")
|
||||
return []
|
||||
return [n for n in names if b"\x0a" + bytes([len(n)]) + n.upper().encode() not in data]
|
||||
|
||||
|
||||
def main():
|
||||
if "--routing-payload" in sys.argv:
|
||||
# kit net split: профиль для панели (для Happ и Xray); пусто и код 1, если правил нет.
|
||||
loaded = load_rules(report=lambda m: print(m, file=sys.stderr))
|
||||
if not loaded:
|
||||
raise SystemExit(1)
|
||||
print(routing_payload(sys.argv[sys.argv.index("--routing-payload") + 1], loaded))
|
||||
raise SystemExit(0)
|
||||
if "--check-rules" in sys.argv:
|
||||
# kit net split check: тот же разбор, что и при выдаче подписки, но с отчётом на экран.
|
||||
loaded = load_rules(report=print)
|
||||
if loaded:
|
||||
print(f"Правил через VPN: {len(loaded[0])}; DNS для остального: {', '.join(loaded[1]) or 'системный'}")
|
||||
bad = [f"{k} {n}" for k in ("geosite", "geoip") for n in unknown_geo(k, [v for kk, v in loaded[0] if kk == k.upper()])]
|
||||
if bad:
|
||||
print("Нет такой категории: " + ", ".join(bad) + ". Приложение не сможет запустить конфиг – исправьте или удалите эти строки.")
|
||||
raise SystemExit(2)
|
||||
raise SystemExit(0 if loaded else 1)
|
||||
cert, key = CONF.get("cert"), CONF.get("key")
|
||||
if cert and CREDS and os.path.exists(os.path.join(CREDS, "cert.pem")):
|
||||
cert, key = os.path.join(CREDS, "cert.pem"), os.path.join(CREDS, "key.pem")
|
||||
if not cert:
|
||||
srv = Server((CONF.get("listen", "127.0.0.1"), int(CONF["port"])), Handler)
|
||||
log(f"kit-sub слушает http://{CONF.get('listen', '127.0.0.1')}:{CONF['port']}{PATH} (TLS снимает nginx)")
|
||||
log(f"kit-sub слушает {CONF.get('listen', '127.0.0.1')}:{CONF['port']} (TLS снимает nginx)") # путь подписки в журнал не пишем: он секретный
|
||||
srv.serve_forever()
|
||||
return
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ctx.set_alpn_protocols(["http/1.1"]) # как nginx без http2: клиент предложил http/1.1 – соглашаемся
|
||||
ctx.load_cert_chain(cert, key)
|
||||
stamp = [os.path.getmtime(cert)]
|
||||
|
||||
@@ -250,9 +613,10 @@ def main():
|
||||
threading.Thread(target=reload_cert, daemon=True).start()
|
||||
srv = Server((CONF.get("listen", "0.0.0.0"), int(CONF["port"])), Handler)
|
||||
srv.ssl_ctx = ctx
|
||||
log(f"kit-sub слушает {CONF.get('listen', '0.0.0.0')}:{CONF['port']}{PATH}")
|
||||
log(f"kit-sub слушает {CONF.get('listen', '0.0.0.0')}:{CONF['port']}")
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
+2730
-111
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
# Матрица «протокол × клиент»
|
||||
|
||||
Проверяет настоящим подключением, что каждый протокол 3X-UI работает с каждым клиентом:
|
||||
Проверяет, что каждый протокол 3X-UI работает с каждым клиентом:
|
||||
Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. Прогонять на каждой новой версии 3X-UI и Xray.
|
||||
|
||||
1. Сервер: чистый Ubuntu в Docker с systemd, на нём `scripts/3x-ui.sh`, затем `mk-inbounds.sh` –
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Справка `kit help` обязана перечислять все команды: авторские (их берём прямо из диспетчера
|
||||
# scripts/kit.sh) и наши. Если автор добавит команду, а мы забудем про справку — проверка упадёт,
|
||||
# и сборка релиза остановится: так справка не отстанет молча.
|
||||
set -Eeuo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
help=$(awk '/^kit_help_all\(\) \{/,/^\}/' scripts/kit-local.sh)
|
||||
[[ -n $help ]] || { echo "✗ не нашёл kit_help_all в scripts/kit-local.sh" >&2; exit 1; }
|
||||
|
||||
bad=0
|
||||
# 1) авторские команды: первое слово каждой ветки диспетчера kit.sh
|
||||
while read -r word; do
|
||||
[[ -n $word ]] || continue
|
||||
case $word in __* | local | help | -h | --help | -v | --version) continue ;; esac
|
||||
# «user off» в справке может стоять как «user on|off» — поэтому пробел допускаем и как разделитель вариантов
|
||||
pat=${word// /[ |]}
|
||||
grep -qE " kit ${pat}" <<<"$help" || { echo " ✗ в справке нет авторской команды: kit $word" >&2; bad=1; }
|
||||
done < <(grep -oE '^ +"[a-z][a-z0-9 -]*"\)|^ +"[a-z][a-z0-9 -]*" \*\)' scripts/kit.sh | sed 's/)$//' | tr -d '"' | sed 's/^ *//' | sort -u)
|
||||
|
||||
# 2) наши команды из ветки kit_local_cmd
|
||||
while read -r word; do
|
||||
[[ -n $word ]] || continue
|
||||
grep -qF " kit local $word" <<<"$help" || { echo " ✗ в справке нет нашей команды: kit local $word" >&2; bad=1; }
|
||||
done < <(awk '/^kit_local_cmd\(\) \{/,/^\}/' scripts/kit-local.sh | grep -oE '^ [a-z-]+' | tr -d ' ' | sort -u)
|
||||
|
||||
if (( bad )); then
|
||||
echo "✗ справка kit help отстала от набора команд — поправьте kit_help_all в scripts/kit-local.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ справка kit help содержит все команды (авторские и наши)"
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Слежение за чужими версиями: сравнивает закреплённые в скриптах версии с последними релизами.
|
||||
# Ничего не меняет, только печатает. Запуск: tools/check-upstream.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
latest() { # репозиторий шаблон-тега (регулярка)
|
||||
git ls-remote --tags --refs "https://github.com/$1" 2>/dev/null | awk '{print $2}' | sed 's#refs/tags/##' | grep -E "$2" | sort -V | tail -1
|
||||
}
|
||||
pinned() { sed -n "s/^$2=\"\(.*\)\"/\1/p" "$1" | head -1; }
|
||||
row() { # название закреплено последняя
|
||||
local mark=" "
|
||||
[[ -n $3 && $2 != "$3" ]] && mark="⬆ "
|
||||
printf '%s%-12s закреплено %-12s последняя %s\n' "$mark" "$1" "$2" "${3:-?}"
|
||||
}
|
||||
|
||||
echo "Закреплённые версии и последние релизы (⬆ – есть новее, прочитайте список изменений и протестируйте):"
|
||||
row "3X-UI" "$(pinned scripts/3x-ui.sh XUI_VERSION)" "$(latest MHSanaei/3x-ui '^v[0-9]+\.[0-9]+\.[0-9]+$')"
|
||||
row "Xray-core" "$(pinned scripts/3x-ui.sh XRAY_CORE)" "$(latest XTLS/Xray-core '^v[0-9]+\.[0-9]+\.[0-9]+$')"
|
||||
zv=$(sed -n "s#.*zashboard/releases/download/\(v[0-9.]*\)/dist.zip.*#\1#p" tools/lib/mihomo.js | head -1)
|
||||
row "zashboard" "$zv" "$(latest Zephyruso/zashboard '^v[0-9]+\.[0-9]+\.[0-9]+$')"
|
||||
printf ' %-12s последняя %s\n' "Mihomo" "$(latest MetaCubeX/mihomo '^v[0-9]+\.[0-9]+\.[0-9]+$')"
|
||||
printf ' %-12s последняя %s\n' "sing-box" "$(latest SagerNet/sing-box '^v[0-9]+\.[0-9]+\.[0-9]+$')"
|
||||
echo
|
||||
echo "Что проверить при новой версии 3X-UI или Xray: tests/matrix (протокол × клиент), kit panel update, kit check."
|
||||
echo "Новое ядро Xray сначала проверяется клиентами: на тест-сервере с KIT запустите bash tools/core-compat.sh vX.Y.Z"
|
||||
echo "(с 26.9.30 REALITY требует от клиента ключ X25519MLKEM768: Mihomo его отправляет, sing-box пока нет). Версия ядра в KIT"
|
||||
echo "меняется, только когда проверка показывает «работает» у обоих клиентов."
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Проверка, подходит ли новая версия ядра Xray клиентам на Mihomo и sing-box (REALITY по TCP).
|
||||
# Запускать от root на ТЕСТОВОМ сервере с установленным KIT: скрипт на время подменяет ядро панели
|
||||
# проверенной по SHA256 версией, прогоняет клиентов и в любом случае возвращает прежнее ядро.
|
||||
#
|
||||
# bash core-compat.sh v26.9.30 проверить версию ядра
|
||||
# bash core-compat.sh проверить то, что сейчас стоит (контроль)
|
||||
# MIHOMO=v1.19.32 SINGBOX=1.14.2 bash core-compat.sh v26.9.30 взять конкретные клиенты
|
||||
# MIHOMO_SHA256=… SINGBOX_SHA256=… свои суммы архивов клиентов, если GitHub не прислал digest
|
||||
set -Eeuo pipefail
|
||||
|
||||
CAND=${1:-}
|
||||
BIN=/usr/local/x-ui/bin/xray-linux-amd64
|
||||
W=$(mktemp -d); BAK=$W/xray.orig
|
||||
[[ $EUID -eq 0 && -x $BIN ]] || { echo "Нужен root на сервере с панелью 3X-UI." >&2; exit 1; }
|
||||
command -v kit >/dev/null || { echo "Нужен установленный kit." >&2; exit 1; }
|
||||
arch=$(uname -m); case $arch in x86_64) xa=64; ma=amd64 ;; aarch64) xa=arm64-v8a; ma=arm64 ;; *) echo "Только amd64 и arm64." >&2; exit 1 ;; esac
|
||||
|
||||
restore() { if [[ -f $BAK ]]; then systemctl stop x-ui; install -m 755 "$BAK" "$BIN"; systemctl start x-ui; sleep 8; fi; pkill -f "$W/" 2>/dev/null || true; rm -rf "$W"; }
|
||||
trap restore EXIT
|
||||
|
||||
gh() { curl -fsSL "$1"; }
|
||||
latest_tag() { gh "https://api.github.com/repos/$1/releases?per_page=30" | python3 -c "
|
||||
import json, sys
|
||||
for r in json.load(sys.stdin):
|
||||
if not r['prerelease'] and not r['draft']:
|
||||
print(r['tag_name']); break"; }
|
||||
|
||||
# --- ядро-кандидат ---
|
||||
if [[ -n $CAND ]]; then
|
||||
cp -a "$BIN" "$BAK"
|
||||
gh "https://github.com/XTLS/Xray-core/releases/download/$CAND/Xray-linux-$xa.zip" >"$W/x.zip"
|
||||
gh "https://github.com/XTLS/Xray-core/releases/download/$CAND/Xray-linux-$xa.zip.dgst" >"$W/x.dgst"
|
||||
want=$(awk 'tolower($0) ~ /sha2-256/ {print $NF; exit}' "$W/x.dgst"); got=$(sha256sum "$W/x.zip" | awk '{print $1}')
|
||||
[[ -n $want && $want == "$got" ]] || { echo "Сумма ядра $CAND не совпала – остановился." >&2; exit 1; }
|
||||
unzip -q -o "$W/x.zip" xray -d "$W"; systemctl stop x-ui; install -m 755 "$W/xray" "$BIN"; systemctl start x-ui; sleep 8
|
||||
fi
|
||||
echo "Ядро на сервере: $("$BIN" -version | head -1)"
|
||||
|
||||
# --- клиенты (суммы – из метаданных релиза) ---
|
||||
fetch() { # репозиторий метка-файла-шаблон выходной-файл
|
||||
gh "https://api.github.com/repos/$1/releases/tags/$2" | python3 -c "
|
||||
import json, re, sys
|
||||
r = json.load(sys.stdin)
|
||||
for a in r['assets']:
|
||||
if re.fullmatch(sys.argv[1], a['name']):
|
||||
print(a['browser_download_url'], (a.get('digest') or '').replace('sha256:', '')); break" "$3"; }
|
||||
MV=${MIHOMO:-$(latest_tag MetaCubeX/mihomo)}; SV=${SINGBOX:-$(latest_tag SagerNet/sing-box | sed 's/^v//')}
|
||||
read -r murl msum < <(fetch MetaCubeX/mihomo "$MV" "mihomo-linux-$ma-compatible-$MV.gz") || true
|
||||
read -r surl ssum < <(fetch SagerNet/sing-box "v$SV" "sing-box-$SV-linux-$ma.tar.gz") || true
|
||||
# Без суммы ничего не запускаем: либо digest из GitHub, либо закреплённая вручную.
|
||||
msum=${MIHOMO_SHA256:-${msum:-}}; ssum=${SINGBOX_SHA256:-${ssum:-}}
|
||||
[[ -n ${murl:-} && $msum =~ ^[0-9a-f]{64}$ ]] || { echo "Нет архива или SHA256 для mihomo $MV (GitHub не прислал digest) – не ставлю. Задайте MIHOMO_SHA256=… со страницы релиза." >&2; exit 1; }
|
||||
[[ -n ${surl:-} && $ssum =~ ^[0-9a-f]{64}$ ]] || { echo "Нет архива или SHA256 для sing-box $SV (GitHub не прислал digest) – не ставлю. Задайте SINGBOX_SHA256=… со страницы релиза." >&2; exit 1; }
|
||||
gh "$murl" >"$W/m.gz"; [[ $(sha256sum "$W/m.gz" | awk '{print $1}') == "$msum" ]] || { echo "Сумма mihomo не совпала." >&2; exit 1; }
|
||||
gunzip -c "$W/m.gz" >"$W/mihomo"; chmod +x "$W/mihomo"
|
||||
gh "$surl" >"$W/s.tgz"; [[ $(sha256sum "$W/s.tgz" | awk '{print $1}') == "$ssum" ]] || { echo "Сумма sing-box не совпала." >&2; exit 1; }
|
||||
mkdir -p "$W/sb"; tar xzf "$W/s.tgz" -C "$W/sb" --strip-components=1
|
||||
|
||||
link=$(KIT_NO_QR=1 kit user link "${USER_NAME:-admin}" --all 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep -a -E '^ vless://.*security=reality.*type=tcp' | head -1 | tr -d ' ')
|
||||
[[ -n $link ]] || { echo "Не нашёл ссылку REALITY (tcp) у пользователя ${USER_NAME:-admin}." >&2; exit 1; }
|
||||
export LINK=$link W
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
u = urlparse(os.environ["LINK"]); q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
||||
W = os.environ["W"]
|
||||
json.dump({"mixed-port": 17890, "log-level": "warning", "rules": ["MATCH,P"], "proxies": [{
|
||||
"name": "P", "type": "vless", "server": u.hostname, "port": u.port, "uuid": u.username, "network": "tcp", "tls": True,
|
||||
"udp": True, "flow": q.get("flow", ""), "servername": q["sni"], "client-fingerprint": q.get("fp", "chrome"),
|
||||
"reality-opts": {"public-key": q["pbk"], "short-id": q["sid"], "support-x25519mlkem768": q.get("support-x25519mlkem768") == "true"}}]}, open(W + "/m.yaml", "w"))
|
||||
json.dump({"log": {"level": "warn"}, "inbounds": [{"type": "mixed", "listen": "127.0.0.1", "listen_port": 17891}], "outbounds": [{
|
||||
"type": "vless", "server": u.hostname, "server_port": u.port, "uuid": u.username, "flow": q.get("flow", ""),
|
||||
"tls": {"enabled": True, "server_name": q["sni"], "utls": {"enabled": True, "fingerprint": q.get("fp", "chrome")},
|
||||
"reality": {"enabled": True, "public_key": q["pbk"], "short_id": q["sid"]}}}]}, open(W + "/s.json", "w"))
|
||||
PY
|
||||
|
||||
probe() { # порт
|
||||
curl -sS -m 12 -x "http://127.0.0.1:$1" -o /dev/null -w '%{http_code}' "https://www.cloudflare.com/cdn-cgi/trace?x=$RANDOM" 2>/dev/null || true
|
||||
}
|
||||
setsid nohup "$W/mihomo" -d "$W" -f "$W/m.yaml" >"$W/m.log" 2>&1 </dev/null & sleep 5
|
||||
mc=$(probe 17890); pkill -f "$W/mihomo" || true
|
||||
setsid nohup "$W/sb/sing-box" run -c "$W/s.json" >"$W/s.log" 2>&1 </dev/null & sleep 4
|
||||
sc=$(probe 17891); pkill -f "$W/sb/sing-box" || true
|
||||
|
||||
ok() { [[ $1 == 200 ]] && echo "работает" || echo "НЕ РАБОТАЕТ (http=${1:-000})"; }
|
||||
echo
|
||||
echo "Ядро Xray: ${CAND:-как стоит}"
|
||||
echo " Mihomo $MV: $(ok "$mc")"
|
||||
echo " sing-box $SV: $(ok "$sc")"
|
||||
echo "Xray-клиенты (Happ, v2rayN) здесь не проверяются: они на том же ядре."
|
||||
[[ $mc == 200 && $sc == 200 ]]
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bash
|
||||
# Робот ежесуточной синхронизации: проверяет обновления у автора проекта и, если они есть,
|
||||
# переносит их в нашу ветку dev — но только когда всё сошлось.
|
||||
#
|
||||
# Где живёт: запускается из .gitea/workflows/fork-sync.yml на раннере (CT 114 «runner»).
|
||||
# Что НЕ делает: не выпускает релиз и не подписывает его — закрытый ключ подписи остаётся
|
||||
# только на рабочей станции владельца, поэтому версия автора публикуется вручную.
|
||||
#
|
||||
# Переменные окружения (все имеют значения по умолчанию, кроме TOKEN):
|
||||
# TOKEN токен Gitea с правом записи в репозиторий (берётся из секретов workflow)
|
||||
# GITEA_URL внутренний адрес Gitea (снаружи контейнер её не видит: имя резолвится в публичный IP)
|
||||
# REPO владелец/репозиторий
|
||||
# UPSTREAM адрес авторского репозитория
|
||||
# WORK рабочая копия на раннере
|
||||
# BRANCH наша ветка для синхронизации
|
||||
# LOG файл отчёта последнего запуска (его читают из контейнера руками)
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
GITEA_URL=${GITEA_URL:-http://10.10.10.54:3000}
|
||||
REPO=${REPO:-hacker07/3X-UI_autoVPS}
|
||||
UPSTREAM=${UPSTREAM:-https://github.com/itsnotkubrick/3X-UI_KIT}
|
||||
WORK=${WORK:-/opt/kit-fork-sync/repo}
|
||||
BRANCH=${BRANCH:-dev}
|
||||
LOG=${LOG:-/opt/kit-fork-sync/last-run.log}
|
||||
TOKEN=${TOKEN:-}
|
||||
|
||||
say() { printf '\n==> %s\n' "$*"; }
|
||||
ok() { printf ' ✓ %s\n' "$*"; }
|
||||
die() { printf '✗ %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
printf '\n===== запуск %s =====\n' "$(date '+%F %T %Z')"
|
||||
|
||||
[[ -n $TOKEN ]] || die "нет TOKEN: workflow не передал секрет GITEA_TOKEN"
|
||||
command -v git >/dev/null || die "нет git"
|
||||
PY=${PY:-$(command -v python3 || command -v python || true)}
|
||||
[[ -n $PY ]] || die "нет python3 — без него не применить швы"
|
||||
|
||||
auth_url="${GITEA_URL}/${REPO}.git"
|
||||
auth_url="${auth_url/http:\/\//http://x-access-token:${TOKEN}@}"
|
||||
|
||||
# ---------------------------------------------------------------- рабочая копия
|
||||
|
||||
if [[ ! -d $WORK/.git ]]; then
|
||||
say "Первая синхронизация: клонирую репозиторий"
|
||||
git clone -q "$auth_url" "$WORK" || die "не смог склонировать $REPO"
|
||||
git -C "$WORK" remote set-url origin "$auth_url"
|
||||
git -C "$WORK" remote add upstream "$UPSTREAM" 2>/dev/null || git -C "$WORK" remote set-url upstream "$UPSTREAM"
|
||||
else
|
||||
git -C "$WORK" remote set-url origin "$auth_url"
|
||||
git -C "$WORK" remote set-url upstream "$UPSTREAM" 2>/dev/null || git -C "$WORK" remote add upstream "$UPSTREAM"
|
||||
fi
|
||||
cd "$WORK"
|
||||
|
||||
say "Забираю ветки: наша $BRANCH и авторскую main"
|
||||
git fetch -q origin --prune
|
||||
git fetch -q upstream 'refs/heads/main:refs/heads/upstream' --prune
|
||||
git checkout -q "$BRANCH"
|
||||
git reset -q --hard "origin/$BRANCH"
|
||||
|
||||
author_version=$(git show upstream:VERSION 2>/dev/null | tr -d '[:space:]' || true)
|
||||
[[ -n $author_version ]] || die "у автора не читается VERSION — проверьте ветку upstream"
|
||||
ours=$(tr -d '[:space:]' <VERSION)
|
||||
echo " у автора: $author_version · у нас: $ours"
|
||||
|
||||
new_commits=$(git rev-list --count "$BRANCH..upstream")
|
||||
if [[ $new_commits == 0 ]]; then
|
||||
say "У автора нет новых коммитов — публиковать нечего"
|
||||
ok "проверка выполнена, ветка $BRANCH не изменялась"
|
||||
exit 0
|
||||
fi
|
||||
say "У автора $new_commits новых коммитов — переношу наши правки на свежий код"
|
||||
git --no-pager log --oneline "$BRANCH..upstream" | sed 's/^/ /'
|
||||
|
||||
# ---------------------------------------------------------------- перенос и проверки
|
||||
|
||||
# Скрипт форка делает перенос (rebase на ветку автора) и все проверки: швы, синтаксис, LF, тесты.
|
||||
# Без --apply: швы в нашей ветке уже накатаны, повторный накат на такое дерево падает (якоря автора
|
||||
# заменены нашими строками). Номер версии поднимаем сами — по той же логике, что и в fork-sync.sh.
|
||||
if ! bash tools/fork-sync.sh; then
|
||||
die "перенос не прошёл (конфликт в местах наших правок). Ветка $BRANCH не изменена. Разберите вручную на рабочей станции"
|
||||
fi
|
||||
|
||||
# Номер версии: у автора 1.3 → наш 1.3.1; если база та же, а мы дорабатываем — 1.3.2. Обязан расти.
|
||||
base=${author_version%%.*}
|
||||
cur=$(tr -d '[:space:]' <VERSION)
|
||||
if [[ ${cur%%.*} == "$base" ]]; then
|
||||
new_version="$base.$(( ${cur##*.} + 1 ))"
|
||||
else
|
||||
new_version="${author_version}.1"
|
||||
fi
|
||||
printf '%s\n' "$new_version" >VERSION
|
||||
for f in scripts/kit.sh scripts/3x-ui.sh scripts/hysteria2.sh scripts/3x-ui-local.sh; do
|
||||
sed -i "s/^KIT_VERSION=\"[0-9.]*\"/KIT_VERSION=\"$new_version\"/" "$f"
|
||||
done
|
||||
"$PY" tools/fork_apply.py >/dev/null 2>&1 || true # на дереве со швами это ожидаемо: якорей уже нет
|
||||
echo " версия сборки: $cur → $new_version"
|
||||
|
||||
# Заглушка записи в нашем журнале версий: без раздела «## v<версия>» сборщик релиза откажется
|
||||
# подписывать. Текст — на владельца, он правит его перед выпуском.
|
||||
if ! grep -q "^## v$new_version" FORK-CHANGELOG.md; then
|
||||
say "Добавляю запись в FORK-CHANGELOG для версии $new_version"
|
||||
"$PY" - "$new_version" "$author_version" <<'PY'
|
||||
import sys, pathlib
|
||||
new, author = sys.argv[1], sys.argv[2]
|
||||
p = pathlib.Path("FORK-CHANGELOG.md")
|
||||
text = p.read_text(encoding="utf-8")
|
||||
block = (f"## v{new}: обновление от автора {author}\n\n"
|
||||
f"* Автоматическая синхронизация подтянула код автора {author} в нашу сборку: наши правки (швы)\n"
|
||||
f" перенесены на свежий код, проверки прошли. Описать, что изменилось у автора, до выпуска релиза.\n\n")
|
||||
marker = "## v"
|
||||
i = text.find(marker)
|
||||
p.write_text(text[:i] + block + text[i:], encoding="utf-8", newline="\n")
|
||||
print(f" ✓ раздел «## v{new}» добавлен")
|
||||
PY
|
||||
fi
|
||||
|
||||
# Финальный контроль: швы на месте, синтаксис и LF — как того требует сборщик релиза.
|
||||
"$PY" tools/fork_apply.py --check >/dev/null || die "после переноса швы не проверяются — ничего не публикую"
|
||||
for f in scripts/*.sh tools/*.sh; do bash -n "$f" || die "$f: синтаксис"; done
|
||||
if grep -qU $'\r' scripts/*.sh tools/*.sh VERSION 2>/dev/null; then
|
||||
die "в файлах CRLF — сервер сверяет SHA256, сумма не сойдётся. Ничего не публикую"
|
||||
fi
|
||||
ok "швы на месте, синтаксис и переводы строк в порядке"
|
||||
|
||||
# ---------------------------------------------------------------- публикация
|
||||
|
||||
if git diff --quiet origin/"$BRANCH" -- VERSION scripts tools .gitea 2>/dev/null && [[ $new_version == "$ours" ]]; then
|
||||
say "Содержимое не изменилось — публиковать нечего"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say "Публикую в $BRANCH"
|
||||
git add -A
|
||||
git -c user.name="fork-sync-bot" -c user.email="fork-sync@rukavishnikoff.ru" \
|
||||
commit -q -m "Синхронизация с автором ${author_version}: швы и версия ${new_version}" \
|
||||
-m "Автоматика CT 114. Проверки: швы на месте, bash -n, LF. Релиз и подпись — вручную с рабочей станции." \
|
||||
|| die "не смог закоммитить"
|
||||
curl -fsS -o /dev/null -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/branches/${BRANCH}" || die "Gitea недоступна перед отправкой"
|
||||
# Перенос идёт rebase'ом: наша ветка переписывается на свежий код автора, поэтому отправляем
|
||||
# с --force-with-lease — если за это время в ветку что-то попало с рабочей станции, отправка
|
||||
# откажется, а не затрёт чужой коммит.
|
||||
git push -q --force-with-lease=refs/heads/"$BRANCH" origin "$BRANCH" \
|
||||
|| die "не смог отправить ветку $BRANCH (возможно, в ней есть ваши свежие коммиты — тогда синхронизируйте вручную)"
|
||||
ok "ветка $BRANCH обновлена: $(git --no-pager log --oneline -1)"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Готово. Дальше вручную, с рабочей станции:
|
||||
1) git fetch origin && git checkout $BRANCH && git reset --hard origin/$BRANCH
|
||||
2) описать изменения в FORK-CHANGELOG.md (раздел уже добавлен)
|
||||
3) bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key — подпись и релиз
|
||||
4) git tag v$new_version && git push origin $BRANCH v$new_version
|
||||
EOF
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# 3X-UI_KIT (форк hacker07): подтянуть обновление автора в ветку dev и прогнать проверки.
|
||||
#
|
||||
# Ничего не публикует и ничего не обновляет на серверах: после проверок печатает, что делать
|
||||
# дальше (версия, подпись, тег, push). Запускать с рабочей станции, по шагам.
|
||||
#
|
||||
# bash tools/fork-sync.sh # подтянуть автора и проверить
|
||||
# bash tools/fork-sync.sh --apply # ещё и вписать VERSION/KIT_VERSION (номер версии)
|
||||
#
|
||||
# Логика версии: у автора 1.3 → наш номер 1.3.1; если база та же, а мы что-то доработали —
|
||||
# 1.3.2. Номер обязан строго расти: kit у серверов не принимает откат назад.
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
PY=${PY:-$(command -v python3 || command -v python || true)}
|
||||
[[ -n $PY ]] || { echo "! нужен python3" >&2; exit 1; }
|
||||
|
||||
apply=no
|
||||
case ${1:-} in --apply) apply=yes ;; "") ;; *) echo "Использование: bash tools/fork-sync.sh [--apply]" >&2; exit 1 ;; esac
|
||||
|
||||
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
die() { printf '! %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[[ -z $(git status --porcelain) ]] || die "в рабочей копии есть незакоммиченные правки — сначала закоммить или убери их"
|
||||
|
||||
say "Забираю ветку автора"
|
||||
git fetch --prune upstream 'refs/heads/main:refs/heads/upstream'
|
||||
author_version=$(git show upstream:VERSION | tr -d '[:space:]')
|
||||
echo "у автора: $author_version"
|
||||
|
||||
say "Переношу наши правки на свежую ветку автора"
|
||||
git checkout -q dev
|
||||
if ! git rebase upstream; then
|
||||
echo
|
||||
echo "Конфликт при переносе. Это ожидаемо, если автор переписал те же строки."
|
||||
echo " 1) разбери конфликт: git status, правь файлы, git add, git rebase --continue"
|
||||
echo " 2) если проще собрать швы заново: git rebase --abort, затем"
|
||||
echo " git checkout -B dev upstream && $PY tools/fork_apply.py && git commit -am 'fork: швы'"
|
||||
die "остановился, ничего не публикую"
|
||||
fi
|
||||
|
||||
say "Проверяю, что наши швы ещё на месте"
|
||||
"$PY" tools/fork_apply.py --check
|
||||
|
||||
say "Проверки кода (как на стороне сервера и в CI автора)"
|
||||
for f in scripts/*.sh tools/*.sh; do bash -n "$f" || die "$f: синтаксис"; done
|
||||
echo " ✓ bash -n"
|
||||
"$PY" -m py_compile scripts/kit-sub.py && echo " ✓ python-скрипты собираются"
|
||||
if grep -qU $'\r' scripts/*.sh tools/*.sh VERSION 2>/dev/null; then
|
||||
die "в файлах есть CRLF — сервер сверяет SHA256, и его сумма не сойдётся. Проверь core.autocrlf (нужен false)"
|
||||
fi
|
||||
echo " ✓ переводы строк LF"
|
||||
if command -v shellcheck >/dev/null; then
|
||||
shellcheck -S warning -e SC1090,SC2034,SC2211 scripts/*.sh tools/release.sh tools/fork-sync.sh && echo " ✓ shellcheck"
|
||||
else
|
||||
echo " – shellcheck не установлен, пропускаю"
|
||||
fi
|
||||
if command -v node >/dev/null; then
|
||||
node --test tools/test/*.test.js >/dev/null && echo " ✓ node --test"
|
||||
else
|
||||
echo " – node нет, пропускаю тесты генераторов"
|
||||
fi
|
||||
"$PY" tools/test/restore_env_test.py >/dev/null && "$PY" tools/test/restore_archive_test.py >/dev/null \
|
||||
&& echo " ✓ тесты копии (--restore)"
|
||||
|
||||
say "Готово: наши правки лежат на свежем коде автора"
|
||||
git --no-pager log --oneline upstream..dev
|
||||
echo
|
||||
echo "Версия автора: $author_version · наша сейчас: $(tr -d '[:space:]' <VERSION)"
|
||||
|
||||
if [[ $apply == yes ]]; then
|
||||
base=${author_version%%.*}
|
||||
cur=$(tr -d '[:space:]' <VERSION)
|
||||
if [[ ${cur%%.*} == "$base" ]]; then
|
||||
new="$base.$(( ${cur##*.} + 1 ))"
|
||||
else
|
||||
new="$author_version.1"
|
||||
fi
|
||||
printf '%s\n' "$new" >VERSION
|
||||
"$PY" tools/fork_apply.py >/dev/null
|
||||
echo "Версия сборки → $new (VERSION и KIT_VERSION в трёх скриптах)"
|
||||
fi
|
||||
|
||||
cat <<'NEXT'
|
||||
|
||||
Дальше вручную, по шагам:
|
||||
1) подпись и релиз: bash tools/release.sh ~/.ssh/3x-ui-kit-fork/release_key
|
||||
2) коммит и тег: git add -A && git commit -m "Релиз v$(cat VERSION): швы форка + подпись"
|
||||
git tag v$(cat VERSION)
|
||||
3) публикация: git push origin dev v$(cat VERSION) upstream
|
||||
4) на сервере: kit update --force && kit check
|
||||
NEXT
|
||||
@@ -0,0 +1,709 @@
|
||||
#!/usr/bin/env python3
|
||||
"""3X-UI_KIT (форк hacker07): накатывает наши «швы» на чистый код автора.
|
||||
|
||||
Это источник правды для форка: единственное место, где описано, ЧТО именно мы правим в
|
||||
файлах автора. Всё, что мы дописываем по существу, живёт в scripts/kit-local.sh — файле,
|
||||
который автор не трогает, поэтому обновления автора его не ломают.
|
||||
|
||||
Правила:
|
||||
* каждый якорь обязан найтись РОВНО один раз, иначе — стоп (автор изменил код, правку
|
||||
надо пересмотреть руками);
|
||||
* при любой ошибке ничего не пишется наполовину: сначала прогоняй --check;
|
||||
* открытый ключ подписи берётся из tools/fork-signer.pub (закрытая часть — только на
|
||||
рабочей станции владельца, в репозиторий не попадает).
|
||||
|
||||
Использование:
|
||||
python3 tools/fork_apply.py --check [корень-репозитория]
|
||||
python3 tools/fork_apply.py [корень-репозитория]
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
GITEA = os.environ.get("FORK_GITEA_BASE", "https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS")
|
||||
SIGNER_FILE = os.environ.get("FORK_SIGNER_PUB", "tools/fork-signer.pub")
|
||||
|
||||
# README.md форка — НАШ файл целиком: авторское описание из него убрано, чтобы никто не
|
||||
# ставил сервер авторской командой и не оказался на авторском канале обновлений. Шва здесь нет:
|
||||
# при обновлении из авторского проекта README конфликтует и решается в нашу пользу (см. FORK.md).
|
||||
HOOK = """
|
||||
# --- 3X-UI_KIT (форк hacker07): локальные команды и хуки ---
|
||||
# Подключаем scripts/kit-local.sh, если он установлен. Наш файл: правки автора его не касаются.
|
||||
# Все команды форка — под «kit local …» (см. kit local help).
|
||||
KIT_LOCAL_LIB="${KIT_LOCAL_LIB:-/usr/local/lib/kit/kit-local.sh}"
|
||||
if [[ -r $KIT_LOCAL_LIB ]]; then . "$KIT_LOCAL_LIB"; kit_local_dispatch "$@"; fi
|
||||
"""
|
||||
|
||||
|
||||
def die(msg):
|
||||
print("! " + msg, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print("==> " + msg)
|
||||
|
||||
|
||||
def count(text, anchor, regex):
|
||||
if regex:
|
||||
return len(re.findall(anchor, text, re.MULTILINE))
|
||||
return text.count(anchor)
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:]]
|
||||
check = False
|
||||
if "--check" in args:
|
||||
check = True
|
||||
args.remove("--check")
|
||||
root = pathlib.Path(args[0] if args else ".").resolve()
|
||||
if not (root / "VERSION").exists():
|
||||
die("%s не похож на репозиторий 3X-UI_autoVPS (нет VERSION)" % root)
|
||||
os.chdir(root)
|
||||
|
||||
version = (root / "VERSION").read_text(encoding="utf-8").strip()
|
||||
if not re.fullmatch(r"[0-9]+(\.[0-9]+)+", version):
|
||||
die("VERSION=%r: нужен числовой номер вида 1.2.1" % version)
|
||||
|
||||
signer_path = pathlib.Path(SIGNER_FILE)
|
||||
if not signer_path.exists():
|
||||
die(
|
||||
"нет %s — сначала создай ключ подписи:\n"
|
||||
" ssh-keygen -t ed25519 -C \"3X-UI KIT fork releases\" -f ~/.ssh/3x-ui-kit-fork/release_key\n"
|
||||
" mkdir -p tools && ssh-keygen -y -f ~/.ssh/3x-ui-kit-fork/release_key > tools/fork-signer.pub"
|
||||
% SIGNER_FILE
|
||||
)
|
||||
pub = ""
|
||||
for line in signer_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith(("ssh-ed25519 ", "ssh-rsa ")):
|
||||
pub = line
|
||||
break
|
||||
if not pub:
|
||||
die("%s: не нашёл строку открытого ключа (ssh-ed25519 …)" % signer_path)
|
||||
|
||||
version_re = r'^KIT_VERSION="[0-9.]+"$'
|
||||
edits = [
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'# kit – управление сервером 3X-UI KIT: пользователи, обновление, резервная копия.\n',
|
||||
False,
|
||||
'# kit – управление сервером 3X-UI_autoVPS: пользователи, обновление, резервная копия.\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'kit_ref_raw() { echo "https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v$1"; }\n',
|
||||
False,
|
||||
'kit_ref_raw() { echo "https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$1"; }\n\n# --- 3X-UI_KIT (форк hacker07): локальные команды и хуки ---\n# Подключаем scripts/kit-local.sh, если он установлен. Наш файл: правки автора его не касаются.\n# Все команды форка — под «kit local …» (см. kit local help).\nKIT_LOCAL_LIB="${KIT_LOCAL_LIB:-/usr/local/lib/kit/kit-local.sh}"\nif [[ -r $KIT_LOCAL_LIB ]]; then . "$KIT_LOCAL_LIB"; kit_local_dispatch "$@"; fi\n\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'Description=3X-UI KIT: общий лимит трафика пользователей (сумма по всем записям)\n',
|
||||
False,
|
||||
'Description=3X-UI_autoVPS: общий лимит трафика пользователей (сумма по всем записям)\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'Description=3X-UI KIT: проверка общего лимита трафика раз в 5 минут\n',
|
||||
False,
|
||||
'Description=3X-UI_autoVPS: проверка общего лимита трафика раз в 5 минут\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' # SHA256:bNjI10eGVGjdFeDJnAHvUFi73rlrXVy6vKFf8lmNyxU (itsnotkubrick, 2026-10-14; прежний ключ SHA256:VDuGgJ8d… утерян, снят)\n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEWDFGq2Wno/ADRS2E6UKsgFbUSLvaiPIfRBvSEGJr81 3X-UI KIT releases"\n',
|
||||
False,
|
||||
' # Наш шов: здесь только ключ форка (hacker07). Авторские ключи сняты намеренно — сервер обязан\n # принимать обновления лишь из нашего канала (git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS).\n "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI_autoVPS fork releases (hacker07)"\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'update_hint() {\n',
|
||||
False,
|
||||
'update_hint() { # подсказка про новую версию\n [[ -d /etc/kit ]] || return 0 # kit удалён — подсказывать нечего (раньше тут сыпались ошибки про latest-version)\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' echo "3X-UI KIT $KIT_VERSION"\n',
|
||||
False,
|
||||
' echo "3X-UI_autoVPS $KIT_VERSION"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' curl -fsS -m 5 "$KIT_RAW/CHANGELOG.md" 2>/dev/null | awk -v v="## v$1" \'\n',
|
||||
False,
|
||||
' { curl -fsS -m 5 "$KIT_RAW/CHANGELOG.md" 2>/dev/null; curl -fsS -m 5 "$KIT_RAW/FORK-CHANGELOG.md" 2>/dev/null; } | awk -v v="## v$1" \'\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' grep -qx "# 3X-UI KIT $v" "$d/SHA256SUMS" || { warn "Подписанный релиз не той версии – ничего не ставлю."; return 1; }\n for f in scripts/kit.sh scripts/kit-sub.py; do\n',
|
||||
False,
|
||||
' grep -qxE "# (3X-UI KIT|3X-UI_autoVPS) $v" "$d/SHA256SUMS" || { warn "Подписанный релиз не той версии – ничего не ставлю."; return 1; }\n for f in scripts/kit.sh scripts/kit-sub.py scripts/kit-local.sh; do\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'Description=kit-sub: подписка с учётом приложения (3X-UI KIT)\n',
|
||||
False,
|
||||
'Description=kit-sub: подписка с учётом приложения (3X-UI_autoVPS)\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'Description=3X-UI KIT: автообновление kit и kit-sub (только подписанные релизы)\n',
|
||||
False,
|
||||
'Description=3X-UI_autoVPS: автообновление kit и kit-sub (только подписанные релизы)\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'Description=3X-UI KIT: проверка обновлений раз в сутки\n',
|
||||
False,
|
||||
'Description=3X-UI_autoVPS: проверка обновлений раз в сутки\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' if [[ $latest == "$KIT_VERSION" ]]; then say "Применяю исправления версии $latest"; else say "3X-UI KIT $KIT_VERSION → $latest"; fi\n',
|
||||
False,
|
||||
' if [[ $latest == "$KIT_VERSION" ]]; then say "Применяю исправления версии $latest"; else say "3X-UI_autoVPS $KIT_VERSION → $latest"; fi\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' install -m 755 "$tmp/kit.sh" /usr/local/bin/kit.new && mv -f /usr/local/bin/kit.new /usr/local/bin/kit\n',
|
||||
False,
|
||||
' install -m 755 "$tmp/kit.sh" /usr/local/bin/kit.new && mv -f /usr/local/bin/kit.new /usr/local/bin/kit\n # Форк: ставим наши локальные дополнения из того же подписанного релиза.\n install -d -m 755 /usr/local/lib/kit\n install -m 644 "$tmp/kit-local.sh" /usr/local/lib/kit/kit-local.sh\n',
|
||||
'авто-шов: insert, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' echo "${G}✓ Готово: 3X-UI KIT $latest.${N} Пользователи, ссылки и подписки не менялись."\n',
|
||||
False,
|
||||
' echo "${G}✓ Готово: 3X-UI_autoVPS $latest.${N} Пользователи, ссылки и подписки не менялись."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/<base64> с Name "3X-UI KIT"\n',
|
||||
False,
|
||||
'split_panel_ours() { # наш профиль: JSON или ссылка happ://routing/onadd/<base64> с Name "3X-UI_autoVPS"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' grep -q \'"Name":"3X-UI KIT"\' <<<"$v"\n',
|
||||
False,
|
||||
' grep -q \'"Name":"3X-UI_autoVPS"\' <<<"$v"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' box "3X-UI KIT $KIT_VERSION – управление сервером" "1. Пользователи" "2. Протоколы, порты и маскировка" "3. Проверка сервера" "" \\\n',
|
||||
False,
|
||||
' box "3X-UI_autoVPS $KIT_VERSION – управление сервером" "1. Пользователи" "2. Протоколы, порты и маскировка" "3. Проверка сервера" "" \\\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' "7. Резервная копия" "8. Версии" "" "9. Панель 3X-UI (меню x-ui)" "10. Удалить KIT с сервера" "0. Выход"\n',
|
||||
False,
|
||||
' "7. Резервная копия" "8. Версии" "" "9. Доступ к панели, ссылки и заглушка" "10. Панель x-ui" "11. Удалить KIT с сервера" "0. Выход"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' 9) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 10) run_action cmd_uninstall; pause ;;\n',
|
||||
False,
|
||||
' # Наш шов: пункт 9 — доступ к панели, ссылки на подключения и состояние заглушки в одном месте.\n 9) kit local access; pause ;;\n 10) command -v x-ui >/dev/null && x-ui || echo "Меню x-ui не найдено." ;;\n 11) run_action cmd_uninstall; pause ;;\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
' echo "${B}Полное удаление 3X-UI KIT${N}"\n',
|
||||
False,
|
||||
' echo "${B}Полное удаление 3X-UI_autoVPS${N}"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'${B}3X-UI KIT $KIT_VERSION${N} – команды (то же самое есть в меню: просто ${B}kit${N})\n',
|
||||
False,
|
||||
'${B}3X-UI_autoVPS $KIT_VERSION${N} – команды (то же самое есть в меню: просто ${B}kit${N})\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
'KIT_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v$KIT_VERSION"\n',
|
||||
False,
|
||||
'KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v$KIT_VERSION"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
'SNI_CANDIDATES=(dl.google.com www.amazon.com www.samsung.com www.yahoo.com)\n',
|
||||
False,
|
||||
'# Наш шов: добавлены известные сайты (Microsoft, AMD, Intel, NVIDIA) — их можно выбрать вручную.\nSNI_CANDIDATES=(dl.google.com www.amazon.com www.samsung.com www.yahoo.com www.microsoft.com www.amd.com www.intel.com www.nvidia.com)\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
" seq 1 254 | xargs -P 8 -I{} bash -c '\n",
|
||||
False,
|
||||
" # Наш шов: 32 потока вместо 8 и таймаут 3 с — скан подсети занимает секунды, а не минуту.\n seq 1 254 | xargs -P 32 -I{} bash -c '\n",
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' echo | timeout 4 openssl s_client -connect "$ip:443" -tls1_3 -alpn h2 2>/dev/null | openssl x509 -noout -ext subjectAltName 2>/dev/null \\\n | tr "," "\\n" | sed -n "s/^ *DNS://p"\' _ "$1.{}" "$me" 2>/dev/null | sort -u | grep -E "$SNI_RE" | head -40 || true\n',
|
||||
False,
|
||||
' echo | timeout 3 openssl s_client -connect "$ip:443" -tls1_3 -alpn h2 2>/dev/null | openssl x509 -noout -ext subjectAltName 2>/dev/null \\\n | tr "," "\\n" | sed -n "s/^ *DNS://p"\' _ "$1.{}" "$me" 2>/dev/null | sort -u | grep -E "$SNI_RE" | head -60 || true\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' local me a b c base n names found=0\n',
|
||||
False,
|
||||
' local me a b c base n names out\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' [[ $me =~ ^[0-9]{1,3}(\\.[0-9]{1,3}){3}$ ]] || return 0\n IFS=. read -r a b c _ <<<"$me"\n',
|
||||
False,
|
||||
' [[ $me =~ ^[0-9]{1,3}(\\.[0-9]{1,3}){3}$ ]] || return 0\n IFS=. read -r a b c _ <<<"$me"\n # Наш шов: имена проверяются параллельно (8 сразу) — раньше по очереди, и это была основная\n # причина «долго сканирует». Функции передаём в подпроцессы через export -f.\n export -f same_net sni_alive sni_trusted sni_quality sni_ok host_ip\n export HOST\n',
|
||||
'авто-шов: insert, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' names=$(nearby_scan "$base")\n for n in $names; do\n [[ $n == "$me" || $n =~ $SNI_DYN_RE || $n =~ $SNI_BRAND_RE || $n =~ $SNI_BAD_RE ]] && continue\n if same_net "$n" && sni_alive "$n" && sni_trusted "$n" && sni_quality "$n"; then echo "$n"; found=$((found + 1)); ((found >= 8)) && return 0; fi\n done\n ((found > 0)) && return 0\n',
|
||||
False,
|
||||
' names=$(nearby_scan "$base" | grep -vE "^(${me})$" || true)\n [[ -n $names ]] || continue\n out=$(while IFS= read -r n; do\n [[ $n =~ $SNI_DYN_RE || $n =~ $SNI_BRAND_RE || $n =~ $SNI_BAD_RE ]] && continue\n printf \'%s\\n\' "$n"\n done <<<"$names" | xargs -P 8 -I{} bash -c \'same_net "$1" && sni_alive "$1" && sni_trusted "$1" && sni_quality "$1" && printf "%s\\n" "$1"\' _ {} 2>/dev/null | head -8)\n [[ -n $out ]] && { printf \'%s\\n\' "$out"; return 0; }\n',
|
||||
'авто-шов: replace, строк 6',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' [[ -x $acme ]] || { warn "Не нашёл acme.sh, которым установщик 3X-UI получает сертификаты: не могу выпустить сертификат для $DOMAIN."; return 1; }\n',
|
||||
False,
|
||||
' [[ -x $acme ]] || { warn "Не нашёл acme.sh, которым установщик 3X-UI получает сертификаты: не могу выпустить сертификат для $DOMAIN."; return 1; }\n # Наш шов: лимит Let\'s Encrypt считается на точный набор имён (5 сертификатов за 7 суток). Если у acme.sh\n # уже лежит годный сертификат на этот домен — берём его и не заказываем новый, лишний заказ упирается в лимит.\n local need_issue=yes\n if [[ -s /root/.acme.sh/${DOMAIN}_ecc/fullchain.cer ]] \\\n && openssl x509 -in "/root/.acme.sh/${DOMAIN}_ecc/fullchain.cer" -noout -checkhost "$DOMAIN" 2>/dev/null | grep -q \'does match\' \\\n && openssl x509 -in "/root/.acme.sh/${DOMAIN}_ecc/fullchain.cer" -noout -checkend 86400 >/dev/null 2>&1; then\n ok "у acme.sh уже есть годный сертификат для ${B}$DOMAIN${N} — беру его, новый не заказываю"\n need_issue=no\n fi\n',
|
||||
'авто-шов: insert, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' [[ $rc == 0 || $rc == 2 ]] || { warn "Let\'s Encrypt не выдал сертификат для $DOMAIN. Обычно дело в одном из трёх: A-запись ещё не обновилась, порт 80 закрыт у хостера или домен за проксированием Cloudflare. Лог: $log"; return 1; }\n',
|
||||
False,
|
||||
' [[ $rc == 0 || $rc == 2 ]] || {\n # Наш шов: лимит Let\'s Encrypt — частая причина, и она не про сервер. Говорим прямо и с датой повтора.\n if grep -qE \'rateLimited|too many certificates\' "$log"; then\n local till\n till=$(grep -oE \'retry after [0-9-]+ [0-9:]+ UTC\' "$log" | tail -1 | sed \'s/^retry after //\')\n warn "Лимит Let\'s Encrypt: на имя ${B}$DOMAIN${N} за 7 суток уже выпущено 5 сертификатов${till:+ — повтор возможен после $till}. С вашим сервером всё в порядке: DNS, порты и ufw не при чём. Подождите до этой даты либо возьмите другое имя (например node3.…): лимит считается на точный набор имён."\n else\n warn "Let\'s Encrypt не выдал сертификат для $DOMAIN. Обычно дело в одном из трёх: A-запись ещё не обновилась, порт 80 закрыт у хостера или домен за проксированием Cloudflare. Лог: $log"\n fi\n return 1\n }\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' local san="IP:$HOST"\n [[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$HOST"\n',
|
||||
False,
|
||||
' local san="IP:$HOST"\n [[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$HOST"\n # Наш шов: если причина — лимит Let\'s Encrypt, говорим это прямо и с датой повтора, как для домена.\n # Иначе человек читает «частые причины» и ищет проблему у хостера, которой нет.\n local till\n till=$(grep -rhoE \'retry after [0-9-]+ [0-9:]+ UTC\' /var/log/3x-ui-install.log "$HOME"/.acme.sh/*/ca 2>/dev/null | tail -1 | sed \'s/^retry after //\')\n if grep -rqsE \'rateLimited|too many certificates\' /var/log/3x-ui-install.log 2>/dev/null; then\n warn "Причина: лимит Let\'s Encrypt — на этот IP за 7 суток уже выпущено 5 сертификатов${till:+; повтор возможен после $till}. Это ограничение Let\'s Encrypt, а не вашего сервера: порт 80 и файрвол тут не при чём."\n else\n',
|
||||
'авто-шов: insert, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' echo " – сбой у самого Let\'s Encrypt (лог: /var/log/3x-ui-install.log)."\n',
|
||||
False,
|
||||
' echo " – сбой у самого Let\'s Encrypt (лог: /var/log/3x-ui-install.log)."\n } >&2\n fi\n {\n',
|
||||
'авто-шов: insert, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
" _ _ _ _ _ _ _\n (_) |_ ___ _ __ ___ | |_| | ___ _| |__ _ __(_) ___| | __\n | | __/ __| '_ \\ / _ \\| __| |/ / | | | '_ \\| '__| |/ __| |/ /\n | | |_\\__ \\ | | | (_) | |_| <| |_| | |_) | | | | (__| <\n |_|\\__|___/_| |_|\\___/ \\__|_|\\_\\\\__,_|_.__/|_| |_|\\___|_|\\_\\\n",
|
||||
False,
|
||||
" _ _ _ ___ _____\n | | | | __ _ ___| | _____ _ __ / _ \\___ |\n | |_| |/ _` |/ __| |/ / _ \\ '__| | | | | / /\n | _ | (_| | (__| < __/ | | |_| |/ /\n |_| |_|\\__,_|\\___|_|\\_\\___|_| \\___//_/\n",
|
||||
'авто-шов: replace, строк 5',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' echo "${B}3X-UI KIT $KIT_VERSION${N} · it\'s not Kubrick. it\'s just a VPN."\n',
|
||||
False,
|
||||
' echo "${B}3X-UI_autoVPS $KIT_VERSION${N} · сборка форка: панель 3X-UI и VPN-протоколы на одном порту"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP."\n fi\n\n',
|
||||
False,
|
||||
' later "Адрес $HOST не найден на сетевых интерфейсах сервера (бывает за NAT). Если ссылки не работают – запустите с --host и верным IP."\n fi\n\n# ---------------------------------------------------------------------------\n# Наш шов: выбор сайта маскировки по результатам скана подсети.\n# Печатает выбранное имя в stdout (пусто — оставляем автоподбор автора).\n# role — подпись для человека: REALITY или XHTTP/MTProto.\nkl_sni_menu() {\n local role=$1; shift\n local -a found=("$@")\n local -a known=("${SNI_CANDIDATES[@]}")\n local src=${KL_ASK_SRC:-/dev/tty} ans i k n auto_idx known_idx own_idx try\n n=${#found[@]}\n printf \'\\n%s%s%s\\n\' "$B" "$role" "$N" >&2\n if ((n)); then\n printf \'Найдено в подсети:\\n\\n\' >&2\n for i in "${!found[@]}"; do printf \' %d - %s\\n\' "$((i + 1))" "${found[i]}" >&2; done\n auto_idx=$((n + 1)); known_idx=$((n + 2)); own_idx=$((n + 3))\n printf \' %d - автовыбор из списка\\n\' "$auto_idx" >&2\n printf \' %d - выбрать общедоступный (например %s, %s)\\n\' "$known_idx" "${known[0]}" "${known[1]}" >&2\n printf \' %d - ввести свой домен\\n\\n\' "$own_idx" >&2\n else\n auto_idx=0; known_idx=1; own_idx=2\n printf \'В подсети доменов не найдено, выберите:\\n\\n\' >&2\n printf \' 1 - общедоступный (например %s, %s)\\n\' "${known[0]}" "${known[1]}" >&2\n printf \' 2 - ввести свой домен\\n\\n\' >&2\n fi\n printf \'Ваш выбор (Enter — автовыбор): \' >&2\n for try in 1 2 3; do\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n [[ -z $ans ]] && return 0\n [[ $ans =~ ^[0-9]+$ ]] || { printf \' введите номер из списка\\n\' >&2; continue; }\n i=$((10#$ans))\n if ((n)) && ((i >= 1 && i <= n)); then printf \'sni:%s\' "${found[i - 1]}"; return 0; fi\n ((i == auto_idx)) && return 0\n if ((i == known_idx)); then\n printf \'\\nОбщедоступные сайты:\\n\' >&2\n for k in "${!known[@]}"; do printf \' %d - %s\\n\' "$((k + 1))" "${known[k]}" >&2; done\n # Наш шов: последним пунктом — автовыбор из этого списка (раньше выбор был только по номеру).\n printf \' %d - автовыбор из списка\\n\' "$((${#known[@]} + 1))" >&2\n printf \' 0 - назад\\nВаш выбор: \' >&2\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans >= 1 && 10#$ans <= ${#known[@]})); then\n printf \'sni:%s\' "${known[10#$ans - 1]}"; return 0\n fi\n if [[ $ans =~ ^[0-9]+$ ]] && ((10#$ans == ${#known[@]} + 1)); then\n local autopick="${known[RANDOM % ${#known[@]}]}"\n printf \' автовыбор: %s\\n\' "$autopick" >&2\n printf \'sni:%s\' "$autopick"; return 0\n fi\n printf \' ничего не выбрано — оставляю автовыбор\\n\' >&2\n return 0\n fi\n if ((i == own_idx)); then\n printf \' адрес сайта (например vpn.example.com): \' >&2\n ans=""\n read -r -t 300 ans <"$src" 2>/dev/null || true\n ans=${ans//[[:space:]]/}\n [[ $ans =~ ^[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$ ]] && { printf \'own:%s\' "$ans"; return 0; }\n printf \' не похоже на адрес сайта — оставляю автовыбор\\n\' >&2\n return 0\n fi\n printf \' такого номера нет\\n\' >&2\n done\n return 0\n}\n\n',
|
||||
'авто-шов: insert, строк 3',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' elif [[ -z $SNI && $yes == no && $PANEL_SSL == ip && $multi == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
|
||||
False,
|
||||
' # Наш шов: когда выбор сайта делает наше меню (KL_PICK_SNI=1), меню автора не показываем — иначе\n # человек отвечает на «Свой домен / Сосед / Свой сайт», и до скана подсети дело не доходит.\n elif [[ -z $SNI && $yes == no && $PANEL_SSL == ip && $multi == no && -t 1 && ${KL_PICK_SNI:-0} != 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' echo "Подбираю сайт для маскировки среди соседей по подсети… (около 250 коротких обращений к порту 443 соседних адресов, около минуты; не нужно – запустите с --sni или --domain)"\n',
|
||||
False,
|
||||
' echo "Сканирую подсеть: около 250 коротких обращений к порту 443 соседних адресов (около минуты)."\n echo "По результатам предложу сайты для маскировки. Не нужно – запустите с --sni или --domain."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' if ((${#nb[@]})); then\n SNI=${nb[0]}; SNI2=${nb[1]:-}; SNI3=${nb[2]:-}\n',
|
||||
False,
|
||||
' # Наш шов: показываем найденное и даём выбрать — отдельно для REALITY и для XHTTP/MTProto.\n if [[ ${KL_PICK_SNI:-} == 1 && $yes == no ]] && { : </dev/tty; } 2>/dev/null; then\n local pick pick2 own_sni=no\n pick=$(kl_sni_menu "REALITY (TCP 443) — под какой сайт маскировать" ${nb[@]+"${nb[@]}"})\n case $pick in\n own:*)\n own_sni=yes\n DOMAIN=${pick#own:}\n # Наш шов: домен вводится заново, пока он не указывает на этот сервер. Раньше при промахе\n # установка молча маскировалась под чужой сайт, а при домене из флага — обрывалась.\n # A-запись у регистратора расходится не мгновенно, поэтому первым пунктом — «проверить снова».\n while :; do\n if domain_points_here "$DOMAIN"; then\n SNI=$DOMAIN\n ok "маскировка под свой домен: ${B}$DOMAIN${N} (сертификат Let\'s Encrypt выпустит установщик)"\n # Наш шов: спрашиваем, где открывать панель и подписку, и при интерактивном вводе домена тоже.\n # У автора этот вопрос задавался только при домене из флага --domain, а при выборе «ввести свой\n # домен» в меню маскировки — нет, и панель молча оставалась по IP с самоподписанным сертификатом.\n panel_on_choice\n break\n fi\n echo " Домен ${B}$DOMAIN${N} пока не указывает на этот сервер." >&2\n echo " ${B}1${N} проверить снова A-запись расходится по DNS от нескольких минут до нескольких часов" >&2\n echo " ${B}2${N} ввести другой домен" >&2\n echo " ${B}3${N} без своего домена: маскировка соседом по подсети" >&2\n ask_tty " Выбор [1]: " || break\n case ${REPLY//[[:space:]]/} in\n 2) ask_tty " адрес сайта (например vpn.example.com): " || break\n local d=${REPLY//[[:space:]]/}\n [[ -n $d ]] && DOMAIN=$d ;;\n 3) own_sni=no; DOMAIN=""; break ;;\n *) sleep 5 ;;\n esac\n done\n ;;\n sni:*) SNI=${pick#sni:} ;;\n esac\n if [[ -n $SNI ]]; then\n if [[ $own_sni == no ]] && ! sni_ok "$SNI"; then\n warn "$SNI не отвечает по TLS 1.3 + HTTP/2 — беру найденное в подсети"\n SNI="${nb[0]:-$SNI}"\n fi\n ok "маскировка REALITY: ${B}$SNI${N}"\n fi\n if [[ " ${PROTOS[*]} " == *" xhttp "* || " ${PROTOS[*]} " == *" mtproto "* ]]; then\n pick2=$(kl_sni_menu "XHTTP и MTProto (различаются по имени сайта) — какой сайт взять" ${nb[@]+"${nb[@]}"})\n [[ $pick2 == sni:* ]] && pick2=${pick2#sni:}\n [[ $pick2 == own:* ]] && pick2=${pick2#own:}\n [[ -n $pick2 ]] && SNI2=$pick2\n [[ -n $SNI2 ]] && ok "маскировка XHTTP/MTProto: ${B}$SNI2${N}"\n fi\n fi\n if ((${#nb[@]})); then ok "подходящих соседей найдено: ${#nb[@]}"\n else warn "в подсети подходящих сайтов не нашлось (адреса проверены, откликов нет)"; fi\n if [[ -z $SNI ]] && ((${#nb[@]})); then\n SNI=${nb[0]}; SNI2=${SNI2:-${nb[1]:-}}; SNI3=${nb[2]:-}\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' ok "найден: ${B}$SNI${N} · та же подсеть · TLS 1.3, h2, сертификат верный"\n else\n',
|
||||
False,
|
||||
' ok "найден: ${B}$SNI${N} · та же подсеть · TLS 1.3, h2, сертификат верный"\n elif [[ -z $SNI ]]; then\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' if [[ $yes == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
|
||||
False,
|
||||
' if [[ -n ${KL_PICK_SNI:-} && ${KL_PICK_SNI} == 1 && -z $SNI ]]; then\n : # сайт уже выбран в нашем меню — вопрос автора не нужен\n elif [[ $yes == no && -t 1 ]] && { : </dev/tty; } 2>/dev/null; then\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' if [[ $TRUSTED == yes && $multi == no && ( -z $SNI2 || -z $SNI3 ) ]]; then\n',
|
||||
False,
|
||||
' # Наш шов: если XHTTP и MTProto не выбраны, имена для них сейчас не нужны — не сканируем соседей\n # по подсети впустую (около 250 обращений к чужим 443). Имена SNI2/SNI3 возьмутся из общего списка ниже.\n if [[ $TRUSTED == yes && $multi == no && ( -z $SNI2 || -z $SNI3 ) ]] \\\n && [[ " ${PROTOS[*]} " == *" xhttp "* || " ${PROTOS[*]} " == *" mtproto "* ]]; then\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' issue_domain_cert || die "Без сертификата для $DOMAIN продолжать нельзя. Исправьте причину и запустите скрипт снова."\n',
|
||||
False,
|
||||
' # Наш шов: провал сертификата не обрывает установку. Человек может повторить попытку (DNS доехал,\n # лимит Let\'s Encrypt кончился) или ввести другое имя — установка продолжится с него же.\n while ! issue_domain_cert; do\n echo\n echo " ${B}Что делать дальше?${N}"\n echo " ${B}1${N} повторить попытку если ждали обновления A-записи или лимита Let\'s Encrypt"\n echo " ${B}2${N} ввести другой домен проверим, что он указывает на этот сервер, и повторим"\n echo " ${B}3${N} остановиться поставить без своего домена: перезапустите и выберите соседа по подсети"\n ask_tty " Выбор [1]: " || die "Сертификат для $DOMAIN не выпущен. Подробности: /var/log/kit-domain-cert.log"\n case ${REPLY//[[:space:]]/} in\n 2) ask_tty " адрес сайта (например vpn.example.com): " || continue\n local d=${REPLY//[[:space:]]/}\n [[ -n $d ]] || continue\n if domain_points_here "$d"; then DOMAIN=$d; SNI=$DOMAIN\n else warn "оставляю прежний домен $DOMAIN — новый не указывает на этот сервер"; fi ;;\n 3) # Наш шов: продолжаем без своего домена прямо здесь — маскировка соседом по подсети\n # (или известным сайтом), панель и подписка по IP. Перезапускать установку не нужно.\n local nb2\n nb2=$(nearby_sites 2>/dev/null | shuf | head -1)\n [[ -n $nb2 ]] || nb2=dl.google.com\n DOMAIN=""; PANEL_ON=ip; LINK_HOST=$HOST\n SNI=$nb2; SNI2=${SNI2:-$nb2}\n ok "продолжаю без своего домена: маскировка ${B}$SNI${N}, панель и подписка — по IP"\n break ;;\n *) sleep 5 ;;\n esac\n done\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' ok "подключения работают: REALITY, XHTTP и Hysteria2 проверены клиентом с самого сервера"\n',
|
||||
False,
|
||||
' ok "подключения работают (проверено клиентом с самого сервера): ${CREATED[*]:-нет}"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' local extra\n extra=$(sub_links "$SUBID-awg" 1; sub_links "$SUBID-tg" 1)\n',
|
||||
False,
|
||||
' local extra="" # пустая строка, иначе set -u валит скрипт, когда AWG/MTProto не выбраны\n # Наш шов: подписки «-awg»/«-tg» есть только у установок с AmneziaWG и MTProto — не тянем их,\n # если эти протоколы не выбраны (никаких лишних обращений к панели).\n if [[ " ${PROTOS[*]} " == *" awg "* || " ${PROTOS[*]} " == *" awg3 "* || " ${PROTOS[*]} " == *" mtproto "* ]]; then\n extra=$(sub_links "$SUBID-awg" 1; sub_links "$SUBID-tg" 1)\n fi\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' echo "3X-UI KIT (3X-UI $XUI_VERSION) – данные для входа (файл виден только root)"\n',
|
||||
False,
|
||||
' echo "3X-UI_autoVPS (3X-UI $XUI_VERSION) – данные для входа (файл виден только root)"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' } >"$RESULT"\n\n echo\n echo "${G}${B}Готово! Сервер работает.${N}"\n [[ -n $DOMAIN ]] && echo "Маскировка: свой домен ${B}$DOMAIN${N}, сертификат Let\'s Encrypt продлевается сам."\n [[ $SELF_IP_CERT == yes ]] && later "Сертификат на IP самоподписанный: пользуйтесь ссылками на отдельные подключения, подписка в приложениях может не открыться."\n',
|
||||
False,
|
||||
' } >"$RESULT"\n\n echo\n',
|
||||
'авто-шов: delete, строк 6',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' echo "${B}ПАНЕЛЬ${N}"\n if [[ $TRUSTED == yes ]]; then\n echo " $panel_url"\n else\n echo " Только через SSH-туннель. На вашем компьютере:"\n echo " ssh -L $XUI_PANEL_PORT:127.0.0.1:$XUI_PANEL_PORT root@$HOST"\n echo " Затем в браузере: http://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH"\n fi\n echo " Логин: ${B}$XUI_USERNAME${N} Пароль: ${B}$XUI_PASSWORD${N}"\n echo " ${D}(сохранено в $RESULT, файл виден только root)${N}"\n echo\n if [[ $TRUSTED == yes ]]; then\n echo "${B}ПОДПИСКА${N} – одна ссылка для всех протоколов"\n echo " ${B}$SUB_URL${N}"\n echo " Вставьте в Happ, Hiddify, Karing, v2rayN, Clash Verge или FlClash."\n qrencode -t ANSIUTF8 -m 1 "$SUB_URL" || true\n else\n /usr/local/bin/kit __links "$NAME" "$SUBID" || true\n fi\n local -a tp=(\n "Управление сервером: введите команду ${B}kit${N} – откроется меню. Запоминать команды не нужно."\n "Ссылки на отдельные протоколы (REALITY, Hysteria2, Telegram и другие): kit → Пользователи → Показать ссылки."\n "Добавить пользователя: kit → Пользователи → Добавить."\n "Резервная копия: kit → Резервная копия. Храните её как пароль.")\n tips "Что дальше" "${tp[@]}"\n',
|
||||
False,
|
||||
' echo "${G}${B}Установка завершена.${N} Доступы к панели, подписка и QR-код — в самом конце вывода."\n',
|
||||
'авто-шов: replace, строк 25',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
'KIT_CLI_URL="$KIT_RAW/scripts/kit.sh"\n',
|
||||
False,
|
||||
'KIT_CLI_URL="$KIT_RAW/scripts/kit.sh"\nKIT_LOCAL_URL="$KIT_RAW/scripts/kit-local.sh"\n',
|
||||
'авто-шов: insert, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' else curl -fsSL --retry 3 -o /usr/local/bin/kit "$KIT_CLI_URL" && chmod 755 /usr/local/bin/kit; fi\n',
|
||||
False,
|
||||
' else curl -fsSL --retry 3 -o /usr/local/bin/kit "$KIT_CLI_URL" && chmod 755 /usr/local/bin/kit; fi\n # Форк: ставим наши локальные дополнения (см. scripts/kit-local.sh).\n install -d -m 755 /usr/local/lib/kit\n if [[ -n $src && -f $d/kit-local.sh ]]; then\n install -m 644 "$d/kit-local.sh" /usr/local/lib/kit/kit-local.sh\n else\n curl -fsSL --retry 3 -o /usr/local/lib/kit/kit-local.sh "$KIT_LOCAL_URL" \\\n || warn "Локальные дополнения kit не скачались: kit local не будет работать"\n fi\n',
|
||||
'авто-шов: insert, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
'KIT_INSTALL_CMD="bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh)"\n',
|
||||
False,
|
||||
'KIT_INSTALL_CMD="bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui.sh)"\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' echo "# Сгенерировано 3x-ui.sh (3X-UI KIT) – перезаписывается при повторном запуске."\n',
|
||||
False,
|
||||
' echo "# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
'# Сгенерировано 3x-ui.sh (3X-UI KIT) – перезаписывается при повторном запуске.\n',
|
||||
False,
|
||||
'# Сгенерировано 3x-ui.sh (3X-UI_autoVPS) – перезаписывается при повторном запуске.\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' upd=$(jq -c --arg path "$SUB_PATH" --argjson ip "$SUB_INTERNAL" --arg title "3X-UI KIT" --arg uri "$uri" \'\n',
|
||||
False,
|
||||
' upd=$(jq -c --arg path "$SUB_PATH" --argjson ip "$SUB_INTERNAL" --arg title "3X-UI_autoVPS" --arg uri "$uri" \'\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' upd=$(jq -c --arg path "$SUB_PATH" --arg title "3X-UI KIT" \'\n',
|
||||
False,
|
||||
' upd=$(jq -c --arg path "$SUB_PATH" --arg title "3X-UI_autoVPS" \'\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
'Description=kit-sub: подписка с учётом приложения (3X-UI KIT)\n',
|
||||
False,
|
||||
'Description=kit-sub: подписка с учётом приложения (3X-UI_autoVPS)\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' [[ $(LC_ALL=C tr -d \'\\n -~\' <"$f" | wc -c) -eq 0 ]] \\\n || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
|
||||
False,
|
||||
' [[ $(LC_ALL=C tr -d \'\\n -~\' <"$f" | wc -c) -eq 0 ]] \\\n || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' [[ $rc -eq 1 ]] \\\n || die "Это не резервная копия 3X-UI KIT или она повреждена (файл ${f##*/}). Ничего не менял."\n',
|
||||
False,
|
||||
' [[ $rc -eq 1 ]] \\\n || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (файл ${f##*/}). Ничего не менял."\n',
|
||||
'авто-шов: replace, строк 2',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' [[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI KIT или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял."\n',
|
||||
False,
|
||||
' [[ " $* " == *" $n "* ]] || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (лишнее имя $n в ${f##*/}). Ничего не менял."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' *) die "Это не резервная копия 3X-UI KIT или она повреждена (имя $n в ${f##*/}). Ничего не менял." ;;\n',
|
||||
False,
|
||||
' *) die "Это не резервная копия 3X-UI_autoVPS или она повреждена (имя $n в ${f##*/}). Ничего не менял." ;;\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' || die "Это не резервная копия 3X-UI KIT или она повреждена (значение $n в ${f##*/}). Ничего не менял."\n',
|
||||
False,
|
||||
' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (значение $n в ${f##*/}). Ничего не менял."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' python3 - "$file" "$tmp" <<\'PY\' || die "Это не резервная копия 3X-UI KIT или она повреждена. Ничего не менял."\n',
|
||||
False,
|
||||
' python3 - "$file" "$tmp" <<\'PY\' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена. Ничего не менял."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' || die "Это не резервная копия 3X-UI KIT или она повреждена (порт панели). Ничего не менял."\n',
|
||||
False,
|
||||
' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (порт панели). Ничего не менял."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' [[ $BACKUP_HOST =~ ^[A-Za-z0-9.:-]+$ ]] || die "Это не резервная копия 3X-UI KIT или она повреждена (адрес сервера). Ничего не менял."\n',
|
||||
False,
|
||||
' [[ $BACKUP_HOST =~ ^[A-Za-z0-9.:-]+$ ]] || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (адрес сервера). Ничего не менял."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' || die "Это не резервная копия 3X-UI KIT или она повреждена (нет своего сертификата). Ничего не менял."\n',
|
||||
False,
|
||||
' || die "Это не резервная копия 3X-UI_autoVPS или она повреждена (нет своего сертификата). Ничего не менял."\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
' [[ -n $DOMAIN ]] && { issue_domain_cert || die "Не удалось получить сертификат для $DOMAIN: порт 80 должен быть свободен, а A-запись домена указывать на этот сервер. Лог: /var/log/kit-domain-cert.log"; }\n',
|
||||
False,
|
||||
' # Наш шов: открываем 80 в ufw ПРЯМО ПЕРЕД выпуском сертификата. Установщик открывает список портов\n # позже (после сборки nginx), поэтому на свежем сервере проверка Let\'s Encrypt доходила до сервера и\n # «проглатывалась» — в логе acme это «Timeout during connect (likely firewall problem)».\n command -v ufw >/dev/null 2>&1 && ufw allow 80/tcp >/dev/null 2>&1 || true\n [[ -n $DOMAIN ]] && { issue_domain_cert || die "Без сертификата для $DOMAIN продолжать нельзя — причина выше, подробности в /var/log/kit-domain-cert.log"; }\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/hysteria2.sh',
|
||||
'# установку 3X-UI KIT (scripts/3x-ui.sh). Файл оставлен, чтобы старая команда `hy2 update`\n',
|
||||
False,
|
||||
'# установку 3X-UI_autoVPS (scripts/3x-ui.sh). Файл оставлен, чтобы старая команда `hy2 update`\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/hysteria2.sh',
|
||||
'Hysteria2 входит в основную установку 3X-UI KIT (один из 11 протоколов).\n',
|
||||
False,
|
||||
'Hysteria2 входит в основную установку 3X-UI_autoVPS (один из 11 протоколов).\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit-sub.py',
|
||||
' data = {"Name": "3X-UI KIT", "GlobalProxy": "false", "DomainStrategy": "IPIfNonMatch", "ProxySites": sites,\n',
|
||||
False,
|
||||
' data = {"Name": "3X-UI_autoVPS", "GlobalProxy": "false", "DomainStrategy": "IPIfNonMatch", "ProxySites": sites,\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit-sub.py',
|
||||
' data = {"Name": "3X-UI KIT", "DomainStrategy": "IPIfNonMatch", "RouteOrder": "block-proxy-direct",\n',
|
||||
False,
|
||||
' data = {"Name": "3X-UI_autoVPS", "DomainStrategy": "IPIfNonMatch", "RouteOrder": "block-proxy-direct",\n',
|
||||
'авто-шов: replace, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit-sub.py',
|
||||
' main()\n',
|
||||
False,
|
||||
' main()\n\n',
|
||||
'авто-шов: insert, строк 1',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
version_re,
|
||||
True,
|
||||
f'KIT_VERSION="{version}"',
|
||||
'версия сборки форка',
|
||||
),
|
||||
(
|
||||
'scripts/3x-ui.sh',
|
||||
version_re,
|
||||
True,
|
||||
f'KIT_VERSION="{version}"',
|
||||
'версия сборки форка',
|
||||
),
|
||||
(
|
||||
'scripts/hysteria2.sh',
|
||||
version_re,
|
||||
True,
|
||||
f'KIT_VERSION="{version}"',
|
||||
'версия сборки форка',
|
||||
),
|
||||
(
|
||||
'scripts/kit.sh',
|
||||
'KIT_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main"',
|
||||
False,
|
||||
'KIT_RAW="https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main"',
|
||||
'канал обновлений: наш Gitea вместо GitHub',
|
||||
),
|
||||
]
|
||||
|
||||
bad = 0
|
||||
for path, anchor, is_re, new, why in edits:
|
||||
p = pathlib.Path(path)
|
||||
if not p.exists():
|
||||
print("✗ %s: файла нет" % path)
|
||||
bad += 1
|
||||
continue
|
||||
text = p.read_text(encoding="utf-8", newline="")
|
||||
if check:
|
||||
# --check смотрит на готовое дерево dev: наш шов обязан быть на месте и ровно один раз
|
||||
n = text.count(new)
|
||||
what, shown = "наш шов", new
|
||||
else:
|
||||
# применение — только на чистое дерево автора: якорь обязан найтись ровно один раз
|
||||
n = count(text, anchor, is_re)
|
||||
what, shown = "якорь автора", anchor
|
||||
short = shown if len(shown) <= 54 else shown[:51] + "…"
|
||||
short = short.replace("\n", "⏎")
|
||||
if n != 1:
|
||||
print("✗ %-20s %-56s %s найден %d раз (%s)" % (path, short, what, n, why))
|
||||
bad += 1
|
||||
continue
|
||||
if check:
|
||||
print("✓ %-20s %-56s (%s)" % (path, short, why))
|
||||
continue
|
||||
if is_re:
|
||||
text = re.sub(anchor, new, text, count=1, flags=re.MULTILINE)
|
||||
else:
|
||||
text = text.replace(anchor, new, 1)
|
||||
p.write_text(text, encoding="utf-8", newline="")
|
||||
print("✓ %-20s %-56s (%s)" % (path, short, why))
|
||||
|
||||
|
||||
# Массовая замена бренда в файлах автора: строки, которые видит человек (справка, версия,
|
||||
# описания служб, баннер). Строку проверки шапки SHA256SUMS не трогаем — её правит отдельный шов,
|
||||
# а сама шапка ещё один релиз пишется в старом виде (иначе уже установленные серверы откажутся
|
||||
# принимать обновление: они сверяют точный вид строки).
|
||||
globals_ = [
|
||||
("scripts/kit.sh", "3X-UI KIT", "3X-UI_autoVPS"),
|
||||
("scripts/3x-ui.sh", "3X-UI KIT", "3X-UI_autoVPS"),
|
||||
("scripts/hysteria2.sh", "3X-UI KIT", "3X-UI_autoVPS"),
|
||||
("scripts/kit-sub.py", "3X-UI KIT", "3X-UI_autoVPS"),
|
||||
]
|
||||
for path, old, new in globals_:
|
||||
p = pathlib.Path(path)
|
||||
if not p.exists():
|
||||
print("✗ %s: файла нет" % path)
|
||||
bad += 1
|
||||
continue
|
||||
text = p.read_text(encoding="utf-8", newline="")
|
||||
# строки, где старое имя — часть служебной сверки, не переименовываем
|
||||
keep = lambda line: "grep -qx" in line
|
||||
if check:
|
||||
if new not in text:
|
||||
print("✗ %-20s нет нашего имени «%s»" % (path, new))
|
||||
bad += 1
|
||||
continue
|
||||
left = [l for l in text.splitlines() if old in l and not keep(l)]
|
||||
if left:
|
||||
print("✗ %-20s осталось старое имя в %d строк(ах)" % (path, len(left)))
|
||||
bad += 1
|
||||
continue
|
||||
print("✓ %-20s бренд: %s" % (path, new))
|
||||
continue
|
||||
out = []
|
||||
changed = 0
|
||||
for line in text.splitlines(keepends=True):
|
||||
if old in line and not keep(line):
|
||||
out.append(line.replace(old, new))
|
||||
changed += line.count(old)
|
||||
else:
|
||||
out.append(line)
|
||||
if not changed:
|
||||
if new in text:
|
||||
print("– %-20s уже переименовано" % path)
|
||||
continue
|
||||
print("✗ %-20s не нашёл «%s»" % (path, old))
|
||||
bad += 1
|
||||
continue
|
||||
p.write_text("".join(out), encoding="utf-8", newline="")
|
||||
print("✓ %-20s переименовано вхождений: %d" % (path, changed))
|
||||
|
||||
if bad:
|
||||
if check:
|
||||
die("якорей не найдено: %d — автор изменил код, пересмотри правки в tools/fork_apply.py" % bad)
|
||||
die("якорей не найдено: %d — правки не применены" % bad)
|
||||
if check:
|
||||
ok("все якоря на месте (версия сборки %s)" % version)
|
||||
else:
|
||||
ok("швы накатаны, версия сборки %s" % version)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+73
-13
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Подпись релиза 3X-UI KIT. Запускает только автор, на своём компьютере, перед тегом:
|
||||
# Подпись релиза 3X-UI_autoVPS. Запускает только автор, на своём компьютере, перед тегом:
|
||||
#
|
||||
# tools/release.sh [~/.ssh/3xui_kit_release]
|
||||
#
|
||||
# Проверяет, что версии сходятся и установщик 3X-UI не подменили, пишет SHA256SUMS,
|
||||
# подписывает его закрытым ключом (ssh-keygen спросит пароль ключа) и сразу проверяет
|
||||
# подпись открытым ключом, записанным в kit. Сервер 3X-UI KIT ставит обновление, только
|
||||
# подпись открытым ключом, записанным в kit. Сервер 3X-UI_autoVPS ставит обновление, только
|
||||
# если эта проверка у него тоже сошлась. Закрытый ключ никуда не отправляется.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -13,28 +13,67 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
die() { printf '✗ %s\n' "$*" >&2; exit 1; }
|
||||
ok() { printf '✓ %s\n' "$*"; }
|
||||
warn() { printf '! %s\n' "$*" >&2; } # сеть до GitHub отвалилась: проверку пропускаем, релиз не блокируем
|
||||
sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; }
|
||||
|
||||
key=${1:-$HOME/.ssh/3xui_kit_release}
|
||||
[[ -f $key ]] || die "Нет закрытого ключа $key. Создать: ssh-keygen -t ed25519 -C \"3X-UI KIT releases\" -f $key"
|
||||
[[ -f $key ]] || die "Нет закрытого ключа $key. Создать: ssh-keygen -t ed25519 -C \"3X-UI_autoVPS releases\" -f $key"
|
||||
NS="3x-ui-kit-release"
|
||||
ID="releases@3x-ui-kit"
|
||||
FILES=(scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh scripts/kit-sub.py VERSION)
|
||||
FILES=(scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh scripts/kit-sub.py scripts/kit-local.sh scripts/3x-ui-local.sh VERSION)
|
||||
|
||||
v=$(tr -d '[:space:]' <VERSION)
|
||||
[[ $v =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "VERSION: $v"
|
||||
for f in scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh; do
|
||||
grep -qx "KIT_VERSION=\"$v\"" "$f" || die "$f: KIT_VERSION не $v"
|
||||
done
|
||||
grep -q "^## v$v" CHANGELOG.md || die "В CHANGELOG.md нет раздела ## v$v"
|
||||
(grep -q "^## v$v" CHANGELOG.md || grep -q "^## v$v" FORK-CHANGELOG.md) || die "Нет раздела ## v$v ни в CHANGELOG.md, ни в FORK-CHANGELOG.md (форк ведёт свои записи в FORK-CHANGELOG.md)"
|
||||
ok "Версия $v во всех скриптах и в CHANGELOG"
|
||||
|
||||
[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
|
||||
# Переводы строк. Сервер получает файл из git (там LF), а суммы считаются по рабочей копии:
|
||||
# если в рабочей копии окажутся CR, суммы не сойдутся и kit update откажется ставить релиз.
|
||||
for f in "${FILES[@]}"; do
|
||||
[[ -z $(tr -cd "\r" <"$f") ]] || die "$f: переводы строк Windows (CR). Уберите: tr -d '\r' < \"$f\" > \"$f.lf\" && mv \"$f.lf\" \"$f\""
|
||||
done
|
||||
ok "Переводы строк во всех файлах релиза — LF"
|
||||
|
||||
export PY
|
||||
bash tools/check-help.sh || die "справка kit help отстала от набора команд"
|
||||
|
||||
# «Строки-сироты»: строка с флагом без команды (например потерянный "#" в комментарии) не ловится
|
||||
# ни bash -n, ни справкой, но при каждом запуске печатает «command not found». Проверяем холостым прогоном.
|
||||
err=$(bash scripts/3x-ui-local.sh --dry-run 2>&1 >/dev/null || true)
|
||||
if grep -qE "command not found|syntax error|bad substitution" <<<"$err"; then
|
||||
printf '%s\n' "$err" | grep -E "command not found|syntax error|bad substitution" | head -3
|
||||
die "установщик ругается на строку без команды — поправьте файл (см. выше)"
|
||||
fi
|
||||
ok "Установщик не ругается на пустые строки и опечатки"
|
||||
|
||||
# Наши функции в установщике обязаны быть определены РАНЬШЕ первого вызова: `bash -n` и холостой
|
||||
# прогон этого не видят, а человек видит «command not found» посреди установки (так и было 13.10).
|
||||
for fn in ask_yes ask_ssh_port ssh_ports_now ask_protocols proto_parse proto_menu interactive clean_previous installer_failed; do
|
||||
# || true обязателен: без него grep, не найдя совпадений, возвращает 1 и set -e молча обрывает релиз.
|
||||
d=$(grep -n "^$fn()" scripts/3x-ui-local.sh | head -1 | cut -d: -f1 || true)
|
||||
c=$(grep -n "^[^#]*[^a-z_]$fn[ ;\"]" scripts/3x-ui-local.sh | grep -v "^${d:-0}:" | head -1 | cut -d: -f1 || true)
|
||||
[[ -n $d ]] || die "scripts/3x-ui-local.sh: функция $fn не определена, а вызывается"
|
||||
[[ -z $c || $c -gt $d ]] || die "scripts/3x-ui-local.sh: $fn вызывается (строка $c) раньше, чем определена ($d)"
|
||||
done
|
||||
ok "Все наши функции определены раньше своих вызовов"
|
||||
|
||||
[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md FORK-CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
|
||||
|
||||
# Часть проверок сверяется с GitHub, а он из домашней сети отвечает через раз. Сначала короткая проба:
|
||||
# нет связи — сразу предупреждаем и пропускаем внешние проверки (подписи это не мешает), а не висим минутами.
|
||||
gh_ok=yes
|
||||
curl -fsI --connect-timeout 8 --max-time 12 https://github.com >/dev/null 2>&1 || gh_ok=no
|
||||
[[ $gh_ok == yes ]] || warn "GitHub недоступен из вашей сети — установщик, суммы ядра и панели не сверяю. Подписи это не мешает."
|
||||
gh_curl() { [[ ${gh_ok:-no} == yes ]] || return 1; curl -fsSL --retry 2 --retry-delay 2 --connect-timeout 15 --max-time 90 "$@"; }
|
||||
|
||||
# Установщик 3X-UI: хеш в скрипте должен совпадать с тем, что сейчас лежит на GitHub.
|
||||
xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh)
|
||||
xs=$(sed -n 's/^XUI_INSTALL_SHA256="\(.*\)"/\1/p' scripts/3x-ui.sh)
|
||||
got=$(curl -fsSL "https://raw.githubusercontent.com/MHSanaei/3x-ui/$xv/install.sh" | sha | awk '{print $1}')
|
||||
got=$(gh_curl "https://raw.githubusercontent.com/MHSanaei/3x-ui/$xv/install.sh" | sha | awk '{print $1}') \
|
||||
|| { warn "GitHub недоступен — проверку установщика 3X-UI пропускаю (релиз не блокирую)"; got=$xs; }
|
||||
[[ $got == "$xs" ]] || die "Установщик 3X-UI $xv изменился: $got вместо $xs. Проверьте, что поменялось, прежде чем выпускать."
|
||||
ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256"
|
||||
|
||||
@@ -42,22 +81,43 @@ ok "Установщик 3X-UI $xv совпадает с закреплённы
|
||||
xc=$(sed -n 's/^XRAY_CORE="\(.*\)"/\1/p' scripts/3x-ui.sh)
|
||||
for a in 64 arm64-v8a; do
|
||||
pin=$(sed -n "s/^ \[$a\]=\([0-9a-f]\{64\}\)\$/\1/p" scripts/3x-ui.sh)
|
||||
off=$(curl -fsSL "https://github.com/XTLS/Xray-core/releases/download/$xc/Xray-linux-$a.zip.dgst" | awk '/^SHA2-256=/ {print $2}')
|
||||
off=$(gh_curl "https://github.com/XTLS/Xray-core/releases/download/$xc/Xray-linux-$a.zip.dgst" | awk '/^SHA2-256=/ {print $2}' || true)
|
||||
[[ -n $off ]] || { warn "GitHub недоступен — суммы Xray для $a пропускаю"; continue; }
|
||||
[[ -n $pin && $pin == "$off" ]] || die "Сумма ядра Xray $xc ($a) в 3x-ui.sh не совпадает с официальной: «$pin» вместо «$off»."
|
||||
done
|
||||
ok "Суммы архивов ядра Xray $xc совпадают с официальными"
|
||||
|
||||
# Открытые ключи из kit и hy2 – одинаковые, и среди них есть ключ, которым подписываем.
|
||||
# Проверенная панель и ядро в kit.sh (kit panel update, kit check): те же версии, что в установщике,
|
||||
# а суммы архива панели и скрипта меню x-ui – официальные.
|
||||
kp=$(sed -n 's/^XUI_PIN="\(.*\)"/\1/p' scripts/kit.sh)
|
||||
[[ $kp == "$xv" ]] || die "XUI_PIN в kit.sh ($kp) не совпадает с XUI_VERSION в 3x-ui.sh ($xv)."
|
||||
kx=$(sed -n 's/^XRAY_PIN="\(.*\)"/\1/p' scripts/kit.sh)
|
||||
[[ $kx == "$xc" ]] || die "XRAY_PIN в kit.sh ($kx) не совпадает с XRAY_CORE в 3x-ui.sh ($xc)."
|
||||
for a in amd64 arm64; do
|
||||
pin=$(sed -n "s/^ \[$a\]=\([0-9a-f]\{64\}\)\$/\1/p" scripts/kit.sh)
|
||||
off=$(gh_curl "https://github.com/MHSanaei/3x-ui/releases/download/$kp/x-ui-linux-$a.tar.gz.sha256" | awk 'NR == 1 {print $1}' || true)
|
||||
[[ -n $off ]] || { warn "GitHub недоступен — сумму панели для $a пропускаю"; continue; }
|
||||
[[ -n $pin && $pin == "$off" ]] || die "Сумма архива панели $kp ($a) в kit.sh не совпадает с официальной: «$pin» вместо «$off»."
|
||||
done
|
||||
shs=$(sed -n 's/^XUI_SH_SHA256=\(.*\)/\1/p' scripts/kit.sh)
|
||||
got=$(gh_curl "https://raw.githubusercontent.com/MHSanaei/3x-ui/$kp/x-ui.sh" | sha | awk '{print $1}' || true)
|
||||
[[ -n $got ]] || { warn "GitHub недоступен — проверку x-ui.sh $kp пропускаю"; got=$shs; }
|
||||
[[ $shs == "$got" ]] || die "Сумма x-ui.sh $kp в kit.sh не совпадает с официальной: «$shs» вместо «$got»."
|
||||
ok "Панель $kp и ядро $kx в kit.sh совпадают с установщиком и официальными суммами"
|
||||
|
||||
# В kit записан открытый ключ, которым подписываем.
|
||||
signers() { awk '/^KIT_SIGNERS=\(/ {on = 1; next} on && /^\)/ {exit} on && /^ *"/ {gsub(/^ *"|"$/, ""); print}' "$1"; }
|
||||
[[ -n $(signers scripts/kit.sh) ]] || die "В scripts/kit.sh пустой KIT_SIGNERS: впишите туда открытую часть ключа ($key.pub)."
|
||||
[[ $(signers scripts/kit.sh) == "$(signers scripts/hysteria2.sh)" ]] || die "KIT_SIGNERS в kit.sh и hysteria2.sh различаются."
|
||||
pub=$(ssh-keygen -y -f "$key" | awk '{print $1, $2}')
|
||||
signers scripts/kit.sh | awk '{print $1, $2}' | grep -qxF "$pub" || die "Ключа $key нет в KIT_SIGNERS – серверы не примут такую подпись."
|
||||
ok "Ключ подписи записан в kit и hy2"
|
||||
ok "Ключ подписи записан в kit"
|
||||
|
||||
{ echo "# 3X-UI KIT $v"; sha "${FILES[@]}"; } >SHA256SUMS
|
||||
{ echo "# 3X-UI KIT $v"; echo "# 3X-UI_autoVPS $v"; sha "${FILES[@]}" | awk '{ sub(/^\*/, "", $2); print $1" "$2 }'; } >SHA256SUMS
|
||||
rm -f SHA256SUMS.sig
|
||||
ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null
|
||||
# Ключ защищён парольной фразой: её вводит человек. Понятная подсказка на случай опечатки.
|
||||
echo "Подписываю ключом $key — если он защищён парольной фразой, введите её сейчас."
|
||||
ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null \
|
||||
|| die "Ключ не подписал. Чаще всего это неверная парольная фраза («incorrect passphrase») — запустите команду ещё раз и введите её внимательно. Файл SHA256SUMS при этом не портится."
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
signers scripts/kit.sh | while read -r k; do printf '%s namespaces="%s" %s\n' "$ID" "$NS" "$k"; done >"$tmp"
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Проверки fix_vless_encryption в kit-sub: python3 tools/test/jsonfix_test.py"""
|
||||
import json, os, threading, urllib.request, tempfile, unittest, importlib.util
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
tmp = tempfile.mkdtemp()
|
||||
cfgp = os.path.join(tmp, "config.json")
|
||||
json.dump({"listen": "127.0.0.1", "port": 1, "path": "/sub/", "upstream": "http://127.0.0.1:1/"}, open(cfgp, "w"))
|
||||
os.environ["KIT_SUB_CONFIG"] = cfgp
|
||||
os.environ["KIT_SUB_RULES"] = os.path.join(tmp, "rules.yaml")
|
||||
spec = importlib.util.spec_from_file_location("kit_sub", os.path.join(root, "scripts", "kit-sub.py"))
|
||||
ks = importlib.util.module_from_spec(spec); spec.loader.exec_module(ks)
|
||||
|
||||
|
||||
def vnext(**u):
|
||||
return {"protocol": "vless", "settings": {"vnext": [{"address": "1.2.3.4", "port": 443, "users": [dict(id="x", **u)]}]}}
|
||||
|
||||
|
||||
def flat(**u):
|
||||
return {"protocol": "vless", "settings": dict(address="1.2.3.4", port=443, id="x", **u)}
|
||||
|
||||
|
||||
def run(obj):
|
||||
return json.loads(ks.fix_vless_encryption(json.dumps(obj).encode()))
|
||||
|
||||
|
||||
def enc_vnext(o):
|
||||
return o["outbounds"][0]["settings"]["vnext"][0]["users"][0].get("encryption")
|
||||
|
||||
|
||||
class Fix(unittest.TestCase):
|
||||
def test_vnext(self):
|
||||
self.assertEqual(enc_vnext(run({"outbounds": [vnext()]})), "none")
|
||||
self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="")]})), "none")
|
||||
self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="none")]})), "none")
|
||||
self.assertEqual(enc_vnext(run({"outbounds": [vnext(encryption="mlkem768x25519plus.native.0rtt.abc")]})),
|
||||
"mlkem768x25519plus.native.0rtt.abc")
|
||||
|
||||
def test_flat(self):
|
||||
for u, want in (({}, "none"), ({"encryption": ""}, "none"), ({"encryption": "none"}, "none"), ({"encryption": "mlkem.x"}, "mlkem.x")):
|
||||
self.assertEqual(run({"outbounds": [flat(**u)]})["outbounds"][0]["settings"]["encryption"], want)
|
||||
|
||||
def test_array_and_many(self):
|
||||
r = run([{"outbounds": [vnext(), flat(), vnext(encryption="mlkem.x")]}, {"outbounds": [vnext()]}])
|
||||
self.assertEqual(enc_vnext(r[0]), "none")
|
||||
self.assertEqual(r[0]["outbounds"][1]["settings"]["encryption"], "none")
|
||||
self.assertEqual(r[0]["outbounds"][2]["settings"]["vnext"][0]["users"][0]["encryption"], "mlkem.x")
|
||||
self.assertEqual(enc_vnext(r[1]), "none")
|
||||
|
||||
def test_not_vless_untouched(self):
|
||||
src = {"outbounds": [{"protocol": "trojan", "settings": {"servers": [{"address": "a", "password": "p"}]}},
|
||||
{"protocol": "freedom", "settings": {}}, {"protocol": "blackhole"},
|
||||
{"protocol": "shadowsocks", "settings": {"id": "x"}}]}
|
||||
raw = json.dumps(src).encode()
|
||||
self.assertEqual(ks.fix_vless_encryption(raw), raw)
|
||||
|
||||
def test_bytes_as_is(self):
|
||||
for raw in (b"", b"not json", b"{broken", b"\xff\xfe\x00", b"123", b"null", b'"s"', b"[1,2]", b'{"outbounds": 5}',
|
||||
b'{"outbounds": [null, 1, {"protocol": "vless", "settings": 3}]}'):
|
||||
self.assertEqual(ks.fix_vless_encryption(raw), raw)
|
||||
|
||||
def test_big(self):
|
||||
raw = json.dumps({"outbounds": [vnext()], "pad": "a" * (ks.JSON_FIX_MAX + 1)}).encode()
|
||||
self.assertEqual(ks.fix_vless_encryption(raw), raw)
|
||||
|
||||
def test_unchanged_is_identical_and_idempotent(self):
|
||||
raw = json.dumps({"outbounds": [vnext(encryption="none")]}, indent=2).encode()
|
||||
self.assertEqual(ks.fix_vless_encryption(raw), raw)
|
||||
once = ks.fix_vless_encryption(json.dumps({"outbounds": [vnext()]}).encode())
|
||||
self.assertEqual(ks.fix_vless_encryption(once), once)
|
||||
|
||||
def test_utf8(self):
|
||||
out = ks.fix_vless_encryption(json.dumps({"remarks": "Привет", "outbounds": [vnext()]}, ensure_ascii=False).encode())
|
||||
self.assertIn("Привет".encode(), out)
|
||||
|
||||
def test_deep_nesting_as_is(self):
|
||||
raw = b"[" * 100000 + b"]" * 100000
|
||||
self.assertEqual(ks.fix_vless_encryption(raw), raw)
|
||||
|
||||
|
||||
class Http(unittest.TestCase):
|
||||
"""Через настоящий обработчик, upstream подменён заглушкой."""
|
||||
def get(self, ctype, body):
|
||||
orig = ks.upstream
|
||||
ks.upstream = lambda *a, **k: (200, {"content-type": ctype}, body)
|
||||
srv = ks.Server(("127.0.0.1", 0), ks.Handler)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
try:
|
||||
req = urllib.request.Request("http://127.0.0.1:%d%sabc123" % (srv.server_address[1], ks.PATH),
|
||||
headers={"User-Agent": "v2rayN/7"})
|
||||
return urllib.request.urlopen(req, timeout=10).read()
|
||||
finally:
|
||||
srv.shutdown(); srv.server_close(); ks.upstream = orig
|
||||
|
||||
def test_json_text_plain(self):
|
||||
raw = json.dumps([{"outbounds": [flat(encryption="")]}], indent=2).encode()
|
||||
for ct in ("text/plain; charset=utf-8", "application/json"):
|
||||
out = json.loads(self.get(ct, raw))
|
||||
self.assertEqual(out[0]["outbounds"][0]["settings"]["encryption"], "none")
|
||||
|
||||
def test_json_text_plain_keeps_lines(self):
|
||||
raw = b'{"a": "vpn://x", "outbounds": []}\n'
|
||||
self.assertEqual(self.get("text/plain", raw), raw)
|
||||
|
||||
def test_plain_text_still_stripped(self):
|
||||
out = self.get("text/plain", b"vless://a@b:1#x\nvpn://zzz\n")
|
||||
self.assertIn(b"vless://", out)
|
||||
self.assertNotIn(b"vpn://", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Проверки портов подключений в kit.sh (inbound_listens, inbounds_down, xray_heal): TCP и UDP отдельно,
|
||||
перезапуск ядра только если правка закрыла порт. python3 tools/test/kit_ports_test.py"""
|
||||
import json, os, re, subprocess, tempfile, unittest
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
src = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
|
||||
funcs = "".join(re.search(r"^%s\(\) \{.*?^\}\n" % n, src, re.S | re.M).group(0)
|
||||
for n in ("port_net", "inbound_listens", "inbounds_down", "xray_heal"))
|
||||
|
||||
INBOUNDS = [
|
||||
{"remark": "REALITY", "protocol": "vless", "port": 10443, "enable": True},
|
||||
{"remark": "Hysteria2", "protocol": "hysteria", "port": 443, "enable": True},
|
||||
{"remark": "Shadowsocks", "protocol": "shadowsocks", "port": 8388, "enable": True},
|
||||
{"remark": "VMess-WS", "protocol": "vmess", "port": 10452, "enable": False},
|
||||
]
|
||||
|
||||
# ss и api – заглушки: открытые порты лежат в файле «t:443», «u:443»; перезапуск ядра открывает порты из $AFTER.
|
||||
STUBS = r'''
|
||||
set -Eeuo pipefail
|
||||
say() { echo "SAY: $*"; }; warn() { echo "WARN: $*"; }; sleep() { :; }
|
||||
ss() { local p=${1:3:1} port=${2##*:}; grep -qx "$p:$port" "$LISTEN" && echo LISTEN || true; }
|
||||
api() { cat "$LIST"; }
|
||||
xray_restart_core() { echo RESTART; cp "$AFTER" "$LISTEN"; }
|
||||
'''
|
||||
|
||||
|
||||
def run(listen, body, after=None):
|
||||
d = tempfile.mkdtemp()
|
||||
paths = {k: os.path.join(d, k) for k in ("listen", "list", "after")}
|
||||
for k, text in (("listen", "\n".join(listen) + "\n"), ("after", "\n".join(after if after is not None else listen) + "\n"),
|
||||
("list", json.dumps(INBOUNDS))):
|
||||
with open(paths[k], "w") as f:
|
||||
f.write(text)
|
||||
env = dict(os.environ, LISTEN=paths["listen"], LIST=paths["list"], AFTER=paths["after"])
|
||||
r = subprocess.run(["bash", "-c", STUBS + funcs + body], capture_output=True, text=True, env=env)
|
||||
return r.returncode, r.stdout + r.stderr
|
||||
|
||||
|
||||
ALL = ["t:10443", "u:443", "t:8388", "u:8388"]
|
||||
|
||||
|
||||
class Ports(unittest.TestCase):
|
||||
def test_all_listen(self):
|
||||
code, out = run(ALL + ["t:443"], 'inbounds_down; echo END')
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertEqual(out.strip(), "END")
|
||||
|
||||
def test_nginx_tcp_is_not_hysteria_udp(self):
|
||||
code, out = run(["t:10443", "t:443", "t:8388", "u:8388"], 'inbounds_down')
|
||||
self.assertEqual(out.strip(), "Hysteria2\t443\tudp", out)
|
||||
|
||||
def test_shadowsocks_needs_both(self):
|
||||
code, out = run(["t:10443", "u:443", "t:8388"], 'inbounds_down')
|
||||
self.assertEqual(out.strip(), "Shadowsocks\t8388\ttcp+udp", out)
|
||||
|
||||
def test_disabled_ignored(self):
|
||||
code, out = run(ALL, 'inbounds_down | grep -c VMess || true')
|
||||
self.assertEqual(out.strip(), "0")
|
||||
|
||||
def test_bad_port_value(self):
|
||||
code, out = run(ALL, 'inbound_listens "443;id" udp && echo YES || echo NO')
|
||||
self.assertEqual(out.strip(), "NO")
|
||||
|
||||
def test_heal_restarts_when_patch_closed_port(self):
|
||||
code, out = run(["t:10443", "t:8388", "u:8388"], 'xray_heal ""', after=ALL)
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn("RESTART", out)
|
||||
self.assertIn("Hysteria2", out)
|
||||
self.assertNotIn("WARN", out)
|
||||
|
||||
def test_heal_quiet_when_nothing_closed(self):
|
||||
code, out = run(ALL, 'xray_heal ""')
|
||||
self.assertEqual(out.strip(), "")
|
||||
|
||||
def test_heal_ignores_port_down_before_patch(self):
|
||||
code, out = run(["t:10443", "t:8388", "u:8388"], 'xray_heal "$(printf "Hysteria2\\t443\\tudp")"')
|
||||
self.assertNotIn("RESTART", out)
|
||||
|
||||
def test_heal_warns_if_restart_did_not_help(self):
|
||||
code, out = run(["t:10443", "t:8388", "u:8388"], 'xray_heal ""')
|
||||
self.assertIn("RESTART", out)
|
||||
self.assertIn("WARN: После перезапуска ядра не слушают: Hysteria2:443", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Ответы kit-sub как у nginx (режим --multi-port): python3 tools/test/kitsub_http_test.py"""
|
||||
import json, os, shutil, socket, ssl, subprocess, sys, tempfile, threading, time, unittest, importlib.util
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
tmp = tempfile.mkdtemp()
|
||||
cfgp = os.path.join(tmp, "config.json")
|
||||
json.dump({"listen": "127.0.0.1", "port": 1, "path": "/sub/", "upstream": "http://127.0.0.1:1/"}, open(cfgp, "w"))
|
||||
os.environ["KIT_SUB_CONFIG"] = cfgp
|
||||
os.environ["KIT_SUB_RULES"] = os.path.join(tmp, "rules.yaml")
|
||||
spec = importlib.util.spec_from_file_location("kit_sub", os.path.join(root, "scripts", "kit-sub.py"))
|
||||
ks = importlib.util.module_from_spec(spec); spec.loader.exec_module(ks)
|
||||
srv = ks.Server(("127.0.0.1", 0), ks.Handler)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
|
||||
|
||||
def raw(data):
|
||||
s = socket.create_connection(srv.server_address[:2]); s.settimeout(3); s.sendall(data); out = b""
|
||||
try:
|
||||
while True:
|
||||
b = s.recv(65536)
|
||||
if not b:
|
||||
break
|
||||
out += b
|
||||
except socket.timeout:
|
||||
out += b"<open>"
|
||||
s.close()
|
||||
return out.decode(errors="replace")
|
||||
|
||||
|
||||
class Http(unittest.TestCase):
|
||||
def test_status_and_server(self):
|
||||
r = raw(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
|
||||
self.assertTrue(r.startswith("HTTP/1.1 404 Not Found\r\n"), r[:40])
|
||||
self.assertIn("\r\nServer: nginx\r\n", r)
|
||||
self.assertIn("\r\nContent-Length: ", r)
|
||||
|
||||
def test_keep_alive(self):
|
||||
r = raw(b"GET / HTTP/1.1\r\nHost: x\r\n\r\nGET /a HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
|
||||
self.assertEqual(r.count("HTTP/1.1 404 Not Found"), 2)
|
||||
self.assertNotIn("<open>", r)
|
||||
|
||||
def test_post_closes(self):
|
||||
r = raw(b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello")
|
||||
self.assertTrue(r.startswith("HTTP/1.1 404"))
|
||||
self.assertIn("Connection: close", r)
|
||||
self.assertNotIn("<open>", r)
|
||||
|
||||
def test_garbage_request_line(self):
|
||||
for req in (b"GARBAGE\r\n\r\n", b"GET\r\n\r\n", b"GET / HTTP/1.1 x\r\n\r\n", b"POST /\r\n\r\n"):
|
||||
r = raw(req)
|
||||
self.assertTrue(r.startswith("HTTP/1.1 400 Bad Request\r\n"), (req, r[:60]))
|
||||
self.assertIn("\r\nServer: nginx\r\n", r)
|
||||
self.assertIn("Connection: close", r)
|
||||
self.assertIn("<center><h1>400 Bad Request</h1></center>", r)
|
||||
self.assertNotIn("<open>", r)
|
||||
|
||||
def test_bad_version(self):
|
||||
r = raw(b"GET / HTTP/9.9\r\n\r\n")
|
||||
self.assertTrue(r.startswith("HTTP/1.1 505"), r[:40])
|
||||
|
||||
def test_connection_header(self):
|
||||
r = raw(b"GET / HTTP/1.1\r\nHost: x\r\n\r\nGET /a HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
|
||||
first, second = r.split("HTTP/1.1 404", 2)[1:]
|
||||
self.assertIn("\r\nConnection: keep-alive\r\n", first)
|
||||
self.assertIn("\r\nConnection: close\r\n", second)
|
||||
self.assertEqual(r.count("Connection:"), 2)
|
||||
|
||||
def test_http10_closes(self):
|
||||
r = raw(b"GET / HTTP/1.0\r\n\r\n")
|
||||
self.assertTrue(r.startswith("HTTP/1.1 404"))
|
||||
self.assertIn("\r\nConnection: close\r\n", r)
|
||||
self.assertNotIn("<open>", r)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("openssl"), "нужен openssl")
|
||||
class Tls(unittest.TestCase):
|
||||
"""Свой TLS (--multi-port): ALPN http/1.1, как у nginx без http2."""
|
||||
def test_alpn(self):
|
||||
d = tempfile.mkdtemp()
|
||||
cert, key = os.path.join(d, "c.pem"), os.path.join(d, "k.pem")
|
||||
subprocess.run(["openssl", "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
|
||||
"-keyout", key, "-out", cert, "-subj", "/CN=1.2.3.4", "-days", "1"], check=True, capture_output=True)
|
||||
s = socket.socket(); s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]; s.close()
|
||||
cfg = os.path.join(d, "config.json")
|
||||
with open(cfg, "w") as f:
|
||||
json.dump({"listen": "127.0.0.1", "port": port, "path": "/sub/", "upstream": "http://127.0.0.1:1/", "cert": cert, "key": key}, f)
|
||||
env = dict(os.environ, KIT_SUB_CONFIG=cfg, KIT_SUB_RULES=os.path.join(d, "rules.yaml"))
|
||||
env.pop("CREDENTIALS_DIRECTORY", None)
|
||||
p = subprocess.Popen([sys.executable, os.path.join(root, "scripts", "kit-sub.py")], env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
|
||||
for alpn, want in ((["h2", "http/1.1"], "http/1.1"), (["h2"], None)):
|
||||
ctx.set_alpn_protocols(alpn)
|
||||
for _ in range(50):
|
||||
try:
|
||||
c = socket.create_connection(("127.0.0.1", port), timeout=3)
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
with ctx.wrap_socket(c) as t:
|
||||
self.assertEqual(t.selected_alpn_protocol(), want)
|
||||
t.sendall(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
|
||||
self.assertTrue(t.recv(64).startswith(b"HTTP/1.1 404"))
|
||||
finally:
|
||||
p.terminate(); p.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Проверка архива копии и сертификатов при --restore (3x-ui.sh): python3 tools/test/restore_archive_test.py"""
|
||||
import io, json, os, re, sqlite3, subprocess, tarfile, tempfile, unittest
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
src = open(os.path.join(root, "scripts", "3x-ui.sh"), encoding="utf-8").read()
|
||||
|
||||
|
||||
def heredoc(after):
|
||||
i = src.index(after)
|
||||
j = src.index("<<'PY'", i)
|
||||
body = src[src.index("\n", j) + 1:]
|
||||
return body[:body.index("\nPY\n") + 1]
|
||||
|
||||
|
||||
CHECK = heredoc('python3 - "$file" "$tmp"')
|
||||
CERT_FILES = heredoc("db_cert_files() {")
|
||||
PIN = heredoc("pin_cert_in_db() {")
|
||||
|
||||
|
||||
def archive(extra=(), drop=()):
|
||||
files = {"etc/x-ui/x-ui.db": b"db", "etc/x-ui/install-result.env": b"X=1\n", "kit-backup.env": b"BACKUP_SSL=ip\n"}
|
||||
files.update({k: v for k, v in extra if v is not None})
|
||||
d = tempfile.mkdtemp()
|
||||
p = os.path.join(d, "b.tar.gz")
|
||||
with tarfile.open(p, "w:gz") as t:
|
||||
for name, data in list(files.items()) + [(k, v) for k, v in extra if v is None]:
|
||||
if name in drop:
|
||||
continue
|
||||
ti = tarfile.TarInfo(name)
|
||||
if data is None:
|
||||
ti.type = tarfile.DIRTYPE
|
||||
t.addfile(ti)
|
||||
else:
|
||||
ti.size = len(data)
|
||||
t.addfile(ti, io.BytesIO(data))
|
||||
return p
|
||||
|
||||
|
||||
def check(p):
|
||||
out = tempfile.mkdtemp()
|
||||
return subprocess.run(["python3", "-", p, out], input=CHECK, capture_output=True, text=True)
|
||||
|
||||
|
||||
PEM = b"-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----\n"
|
||||
|
||||
# Проверка файлов настроек – те же строки, что в --restore, после распаковки архива.
|
||||
FUNCS = "".join(re.search(r"^%s\(\) \{.*?^\}\n" % n, src, re.S | re.M).group(0) for n in ("safe_env", "env_get"))
|
||||
ENV_CHECK = src[src.index(' safe_env "$tmp/kit-backup.env"'):src.index(' python3 -c "import sqlite3')]
|
||||
ENVS = {
|
||||
"kit-backup.env": b"BACKUP_KIT_VERSION=1.2\nBACKUP_HOST=1.2.3.4\nBACKUP_SSL=ip\nBACKUP_DATE=2026-10-06\n",
|
||||
"etc/x-ui/install-result.env": b"XUI_USERNAME=abcDEF1234\nXUI_PASSWORD=Pass1234567890abcdef\nXUI_PANEL_PORT=31234\n"
|
||||
b"XUI_WEB_BASE_PATH=AbCdEf123456789012\nXUI_ACCESS_URL=https://1.2.3.4:31234/AbCdEf123456789012\n"
|
||||
b"XUI_API_TOKEN=tok_ABCdef0123456789\nXUI_DB_TYPE=sqlite\n",
|
||||
"etc/kit/kit.env": b"HOST=1.2.3.4\nLINK_HOST=1.2.3.4\nPANEL_ON=ip\nSUB_BASE=https://1.2.3.4/AbC123/\nSUB_PATH=/AbC123/\n"
|
||||
b"SUB_INTERNAL=10446\nSINGLE=yes\nMTPROTO_INNER=10445\n",
|
||||
}
|
||||
# kit.env 1.1.x: без LINK_HOST и PANEL_ON, подписка на своём порту.
|
||||
KIT_11 = b"HOST=1.2.3.4\nSUB_BASE=https://1.2.3.4:2096/AbC123/\nSUB_PATH=/AbC123/\nSUB_INTERNAL=2096\nSINGLE=no\nMTPROTO_INNER=''\n"
|
||||
|
||||
|
||||
def restore_env(over=None):
|
||||
files = dict(ENVS)
|
||||
files.update(over or {})
|
||||
p = archive([(k, v) for k, v in files.items()])
|
||||
out = tempfile.mkdtemp()
|
||||
r = subprocess.run(["python3", "-", p, out], input=CHECK, capture_output=True, text=True)
|
||||
if r.returncode:
|
||||
return r
|
||||
script = 'set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + FUNCS + 'tmp=$1\n' + ENV_CHECK + 'echo ENV-OK\n'
|
||||
return subprocess.run(["bash", "-c", script, "t", out], capture_output=True, text=True)
|
||||
|
||||
|
||||
class Archive(unittest.TestCase):
|
||||
def test_good(self):
|
||||
r = check(archive())
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_good_with_self_cert(self):
|
||||
r = check(archive([("root/cert/self", None), ("root/cert/self/fullchain.pem", PEM), ("root/cert/self/privkey.pem", PEM)]))
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_empty_self_dir(self):
|
||||
r = check(archive([("root/cert/self", None)]))
|
||||
self.assertNotEqual(r.returncode, 0)
|
||||
self.assertIn("root/cert/self/fullchain.pem", r.stderr)
|
||||
|
||||
def test_empty_custom_dir(self):
|
||||
self.assertNotEqual(check(archive([("root/cert/custom", None)])).returncode, 0)
|
||||
|
||||
def test_self_without_key(self):
|
||||
self.assertNotEqual(check(archive([("root/cert/self/fullchain.pem", PEM)])).returncode, 0)
|
||||
|
||||
def test_self_empty_key(self):
|
||||
self.assertNotEqual(check(archive([("root/cert/self/fullchain.pem", PEM), ("root/cert/self/privkey.pem", b"")])).returncode, 0)
|
||||
|
||||
def test_no_db(self):
|
||||
self.assertNotEqual(check(archive(drop=("etc/x-ui/x-ui.db",))).returncode, 0)
|
||||
|
||||
def test_dotdot(self):
|
||||
self.assertNotEqual(check(archive([("etc/x-ui/../../tmp/x", b"x")])).returncode, 0)
|
||||
|
||||
|
||||
class RestoreEnv(unittest.TestCase):
|
||||
def test_good_12(self):
|
||||
r = restore_env()
|
||||
self.assertIn("ENV-OK", r.stdout, r.stdout + r.stderr)
|
||||
|
||||
def test_good_11(self):
|
||||
r = restore_env({"etc/kit/kit.env": KIT_11,
|
||||
"kit-backup.env": b"BACKUP_KIT_VERSION=1.1.2\nBACKUP_HOST=1.2.3.4\nBACKUP_SSL=ip\nBACKUP_DATE=2026-09-01\n"})
|
||||
self.assertIn("ENV-OK", r.stdout, r.stdout + r.stderr)
|
||||
|
||||
def bad(self, over):
|
||||
r = restore_env(over)
|
||||
self.assertNotIn("ENV-OK", r.stdout)
|
||||
self.assertIn("Это не резервная копия 3X-UI_autoVPS", r.stdout + r.stderr)
|
||||
|
||||
def test_mtproto_inner_sed(self):
|
||||
for v in (b"MTPROTO_INNER='[0-9]*.*/touch \\/tmp\\/pwn-test/e;#'", b"MTPROTO_INNER='1/x/;1e touch /tmp/pwn-test #'", b"MTPROTO_INNER=1/x/e"):
|
||||
self.bad({"etc/kit/kit.env": ENVS["etc/kit/kit.env"].replace(b"MTPROTO_INNER=10445", v)})
|
||||
|
||||
def test_panel_path_nginx(self):
|
||||
for v in (b"XUI_WEB_BASE_PATH='x/ { return 200; } location /zz'", b"XUI_WEB_BASE_PATH=x/../y", b"XUI_WEB_BASE_PATH=$(id)"):
|
||||
self.bad({"etc/x-ui/install-result.env": ENVS["etc/x-ui/install-result.env"].replace(b"XUI_WEB_BASE_PATH=AbCdEf123456789012", v)})
|
||||
|
||||
def test_binary_bytes_every_file(self):
|
||||
# Аудит 1.2 (N6): нулевой байт и прочее непечатное в файлах настроек отклоняет уже проверка архива.
|
||||
for name, body in ENVS.items():
|
||||
first = body.index(b"\n") + 1
|
||||
for bad in (b"\0", b"\r", b"\x1b", b"\x80", b"\xff", b"\t", b"\x7f"):
|
||||
for variant in (bad + body, body[:first] + bad + b"\n" + body[first:],
|
||||
body.replace(b"1.2.3.4", b"1.2" + bad + b".3.4", 1), body + b"PATH=/tmp/x" + bad + b"\n",
|
||||
body + b"PATH=/tmp/x\n" + bad, body + bad + b"\n"):
|
||||
r = check(archive([(k, v) for k, v in dict(ENVS, **{name: variant}).items()]))
|
||||
self.assertNotEqual(r.returncode, 0, "%s %r" % (name, variant))
|
||||
self.assertIn("байты " + name, r.stderr)
|
||||
# Слишком большой файл (одна длинная строка) – тоже отказ.
|
||||
r = check(archive([(k, v) for k, v in dict(ENVS, **{name: body + b"X" * 70000 + b"\n"}).items()]))
|
||||
self.assertIn("байты " + name, r.stderr)
|
||||
# Тот же файл в архиве дважды (tar распакует последний) – проверяются оба.
|
||||
p = archive([(k, v) for k, v in ENVS.items()])
|
||||
with tarfile.open(p, "r:gz") as t:
|
||||
ms = [(m, t.extractfile(m).read()) for m in t.getmembers()]
|
||||
with tarfile.open(p, "w:gz") as t:
|
||||
for m, data in ms + [(tarfile.TarInfo(name), body + b"\0")]:
|
||||
m.size = len(data)
|
||||
t.addfile(m, io.BytesIO(data))
|
||||
self.assertIn("байты " + name, check(p).stderr)
|
||||
|
||||
def test_3xui_txt(self):
|
||||
good = "3X-UI_autoVPS – данные для входа\n\nПанель: https://1.2.3.4/x/\n\tvless://a@1.2.3.4:443?x=1#Мой 🙂\n".encode()
|
||||
r = check(archive([(k, v) for k, v in ENVS.items()] + [("root/3x-ui.txt", good)]))
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
for bad in (b"\0", b"\x1b]0;x\x07", b"\x1b[2J", b"\r", b"\xff", b"\xc2\x9b", b"\x7f"):
|
||||
r = check(archive([(k, v) for k, v in ENVS.items()] + [("root/3x-ui.txt", good + bad + b"\n")]))
|
||||
self.assertIn("байты root/3x-ui.txt", r.stderr, repr(bad))
|
||||
|
||||
def test_values_every_file(self):
|
||||
for name in ENVS:
|
||||
for line in ENVS[name].splitlines():
|
||||
n = line.split(b"=")[0]
|
||||
for v in (b"'a;b'", b"$(touch /tmp/pwn-test)", b"a b", b"x" * 300):
|
||||
self.bad({name: ENVS[name].replace(line, n + b"=" + v)})
|
||||
|
||||
|
||||
def make_db():
|
||||
d = tempfile.mkdtemp()
|
||||
p = os.path.join(d, "x-ui.db")
|
||||
db = sqlite3.connect(p)
|
||||
db.execute("CREATE TABLE inbounds (id INTEGER PRIMARY KEY, remark TEXT, settings TEXT, stream_settings TEXT)")
|
||||
hy = {"network": "hysteria", "security": "tls", "tlsSettings": {"certificates": [
|
||||
{"certificateFile": "/root/cert/ip/fullchain.pem", "keyFile": "/root/cert/ip/privkey.pem"}], "settings": {"fingerprint": "chrome"}},
|
||||
"hysteriaSettings": {"masquerade": {"content": "<p>Кофейня</p>"}}}
|
||||
tuic = {"server": {"certificate": "/root/cert/ip/fullchain.pem"}}
|
||||
other = {"tlsSettings": {"certificates": [{"certificateFile": "/root/cert/custom/fullchain.pem"}]}}
|
||||
db.executemany("INSERT INTO inbounds (remark, settings, stream_settings) VALUES (?, ?, ?)", [
|
||||
("Hysteria2", "{}", json.dumps(hy)), ("TUIC", json.dumps(tuic), ""), ("Other", "{}", json.dumps(other)),
|
||||
("Broken", "not json", "[1]")])
|
||||
db.commit(); db.close()
|
||||
return p
|
||||
|
||||
|
||||
def run_db(code, p, *args):
|
||||
code = code.replace("/etc/x-ui/x-ui.db", p)
|
||||
return subprocess.run(["python3", "-", *args], input=code, capture_output=True, text=True)
|
||||
|
||||
|
||||
class Certs(unittest.TestCase):
|
||||
def test_cert_files(self):
|
||||
r = run_db(CERT_FILES, make_db())
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertEqual(r.stdout.split(), ["/root/cert/custom/fullchain.pem", "/root/cert/ip/fullchain.pem"])
|
||||
|
||||
def test_pin_only_matching_inbound(self):
|
||||
p = make_db()
|
||||
r = run_db(PIN, p, "/root/cert/ip/fullchain.pem", "ab" * 32)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
rows = dict(sqlite3.connect(p).execute("SELECT remark, stream_settings FROM inbounds"))
|
||||
hy = json.loads(rows["Hysteria2"])
|
||||
self.assertEqual(hy["tlsSettings"]["settings"], {"fingerprint": "chrome", "pinnedPeerCertSha256": ["ab" * 32]})
|
||||
self.assertEqual(hy["hysteriaSettings"]["masquerade"]["content"], "<p>Кофейня</p>")
|
||||
self.assertNotIn("pinnedPeerCertSha256", rows["Other"])
|
||||
self.assertEqual(rows["Broken"], "[1]")
|
||||
|
||||
def test_restore_writes_only_own_paths(self):
|
||||
# Путь сертификата приходит из базы копии: генерировать можно только в /root/cert/ip и /root/cert/self.
|
||||
self.assertIn(r"^/root/cert/(ip|self)/fullchain\.pem$", src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Проверки разбора файлов настроек из резервной копии (safe_env, env_get в 3x-ui.sh):
|
||||
python3 tools/test/restore_env_test.py"""
|
||||
import os, re, subprocess, tempfile, unittest
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
src = open(os.path.join(root, "scripts", "3x-ui.sh"), encoding="utf-8").read()
|
||||
funcs = "".join(re.search(r"^%s\(\) \{.*?^\}\n" % n, src, re.S | re.M).group(0) for n in ("safe_env", "env_get"))
|
||||
|
||||
BACKUP = ("BACKUP_KIT_VERSION", "BACKUP_HOST", "BACKUP_SSL", "BACKUP_DATE")
|
||||
XUI = ("XUI_USERNAME", "XUI_PASSWORD", "XUI_PANEL_PORT", "XUI_WEB_BASE_PATH", "XUI_ACCESS_URL", "XUI_API_TOKEN", "XUI_DB_TYPE")
|
||||
KIT = ("HOST", "LINK_HOST", "PANEL_ON", "SUB_BASE", "SUB_PATH", "SUB_INTERNAL", "SINGLE", "MTPROTO_INNER")
|
||||
|
||||
# Как пишут kit backup, установщик 3X-UI и install_kit_cli (printf %q).
|
||||
GOOD = {
|
||||
"kit-backup.env": "BACKUP_KIT_VERSION=1.2\nBACKUP_HOST=1.2.3.4\nBACKUP_SSL=ip\nBACKUP_DATE=2026-10-06\n",
|
||||
"install-result.env": "XUI_USERNAME=abcDEF1234\nXUI_PASSWORD=Pass1234567890abcdef\nXUI_PANEL_PORT=31234\n"
|
||||
"XUI_WEB_BASE_PATH=AbCdEf123456789012\nXUI_ACCESS_URL=https://1.2.3.4:31234/AbCdEf123456789012\n"
|
||||
"XUI_API_TOKEN=tok_ABCdef0123456789\nXUI_DB_TYPE=sqlite\n",
|
||||
"kit.env": "HOST=1.2.3.4\nLINK_HOST=1.2.3.4\nPANEL_ON=ip\nSUB_BASE=https://1.2.3.4/AbC123/\nSUB_PATH=/AbC123/\n"
|
||||
"SUB_INTERNAL=2096\nSINGLE=yes\nMTPROTO_INNER=''\n",
|
||||
}
|
||||
NAMES = {"kit-backup.env": BACKUP, "install-result.env": XUI, "kit.env": KIT}
|
||||
# Другие настоящие варианты: kit.env 1.1.x (без LINK_HOST и PANEL_ON, свой порт подписки), «всё на 443» с доменом,
|
||||
# install-result.env с путём панели в слешах (панель стояла до установщика) и пустыми значениями.
|
||||
GOOD_MORE = [
|
||||
("kit.env", "HOST=1.2.3.4\nSUB_BASE=https://1.2.3.4:2096/AbC123/\nSUB_PATH=/AbC123/\nSUB_INTERNAL=2096\nSINGLE=no\nMTPROTO_INNER=''\n"),
|
||||
("kit.env", "HOST=1.2.3.4\nSUB_BASE=http://127.0.0.1:2096/AbC123/\nSUB_PATH=/AbC123/\nSUB_INTERNAL=2096\nSINGLE=no\nMTPROTO_INNER=''\n"),
|
||||
("kit.env", "HOST=1.2.3.4\nSUB_BASE=https://1.2.3.4/Ab-C_123/\nSUB_PATH=/Ab-C_123/\nSUB_INTERNAL=10446\nSINGLE=yes\nMTPROTO_INNER=10445\n"),
|
||||
("kit.env", "HOST=vpn.example.com\nLINK_HOST=vpn.example.com\nPANEL_ON=domain\nSUB_BASE=https://vpn.example.com/AbC123/\n"
|
||||
"SUB_PATH=/AbC123/\nSUB_INTERNAL=10446\nSINGLE=yes\nMTPROTO_INNER=10445\n"),
|
||||
("install-result.env", "XUI_USERNAME=admin\nXUI_PASSWORD=Pass_1234-abc.def\nXUI_PANEL_PORT=2053\nXUI_WEB_BASE_PATH=/AbCdEf123456789012/\n"
|
||||
"XUI_ACCESS_URL=http://SERVER_IP_UNKNOWN:2053//AbCdEf123456789012/\nXUI_API_TOKEN=''\nXUI_DB_TYPE=sqlite\n"),
|
||||
("kit-backup.env", "BACKUP_KIT_VERSION=1.1.2\nBACKUP_HOST=vpn.example.com\nBACKUP_SSL=custom\nBACKUP_DATE=2026-09-01\n"),
|
||||
]
|
||||
# Плохие значения: проверяются для каждого имени в каждом файле.
|
||||
BAD_VALUES = ("$(touch /tmp/pwn-test)", "`id`", "1;id", "1; id", "'1;id'", "'a b'", "a b", "'", "\"x\"", "$HOME", "x\\y",
|
||||
"1\nid", "'1\n/e;#'", "a" * 300, "9" * 300, "x{", "a|b", "a&b", "a>b", "a*b")
|
||||
|
||||
|
||||
def run(name, text, extra=""):
|
||||
d = tempfile.mkdtemp()
|
||||
p = os.path.join(d, name)
|
||||
with open(p, "w") as fh:
|
||||
fh.write(text)
|
||||
script = ('set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + funcs
|
||||
+ 'RESULT=/root/3x-ui.txt\nsafe_env "$1" ' + " ".join(NAMES[name]) + '\necho OK\n' + extra)
|
||||
r = subprocess.run(["bash", "-c", script, "t", p], capture_output=True, text=True)
|
||||
return r.returncode, r.stdout + r.stderr
|
||||
|
||||
|
||||
class SafeEnv(unittest.TestCase):
|
||||
def test_good_copy(self):
|
||||
for name, text in GOOD.items():
|
||||
code, out = run(name, text)
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn("OK", out)
|
||||
|
||||
def test_good_variants(self):
|
||||
for name, text in GOOD_MORE:
|
||||
code, out = run(name, text)
|
||||
self.assertEqual(code, 0, name + ": " + out)
|
||||
self.assertIn("OK", out)
|
||||
|
||||
def test_bad_value_every_field(self):
|
||||
for name, text in GOOD.items():
|
||||
for n in NAMES[name]:
|
||||
for bad in BAD_VALUES:
|
||||
lines = [l for l in text.splitlines() if not l.startswith(n + "=")] + [n + "=" + bad]
|
||||
self.rejected(name, "\n".join(lines) + "\n")
|
||||
|
||||
def test_wrong_format(self):
|
||||
# Безопасные символы, но не тот формат.
|
||||
for name, n, bad in (("kit.env", "MTPROTO_INNER", "1/x/e"), ("kit.env", "MTPROTO_INNER", "abc"), ("kit.env", "SINGLE", "maybe"),
|
||||
("kit.env", "PANEL_ON", "x"), ("kit.env", "SUB_PATH", "/../"), ("kit.env", "SUB_PATH", "/a"),
|
||||
("kit.env", "SUB_BASE", "file:///etc/"), ("kit.env", "SUB_INTERNAL", "20x6"), ("kit.env", "HOST", ""),
|
||||
("kit.env", "LINK_HOST", "a/b"), ("kit-backup.env", "BACKUP_SSL", "le"), ("kit-backup.env", "BACKUP_DATE", "today"),
|
||||
("kit-backup.env", "BACKUP_KIT_VERSION", "1.2/x"), ("install-result.env", "XUI_WEB_BASE_PATH", "a/b"),
|
||||
("install-result.env", "XUI_WEB_BASE_PATH", ""), ("install-result.env", "XUI_WEB_BASE_PATH", "a.b"),
|
||||
("install-result.env", "XUI_DB_TYPE", "mysql"), ("install-result.env", "XUI_PANEL_PORT", "123456"),
|
||||
("install-result.env", "XUI_USERNAME", ""), ("install-result.env", "XUI_ACCESS_URL", "ftp://1.2.3.4/x")):
|
||||
lines = [l for l in GOOD[name].splitlines() if not l.startswith(n + "=")] + [n + "=" + bad]
|
||||
self.rejected(name, "\n".join(lines) + "\n")
|
||||
|
||||
def test_audit_payloads(self):
|
||||
# Из повторного аудита 1.2: выражение sed с флагом e и чужие директивы nginx.
|
||||
for bad in ("MTPROTO_INNER='[0-9]*.*/touch \\/tmp\\/pwn-test/e;#'", "MTPROTO_INNER='1/x/;1e touch /tmp/pwn-test #'"):
|
||||
self.rejected("kit.env", GOOD["kit.env"].replace("MTPROTO_INNER=''\n", bad + "\n"))
|
||||
self.rejected("install-result.env", GOOD["install-result.env"].replace(
|
||||
"XUI_WEB_BASE_PATH=AbCdEf123456789012", "XUI_WEB_BASE_PATH='x/ { return 200; } location /zz'"))
|
||||
|
||||
def test_values(self):
|
||||
code, out = run("kit-backup.env", GOOD["kit-backup.env"],
|
||||
'echo "[$(env_get "$1" BACKUP_HOST)|$(env_get "$1" BACKUP_SSL)|$(env_get "$1" NOPE)]"\n')
|
||||
self.assertIn("[1.2.3.4|ip|]", out)
|
||||
code, out = run("kit.env", GOOD["kit.env"], 'echo "[$(env_get "$1" SUB_PATH)|$(env_get "$1" MTPROTO_INNER)]"\n')
|
||||
self.assertIn("[/AbC123/|]", out)
|
||||
|
||||
def test_env_get_does_not_touch_shell(self):
|
||||
code, out = run("kit-backup.env", GOOD["kit-backup.env"], 'env_get "$1" BACKUP_HOST >/dev/null; echo "[${BACKUP_HOST:-none}]"\n')
|
||||
self.assertIn("[none]", out)
|
||||
|
||||
def rejected(self, name, text):
|
||||
code, out = run(name, text, 'echo "RESULT=$RESULT"\n')
|
||||
self.assertNotEqual(code, 0, out)
|
||||
self.assertIn("Это не резервная копия 3X-UI_autoVPS", out)
|
||||
self.assertNotIn("\nOK\n", "\n" + out)
|
||||
|
||||
def test_result_in_backup_env(self):
|
||||
self.rejected("kit-backup.env", GOOD["kit-backup.env"] + "RESULT=/etc/cron.d/x\n")
|
||||
|
||||
def test_kit_version_in_kit_env(self):
|
||||
self.rejected("kit.env", GOOD["kit.env"] + "KIT_VERSION=9.9\n")
|
||||
|
||||
def test_foreign_names(self):
|
||||
for bad in ("PATH=/tmp", "IFS=x", "XUI_ENV=/etc/cron.d/x", "HOST=5.6.7.8", "KIT_RAW=https://example.org/"):
|
||||
self.rejected("kit-backup.env", GOOD["kit-backup.env"] + bad + "\n")
|
||||
for bad in ("PATH=/tmp", "RESULT=/etc/cron.d/x", "XUI_ENV=/tmp/x", "KIT_VERSION=9.9"):
|
||||
self.rejected("install-result.env", GOOD["install-result.env"] + bad + "\n")
|
||||
for bad in ("RESULT=/etc/cron.d/x", "XUI_PIN=v0", "BACKUP_HOST=1.2.3.4"):
|
||||
self.rejected("kit.env", GOOD["kit.env"] + bad + "\n")
|
||||
|
||||
def test_binary_bytes(self):
|
||||
# Аудит 1.2 (N6): с нулевым байтом GNU grep без -a считал файл двоичным и молчал – проверка «проходила»,
|
||||
# а bash при «.» выкидывает нулевые байты и выполняет остальное. Лишнее имя PATH плюс один байт в каждом месте.
|
||||
for name, text in GOOD.items():
|
||||
body = text.encode()
|
||||
first = body.index(b"\n") + 1
|
||||
for bad in (b"\0", b"\r", b"\x1b", b"\x80", b"\xff"):
|
||||
for variant in (bad + body, # в начале
|
||||
body[:first] + bad + b"\n" + body[first:], # отдельной строкой
|
||||
body[:5] + bad + body[5:], # в середине имени или в начале значения
|
||||
body.replace(b"1.2.3.4", b"1.2" + bad + b".3.4", 1), # в середине значения
|
||||
body + b"PATH=/tmp/x" + bad + b"\n", # в значении лишнего имени
|
||||
body + b"PATH=/tmp/x\n" + bad, # в конце, без перевода строки
|
||||
body + b"PATH=/tmp/x\n" + bad + b"\n"): # в конце, одиночный
|
||||
self.rejected_bytes(name, variant)
|
||||
# Без лишнего имени, только байт – тоже отказ.
|
||||
self.rejected_bytes(name, body + b"\0\n")
|
||||
self.rejected_bytes(name, body.replace(b"\n", b"\r\n"))
|
||||
|
||||
def rejected_bytes(self, name, data):
|
||||
d = tempfile.mkdtemp()
|
||||
p = os.path.join(d, name)
|
||||
with open(p, "wb") as fh:
|
||||
fh.write(data)
|
||||
script = ('set -Eeuo pipefail\ndie() { echo "DIE: $*"; exit 1; }\n' + funcs
|
||||
+ 'safe_env "$1" ' + " ".join(NAMES[name]) + '\necho OK\n')
|
||||
r = subprocess.run(["bash", "-c", script, "t", p], capture_output=True)
|
||||
out = (r.stdout + r.stderr).decode("utf-8", "replace")
|
||||
self.assertNotEqual(r.returncode, 0, "%s %r: %s" % (name, data, out))
|
||||
self.assertIn("Это не резервная копия 3X-UI_autoVPS", out, "%s %r" % (name, data))
|
||||
self.assertNotIn("\nOK\n", "\n" + out)
|
||||
|
||||
def test_substitutions(self):
|
||||
for bad in ("BACKUP_DATE=$(id)", "BACKUP_DATE=`id`", 'BACKUP_DATE="$HOME"', "export BACKUP_DATE=1", "BACKUP_DATE=1; id", " BACKUP_DATE=1"):
|
||||
self.rejected("kit-backup.env", GOOD["kit-backup.env"] + bad + "\n")
|
||||
|
||||
|
||||
kit_src = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
|
||||
PANEL_PATH = re.search(r"^panel_web_path\(\) \{.*?^\}\n", kit_src, re.S | re.M).group(0)
|
||||
|
||||
|
||||
class NginxPaths(unittest.TestCase):
|
||||
"""Путь панели и подписки перед сборкой nginx (kit.sh panel_web_path, 3x-ui.sh setup_nginx)."""
|
||||
|
||||
def path(self, base, sub="/AbC123/"):
|
||||
r = subprocess.run(["bash", "-c", PANEL_PATH + 'XUI_WEB_BASE_PATH=$1 SUB_PATH=$2\npanel_web_path || echo NO', "t", base, sub],
|
||||
capture_output=True, text=True)
|
||||
return r.stdout.strip()
|
||||
|
||||
def test_good(self):
|
||||
self.assertEqual(self.path("AbCdEf123456789012"), "/AbCdEf123456789012/")
|
||||
self.assertEqual(self.path("/AbC_d-1/"), "/AbC_d-1/")
|
||||
|
||||
def test_bad(self):
|
||||
for bad in ("x/ { return 200; } location /zz", "", "/", "a/b", "a;b", "a\nb", "$(id)", "a" * 65):
|
||||
self.assertEqual(self.path(bad), "NO", bad)
|
||||
for bad in ("/a b/", "/a/ { }", "", "/../"):
|
||||
self.assertEqual(self.path("AbC", bad), "NO", bad)
|
||||
|
||||
def test_setup_nginx_checks(self):
|
||||
self.assertIn('[[ $XUI_WEB_BASE_PATH =~ ^/?[A-Za-z0-9_-]{1,64}/?$ && $SUB_PATH =~ ^/[A-Za-z0-9_-]{1,64}/$ ]]', src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Проверки раздельной маршрутизации kit-sub: python3 tools/test/rules_test.py"""
|
||||
import json, os, sys, tempfile, unittest, importlib.util
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
tmp = tempfile.mkdtemp()
|
||||
cfgp = os.path.join(tmp, "config.json")
|
||||
json.dump({"listen": "127.0.0.1", "port": 1, "path": "/sub/", "upstream": "http://127.0.0.1:1/"}, open(cfgp, "w"))
|
||||
os.environ["KIT_SUB_CONFIG"] = cfgp
|
||||
os.environ["KIT_SUB_RULES"] = os.path.join(tmp, "rules.yaml")
|
||||
spec = importlib.util.spec_from_file_location("kit_sub", os.path.join(root, "scripts", "kit-sub.py"))
|
||||
ks = importlib.util.module_from_spec(spec); spec.loader.exec_module(ks)
|
||||
import yaml
|
||||
|
||||
BASE = yaml.safe_dump({"proxies": [{"name": "a", "type": "vless"}, {"name": "b", "type": "vless"}],
|
||||
"proxy-groups": [{"name": "Авто", "type": "url-test", "proxies": ["a", "b"]}],
|
||||
"rules": ["MATCH,Авто"], "dns": {"enable": False}}).encode()
|
||||
|
||||
|
||||
def write(text):
|
||||
with open(ks.RULES_FILE, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
class Rules(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
if os.path.exists(ks.RULES_FILE):
|
||||
os.remove(ks.RULES_FILE)
|
||||
|
||||
def apply(self, text):
|
||||
write(text)
|
||||
loaded = ks.load_rules(report=lambda m: None)
|
||||
self.assertIsNotNone(loaded)
|
||||
return yaml.safe_load(ks.apply_rules(BASE, loaded))
|
||||
|
||||
def test_no_file(self):
|
||||
self.assertIsNone(ks.load_rules())
|
||||
|
||||
def test_basic(self):
|
||||
c = self.apply("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX, example.org \n - IP-CIDR,1.2.3.0/24\n")
|
||||
r = c["rules"]
|
||||
self.assertEqual(r[-1], "MATCH,DIRECT")
|
||||
self.assertIn("GEOSITE,youtube,Авто", r)
|
||||
self.assertIn("DOMAIN-SUFFIX,example.org,Авто", r)
|
||||
self.assertIn("IP-CIDR,1.2.3.0/24,Авто,no-resolve", r) # адресные правила не разрешают имена
|
||||
self.assertLess(r.index("IP-CIDR,192.168.0.0/16,DIRECT,no-resolve"), r.index("GEOSITE,youtube,Авто"))
|
||||
self.assertEqual(c["mode"], "rule")
|
||||
|
||||
def test_dns_no_leak(self):
|
||||
c = self.apply("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX,example.org\n - DOMAIN,api.example.com\n")
|
||||
d = c["dns"]
|
||||
self.assertEqual(d["enhanced-mode"], "fake-ip")
|
||||
self.assertEqual(d["nameserver"], ["system"])
|
||||
self.assertNotIn("fallback", d)
|
||||
self.assertEqual(d["nameserver-policy"]["geosite:youtube"], ks.DOH_DEFAULT)
|
||||
self.assertIn("+.example.org", d["nameserver-policy"])
|
||||
self.assertIn("api.example.com", d["nameserver-policy"])
|
||||
# DoH-серверы идут через VPN, а не напрямую
|
||||
self.assertIn("IP-CIDR,1.1.1.1/32,Авто,no-resolve", c["rules"])
|
||||
self.assertFalse(c["sniffer"]["override-destination"])
|
||||
|
||||
def test_direct_dns(self):
|
||||
c = self.apply("direct_dns:\n - https://dns.example.net/dns-query\n - http://evil/x\n - 'https://a b/'\nvia_vpn:\n - GEOSITE,openai\n")
|
||||
self.assertEqual(c["dns"]["nameserver"], ["https://dns.example.net/dns-query"])
|
||||
|
||||
def test_bad_rules_dropped(self):
|
||||
write("via_vpn:\n - MATCH,DIRECT\n - PROCESS-NAME,x\n - SCRIPT,abc\n - 'DOMAIN,a.com,DIRECT'\n - 'DOMAIN,a b'\n - GEOSITE,youtube\n - 'DOMAIN-SUFFIX,x.org\\nMATCH,Авто'\n")
|
||||
loaded = ks.load_rules(report=lambda m: None)
|
||||
self.assertEqual(loaded[0], [("GEOSITE", "youtube")])
|
||||
|
||||
def test_broken_files_leave_subscription_alone(self):
|
||||
for text in ("", "via_vpn: 5", "via_vpn: []", "- a\n- b", ":\n - [", "via_vpn:\n - MATCH,DIRECT"):
|
||||
write(text)
|
||||
self.assertIsNone(ks.load_rules(report=lambda m: None), text)
|
||||
with open(ks.RULES_FILE, "wb") as f:
|
||||
f.write(b"via_vpn:\n" + b" - DOMAIN,a.com\n" * 6000)
|
||||
self.assertIsNone(ks.load_rules(report=lambda m: None)) # слишком большой
|
||||
|
||||
def test_limits_and_duplicates(self):
|
||||
write("via_vpn:\n" + "".join(f" - DOMAIN,h{i}.example.org\n" for i in range(400)) + " - DOMAIN,h1.example.org\n")
|
||||
rules, _ = ks.load_rules(report=lambda m: None)
|
||||
self.assertEqual(len(rules), ks.RULES_MAX_COUNT)
|
||||
self.assertEqual(len(set(rules)), len(rules))
|
||||
|
||||
def test_no_proxies_untouched(self):
|
||||
write("via_vpn:\n - GEOSITE,youtube\n")
|
||||
loaded = ks.load_rules(report=lambda m: None)
|
||||
self.assertEqual(ks.apply_rules(b"proxies: []\n", loaded), b"proxies: []\n")
|
||||
|
||||
def test_forgiving_input(self):
|
||||
# Что если: строчные типы, BOM и CRLF из Windows-редактора, лишние пробелы
|
||||
with open(ks.RULES_FILE, "wb") as f:
|
||||
f.write("\ufeffvia_vpn:\r\n - geosite , youtube\r\n - domain-suffix,Example.ORG\r\n".encode("utf-8"))
|
||||
rules, _ = ks.load_rules(report=lambda m: None)
|
||||
self.assertEqual(rules, [("GEOSITE", "youtube"), ("DOMAIN-SUFFIX", "Example.ORG")])
|
||||
|
||||
def test_bad_cidr_and_short_keyword(self):
|
||||
write("via_vpn:\n - IP-CIDR,999.1.1.1/8\n - IP-CIDR,1.2.3.4/99\n - IP-CIDR6,1.2.3.0/24\n - IP-CIDR,2001:db8::/32\n"
|
||||
" - IP-CIDR,203.0.113.0/24\n - IP-CIDR6,2001:db8::/32\n - DOMAIN-KEYWORD,a\n - DOMAIN-KEYWORD,video\n")
|
||||
rules, _ = ks.load_rules(report=lambda m: None)
|
||||
self.assertEqual(rules, [("IP-CIDR", "203.0.113.0/24"), ("IP-CIDR6", "2001:db8::/32"), ("DOMAIN-KEYWORD", "video")])
|
||||
|
||||
def test_template_from_kit_sh_is_valid(self):
|
||||
# Что если: шаблон kit net split on сам окажется с ошибкой
|
||||
import re
|
||||
t = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
|
||||
body = t[t.index("split_template() {"):]
|
||||
body = body[body.index("<<'YAML'\n") + 9: body.index("\nYAML\n")]
|
||||
write(body)
|
||||
rules, _ = ks.load_rules(report=lambda m: self.fail(m))
|
||||
self.assertGreater(len(rules), 40)
|
||||
self.assertIn(("GEOIP", "telegram"), rules)
|
||||
self.assertNotIn(("DOMAIN-SUFFIX", "example.org"), rules) # пример закомментирован
|
||||
|
||||
def test_rules_only_for_mihomo_apps(self):
|
||||
# Что если: Karing/Hiddify просят формат Clash – правила им не нужны (у них своя маршрутизация)
|
||||
def gets_rules(ua):
|
||||
clash = bool(ks.CLASH_UA.search(ua))
|
||||
return clash and not ks.NO_AWG_UA.search(ua)
|
||||
for ua in ("FlClash/0.8.80", "Clash.Meta/1.18", "mihomo/1.19", "clash-verge/v2.0", "ClashVerge/2.0", "Mihomo Party/1.5"):
|
||||
self.assertTrue(gets_rules(ua), ua)
|
||||
for ua in ("Karing/1.2.5", "Karing/1.2.5 clash.meta", "Hiddify/2.0 clash", "sing-box/1.9", "Happ/1.0", "Stash/2.7", "v2rayN/6.0", "Mozilla/5.0"):
|
||||
self.assertFalse(gets_rules(ua), ua)
|
||||
|
||||
def test_routing_payloads_for_panel(self):
|
||||
import base64
|
||||
write("via_vpn:\n - GEOSITE,youtube\n - DOMAIN-SUFFIX,example.org\n - DOMAIN,api.example.com\n - GEOIP,telegram\n - IP-CIDR,203.0.113.0/24\n")
|
||||
loaded = ks.load_rules(report=lambda m: None)
|
||||
link = ks.routing_payload("happ", loaded)
|
||||
self.assertTrue(link.startswith("happ://routing/onadd/"))
|
||||
h = json.loads(base64.b64decode(link.split("/onadd/")[1]))
|
||||
self.assertEqual(h["Name"], "3X-UI_autoVPS")
|
||||
self.assertEqual(h["GlobalProxy"], "false")
|
||||
self.assertEqual(h["ProxySites"], ["geosite:youtube", "domain:example.org", "full:api.example.com"])
|
||||
self.assertEqual(h["ProxyIp"], ["geoip:telegram", "203.0.113.0/24"])
|
||||
j = json.loads(ks.routing_payload("json", loaded))
|
||||
self.assertEqual(j["RouteOrder"], "block-proxy-direct")
|
||||
self.assertEqual(j["DirectSites"], ["regexp:.*"]) # остальное напрямую: панель сама добавляет «всё через прокси» в конец
|
||||
self.assertIn("0.0.0.0/0", j["DirectIp"])
|
||||
self.assertEqual(j["ProxyIp"][:2], ["1.1.1.1", "8.8.8.8"])
|
||||
self.assertEqual(j["DomesticDNSDomain"], "localhost")
|
||||
self.assertLess(len(link), 8192) # предел панели на значение заголовка Routing
|
||||
|
||||
def test_full_default_template_fits_panel_limit(self):
|
||||
t = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
|
||||
body = t[t.index("split_template() {"):]
|
||||
body = body[body.index("<<'YAML'\n") + 9: body.index("\nYAML\n")]
|
||||
write(body)
|
||||
loaded = ks.load_rules(report=lambda m: self.fail(m))
|
||||
self.assertLess(len(ks.routing_payload("happ", loaded)), 8192)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=1)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Проверки сайта-заглушки (stub_site в 3x-ui.sh): python3 tools/test/stub_test.py"""
|
||||
import html.parser, os, re, subprocess, unittest
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
src = open(os.path.join(root, "scripts", "3x-ui.sh"), encoding="utf-8").read()
|
||||
func = re.search(r"^stub_site\(\) \{.*?^\}\n", src, re.S | re.M).group(0)
|
||||
N = 300
|
||||
# rnd на сервере – через shuf, здесь – через /dev/urandom (на Mac нет shuf; $RANDOM в подоболочках повторяется).
|
||||
script = ('set -Eeuo pipefail\nrnd() { echo $(( $1 + $(od -An -N2 -tu2 /dev/urandom) % ($2 - $1 + 1) )); }\n' + func
|
||||
+ 'for i in $(seq %d); do stub_site; echo "@@END@@"; done\n' % N)
|
||||
raw = subprocess.run(["bash", "-c", script], capture_output=True, check=True).stdout
|
||||
PAGES = [p.strip() for p in raw.decode("utf-8").split("@@END@@") if p.strip()] # strict: только UTF-8
|
||||
|
||||
forbidden = []
|
||||
fp = os.path.join(root, ".git", "hooks", "forbidden.txt")
|
||||
if os.path.exists(fp):
|
||||
forbidden = [w.strip().lower() for w in open(fp, encoding="utf-8") if w.strip() and not w.startswith("#")]
|
||||
PUBLIC = re.compile(r"vpn|proxy|xray|x-ui|cumulo|hysteria|reality|v2ray|clash|\bkit\b|tunnel|censor|block|bypass|server", re.I)
|
||||
EXTERNAL = re.compile(r"https?:|//|\bsrc=|\bhref=|<script|<link|<iframe|<img|@import|url\(|\bon[a-z]+=", re.I)
|
||||
VOID = {"meta", "br", "hr", "input", "img"}
|
||||
|
||||
|
||||
class Balance(html.parser.HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(); self.stack = []; self.bad = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag not in VOID:
|
||||
self.stack.append(tag)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if not self.stack or self.stack.pop() != tag:
|
||||
self.bad.append(tag)
|
||||
|
||||
|
||||
class Stub(unittest.TestCase):
|
||||
def test_count(self):
|
||||
self.assertEqual(len(PAGES), N)
|
||||
|
||||
def test_valid_html(self):
|
||||
for p in PAGES:
|
||||
self.assertTrue(p.startswith('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'), p[:80])
|
||||
self.assertTrue(p.endswith("</body></html>"), p[-40:])
|
||||
b = Balance(); b.feed(p); b.close()
|
||||
self.assertEqual((b.stack, b.bad), ([], []), p)
|
||||
self.assertEqual(p.count("<title>"), 1)
|
||||
|
||||
def test_no_external_and_no_leftovers(self):
|
||||
for p in PAGES:
|
||||
self.assertIsNone(EXTERNAL.search(p), p)
|
||||
text = re.sub(r"<style>.*?</style>", "", p)
|
||||
self.assertIsNone(re.search(r"[$|^~`\\]", text), p)
|
||||
self.assertNotIn("\n", p)
|
||||
|
||||
def test_words(self):
|
||||
for p in PAGES:
|
||||
low = p.lower()
|
||||
self.assertIsNone(PUBLIC.search(re.sub(r"<style>.*?</style>", "", p)), p) # в стилях есть display:block
|
||||
for w in forbidden:
|
||||
self.assertNotIn(w, low)
|
||||
|
||||
def test_variety(self):
|
||||
titles = {re.search(r"<title>(.*?)</title>", p).group(1) for p in PAGES}
|
||||
self.assertGreater(len(titles), N * 0.8)
|
||||
layouts = {k for p in PAGES for k, m in (("hero", "place-items:center;text-align"), ("cards", "header{background"),
|
||||
("nav", "nav{width"), ("notes", "article{"), ("band", ".t{background")) if m in p}
|
||||
self.assertEqual(len(layouts), 5)
|
||||
themes = {re.search(r"<title>.*? (\S+)</title>", p).group(1) for p in PAGES}
|
||||
self.assertGreaterEqual(len(themes), 25) # последние слова названий: Coffee, Bakery, Photography…
|
||||
# Ни одно окончание названия не встречается заметно чаще других (было: «Studio» у каждого пятого).
|
||||
ends = [re.search(r"<title>.*? (\S+)</title>", p).group(1) for p in PAGES]
|
||||
top = max(set(ends), key=ends.count)
|
||||
self.assertLess(ends.count(top), N * 0.08, (top, ends.count(top)))
|
||||
|
||||
|
||||
class SamePage(unittest.TestCase):
|
||||
"""Hysteria2 (masquerade) отдаёт заглушку байт в байт как nginx – с последним переводом строки."""
|
||||
|
||||
def read(self, line, var):
|
||||
import tempfile
|
||||
d = tempfile.mkdtemp()
|
||||
f = os.path.join(d, "index.html")
|
||||
with open(f, "wb") as fh:
|
||||
fh.write(b"<html>\n<p>x</p>\n\n")
|
||||
line = line.replace("/var/www/kit/index.html", f)
|
||||
r = subprocess.run(["bash", "-c", line + '\nprintf %s "$' + var + '"'], capture_output=True)
|
||||
return r.stdout
|
||||
|
||||
def test_installer(self):
|
||||
line = re.search(r"^ STUB_HTML=\$\(cat /var/www/kit/index\.html.*$", src, re.M).group(0)
|
||||
self.assertEqual(self.read(line, "STUB_HTML"), b"<html>\n<p>x</p>\n\n")
|
||||
|
||||
def test_kit_masq(self):
|
||||
kit = open(os.path.join(root, "scripts", "kit.sh"), encoding="utf-8").read()
|
||||
line = re.search(r"^ page=\$\(cat /var/www/kit/index\.html.*$", kit, re.M).group(0)
|
||||
self.assertEqual(self.read(line, "page"), b"<html>\n<p>x</p>\n\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user