Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
@@ -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,57 @@
|
||||
# Что нового в сборке форка
|
||||
|
||||
Здесь записи о наших версиях. Файл автора (`CHANGELOG.md`) мы не трогаем — так обновления
|
||||
автора не конфликтуют с нашими записями.
|
||||
|
||||
## 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 KIT 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 KIT 1.2 (панель 3X-UI v3.9.0, ядро Xray v26.6.27). Что нового у автора — в `CHANGELOG.md`.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Форк 3X-UI KIT (hacker07)
|
||||
|
||||
Это личный форк [3X-UI KIT](https://github.com/itsnotkubrick/3X-UI_KIT): наш код живёт рядом с
|
||||
кодом автора и не ломается, когда автор обновляет свой. Здесь описано, как это устроено и что
|
||||
делать после каждого обновления автора.
|
||||
|
||||
## Что где лежит
|
||||
|
||||
| Что | Где | Правит |
|
||||
|---|---|---|
|
||||
| Наши доработки (команды, хуки) | `scripts/kit-local.sh` | только мы |
|
||||
| Описание «швов» в коде автора | `tools/fork_apply.py` | только мы |
|
||||
| Синхронизация с автором и проверки | `tools/fork-sync.sh` | только мы |
|
||||
| Файлы автора (`scripts/kit.sh`, `scripts/3x-ui.sh`, …) | как есть | только автор |
|
||||
|
||||
Ключевая идея: **в файлах автора мы правим только «швы»** — несколько констант и одну строку
|
||||
подключения. Всё, что мы дописываем по существу, лежит в нашем файле, которого автор не знает.
|
||||
Швы описаны в одном месте (`tools/fork_apply.py`) и накатываются только на чистое дерево автора;
|
||||
если автор изменил нужную строку — инструмент останавливается и говорит об этом, а не делает
|
||||
полу-патч.
|
||||
|
||||
## Ветки
|
||||
|
||||
- `upstream` — точная копия ветки автора (`main`), только вперёд.
|
||||
- `dev` — наша сборка: `upstream` + швы + наш файл. Из неё делаются релизы.
|
||||
- релизы — теги `v<номера>`; номер = `<версия автора>.<наш уровень>` (например, автор 1.2 → наш
|
||||
первый релиз 1.2.1, следующий наш — 1.2.2, после релиза автора 1.3 — 1.3.1).
|
||||
|
||||
Номер обязан строго расти: на серверах `kit update` не принимает откат назад.
|
||||
|
||||
## Обновление автора (по шагам)
|
||||
|
||||
```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 + подпись нашим ключом
|
||||
git add -A && git commit -m "Релиз v$(cat VERSION): швы форка + подпись"
|
||||
git tag v$(cat VERSION)
|
||||
git push origin dev v$(cat VERSION) upstream
|
||||
```
|
||||
|
||||
## Установка и обновление из этого репозитория
|
||||
|
||||
```bash
|
||||
# установка одной командой: наш релиз, подпись проверяется до запуска установщика,
|
||||
# в конце — закрытие ping, автообновления безопасности, kit fix и kit check
|
||||
bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh) --domain vpn.example.com
|
||||
|
||||
# «а что вы вообще собираетесь запускать?» — проверка подписи релиза без установки
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --verify-only
|
||||
# план установки без изменений
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --dry-run --domain vpn.example.com
|
||||
|
||||
# то же, но без защиты (--no-harden) и с добавлением второго порта SSH
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --sni dl.google.com --no-harden --ssh-port 2222
|
||||
|
||||
# только установщик автора, без наших дополнений
|
||||
bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui.sh)
|
||||
|
||||
# обновление уже стоящего сервера (тянет наш релиз и проверяет нашу подпись)
|
||||
kit update --force && kit check
|
||||
```
|
||||
|
||||
Внутри `kit` источником файлов служит `KIT_RAW`/`kit_ref_raw()` — они указывают на этот
|
||||
репозиторий, а подпись релиза проверяется ключом из `KIT_SIGNERS` (только ключ форка).
|
||||
|
||||
## Подпись релизов
|
||||
|
||||
- Открытая часть ключа — `tools/fork-signer.pub` и в `KIT_SIGNERS` (в `scripts/kit.sh`).
|
||||
- Закрытая часть — только на рабочей станции владельца (`~/.ssh/3x-ui-kit-fork/release_key`),
|
||||
с паролем; в репозиторий, CI и на серверы не попадает. Кто получит ключ — получит root на всех
|
||||
серверах, которые обновляются из этого репозитория.
|
||||
- Смена ключа: подпиши новый релиз старым ключом, иначе серверы его не примут.
|
||||
|
||||
## Наши команды
|
||||
|
||||
```bash
|
||||
kit local help # справка по дополнениям форка
|
||||
kit local version # версия, источник обновлений, дата сборки нашего файла
|
||||
kit local channel # откуда берутся обновления и чем проверяется подпись
|
||||
kit local seams # какие швы форка сейчас в установленном /usr/local/bin/kit
|
||||
kit local masking # состояние маскировки
|
||||
kit local hardening # сводка по защите сервера
|
||||
|
||||
# защита сервера (поведение из StressOzz/vps-setup, сделано безопаснее)
|
||||
kit local ssh 2222 # добавить порт SSH (прежние продолжают работать)
|
||||
kit local ssh --close-old 2222 # закрыть прежние порты после проверки
|
||||
kit local ping off # не отвечать на ping (правилом ufw)
|
||||
kit local upgrades on # автообновления безопасности
|
||||
kit local passwd --generate # сменить пароль root и показать его один раз
|
||||
```
|
||||
|
||||
У любой правки можно добавить `--dry-run` — покажет план и ничего не изменит.
|
||||
|
||||
## Грабли, о которых стоит помнить
|
||||
|
||||
1. **Ночное автообновление** (`kit-update.timer`, 03:00) перезаписывает `/usr/local/bin/kit`
|
||||
подписанным релизом из настроенного репозитория. Поэтому канал обновления и швы — один шаг:
|
||||
сервер должен смотреть в этот репозиторий, иначе наши правки исчезнут ночью.
|
||||
2. **Переводы строк.** Релиз подписывается по контрольным суммам файлов в рабочей копии, а сервер
|
||||
сверяет их с файлами из Gitea. При `core.autocrlf=true` суммы не сойдутся (CRLF против LF):
|
||||
в этом клоне должно быть `git config core.autocrlf false`.
|
||||
3. **Наш файл обязан быть в списке релиза** (`tools/release.sh`, `FILES=(…)`) и в списке загрузки
|
||||
`fetch_release()` — иначе при обновлении `kit.sh` приедет новый, а `kit-local.sh` останется старым.
|
||||
4. **Панель 3X-UI ставится с GitHub** по закреплённой версии и SHA256 — это шаг автора, форк его не
|
||||
трогает. Для установки с нуля серверу нужен доступ к GitHub.
|
||||
@@ -4,28 +4,28 @@
|
||||
|
||||
**Установка 3X-UI одной командой: 11 протоколов, один порт 443, одна подписка на всё**
|
||||
|
||||
[](https://github.com/itsnotkubrick/3X-UI_KIT/releases/latest)
|
||||
[](manuals/3x-ui.md#протоколы)
|
||||
[](https://github.com/MHSanaei/3x-ui)
|
||||
[](https://github.com/MHSanaei/3x-ui)
|
||||
[](https://github.com/itsnotkubrick/3X-UI_KIT/stargazers)
|
||||
[](https://github.com/itsnotkubrick/3X-UI_KIT/commits)
|
||||
|
||||
[Возможности](#возможности) · [Установка](#установка) · [Команды](#команды-kit) · [Роутер](#vpn-на-роутере) · [Генератор](#генератор-конфигов-для-роутера) · [Полезное](#полезное) · [Поддержать](#поддержать-проект)
|
||||
|
||||
</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]
|
||||
> [!WARNING]
|
||||
> **Проект – набор скриптов с открытым кодом.** Он не оказывает услуг связи и не предоставляет доступ
|
||||
> к серверам. Вы ставите его на собственный сервер и сами отвечаете за соблюдение законодательства
|
||||
> своей страны.
|
||||
> своей страны. Проект создан в образовательных целях.
|
||||
|
||||
> [!TIP]
|
||||
> **Вышла версия 1.2:** панель 3X-UI v3.9.0, меню `kit`, раздельная маршрутизация и `--restore`.
|
||||
> [Что нового](https://github.com/itsnotkubrick/3X-UI_KIT/releases/tag/v1.2) ·
|
||||
> сервер на 1.1.x обновится сам этой ночью, а на 1.0 одной командой:
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.2/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update
|
||||
> ```
|
||||
|
||||
**3X-UI KIT** превращает чистый VPS в готовый VPN-сервер за несколько минут. Скрипт ставит
|
||||
официальную панель [3X-UI](https://github.com/MHSanaei/3x-ui), настраивает все популярные
|
||||
@@ -38,20 +38,22 @@
|
||||
|
||||
## Возможности
|
||||
|
||||
- 🧩 **11 протоколов сразу** – VLESS REALITY, XHTTP, WebSocket, Trojan gRPC, VMess,
|
||||
Shadowsocks 2022, Hysteria2, TUIC, AmneziaWG (классика и 3.1) и MTProto для Telegram.
|
||||
- 🚪 **Всё TCP – через порт 443.** Панель, подписка и протоколы спрятаны за одним портом,
|
||||
а на случайный заход сервер показывает обычный сайт.
|
||||
- 🧩 **11 протоколов на одном порту 443.** Панель, подписка и все TCP-протоколы спрятаны за одним портом,
|
||||
а на случайный заход сервер показывает обычный сайт. Протоколы: VLESS REALITY, VLESS XHTTP + REALITY,
|
||||
VLESS WebSocket + TLS, Trojan gRPC + TLS, VMess WebSocket + TLS, Shadowsocks 2022, Hysteria2, TUIC v5,
|
||||
AmneziaWG, AmneziaWG 3.1, MTProto (прокси для Telegram).
|
||||
- 🔗 **Одна подписка на все приложения.** Hiddify, Happ, v2rayN, Karing, Clash Verge и FlClash
|
||||
получают свой формат и только те протоколы, которые умеют.
|
||||
- 👥 **Дополнительные пользователи одной командой** – `kit user add` добавляет пользователя
|
||||
сразу во все протоколы с общим лимитом трафика, сроком и числом устройств.
|
||||
- 🔄 **Обновляется сам, но только подписанными релизами**, а `kit backup` сохраняет
|
||||
копию сервера с пользователями и ключами.
|
||||
- 🔒 **Сертификат Let's Encrypt на IP** выпускается и продлевается сам, панель скрыта на
|
||||
случайном пути со случайными логином и паролем.
|
||||
- ✅ **Проверено настоящими клиентами** – каждый протокол на ядрах Xray, Mihomo и sing-box,
|
||||
в том числе через интернет к удалённому серверу: [tests/matrix](tests/matrix/).
|
||||
получают свой формат и только те протоколы, которые распознают.
|
||||
- 🧭 **Раздельная маршрутизация.** Через VPN идут только зарубежные сервисы, остальное – напрямую.
|
||||
Правила приезжают вместе с подпиской, DNS не течёт.
|
||||
- 🥷 **Маскировка.** Со своим доменом на сервере стоит ваш сайт, без него сайт подбирается среди соседей
|
||||
по подсети. REALITY работает с `xtls-rprx-vision`.
|
||||
- 👥 **Пользователи во все протоколы сразу.** Один пользователь – одна подписка, общий лимит трафика,
|
||||
срок и число устройств. Меню `kit` работает как у x-ui: выбрали цифру – готово.
|
||||
- 💾 **Резервная копия и переезд на новый сервер одной командой.** Копия сохраняет подключения, ключи
|
||||
и пользователей, а на чистом VPS (даже с другим IP) сервер поднимается из неё за несколько минут.
|
||||
- 🔄 **Обновляется само, но только подписанными релизами.**
|
||||
- 🩺 **Проверка и починка сервера** одной командой.
|
||||
|
||||
## Что понадобится
|
||||
|
||||
@@ -63,43 +65,91 @@
|
||||
|
||||
## Установка
|
||||
|
||||
Подключитесь к серверу по SSH и выполните:
|
||||
Подключитесь к серверу по SSH и выполните одну команду на чистом VPS.
|
||||
|
||||
Панель 3X-UI, все протоколы на порту 443 и одна подписка на всё:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh)
|
||||
```
|
||||
|
||||
Через пару минут скрипт покажет адрес панели, логин, пароль и подписку с QR-кодом.
|
||||
Подробно – подключение приложений, дополнительные пользователи и параметры – в **[инструкции](manuals/3x-ui.md)**.
|
||||
|
||||
Подробно – подключение приложений, дополнительные пользователи и параметры –
|
||||
в **[инструкции](manuals/3x-ui.md)**. Нужен только Hysteria2 – есть
|
||||
[отдельный скрипт](manuals/hysteria2.md).
|
||||
## Команды KIT
|
||||
|
||||
> [!WARNING]
|
||||
> Проект создан в образовательных целях. Убедитесь, что ваши действия
|
||||
> соответствуют законодательству вашей страны.
|
||||
После установки на сервере появляется команда `kit`. Запустите её **без аргументов** – откроется меню, как у `x-ui`: выберите цифру, остальное спросит само. Те же действия одной строкой – в группах ниже (нажмите, чтобы раскрыть). Подробности – в [инструкции](manuals/3x-ui.md).
|
||||
|
||||
## Команды kit
|
||||
<details>
|
||||
<summary><b>Пользователи</b> – <code>kit user</code></summary>
|
||||
|
||||
После установки на сервере появляется команда `kit`. Подробности – в [инструкции](manuals/3x-ui.md).
|
||||
```bash
|
||||
kit user add имя [--gb 50] [--days 30] [--devices 3] # добавить во все протоколы, показать подписку
|
||||
kit user list # кто сколько израсходовал, до какого числа
|
||||
kit user link имя [--all | --amnezia | --telegram] # подписка и ссылки «открыть в приложении»
|
||||
kit user limit имя [--gb N] [--days N] [--devices N] # изменить лимиты (0 – без ограничений)
|
||||
kit user off имя / kit user on имя # выключить и включить
|
||||
kit user del имя # удалить
|
||||
```
|
||||
</details>
|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `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`, панели и ядра, состояние автообновления |
|
||||
<details>
|
||||
<summary><b>Сеть и маскировка</b> – <code>kit net</code></summary>
|
||||
|
||||
Для отдельного сервера 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`.
|
||||
```bash
|
||||
kit net # протоколы, порты, сайт маскировки, отпечаток – одним экраном
|
||||
kit net site [сайт.com] # сменить сайт маскировки (без аргумента – среди соседей по подсети)
|
||||
kit net port имя порт # сменить порт протокола
|
||||
kit net off имя / kit net on имя # выключить и включить протокол (kit net on reality2 – второй REALITY)
|
||||
kit net fp firefox # сменить отпечаток клиента у всех подключений
|
||||
kit net masq on|off # Hysteria2 отвечает на чужой запрос страницей сайта
|
||||
kit net dns on|off # DNS в подписке по DoH через прокси
|
||||
kit net panel domain | ip # панель и подписка по своему домену или по IP
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Раздельная маршрутизация</b> – <code>kit net split</code></summary>
|
||||
|
||||
Через VPN идёт только список зарубежных сервисов, остальное – напрямую. Подробнее – [в инструкции](manuals/3x-ui.md#раздельная-маршрутизация-kit-net-split).
|
||||
|
||||
```bash
|
||||
kit net split on # создать список и включить
|
||||
kit net split check # проверить список
|
||||
kit net split apply # после правки списка обновить профили Happ и Xray
|
||||
kit net split off # выключить
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Обслуживание сервера</b> – проверка, обновление, копия</summary>
|
||||
|
||||
```bash
|
||||
kit check [--deep] [--fix] # проверить сервер; --deep – подключиться клиентом, --fix – исправить безопасное
|
||||
kit update [--panel] # обновить сейчас; --panel – панель 3X-UI до проверенной версии
|
||||
kit update --auto # ночное автообновление включить (--manual – выключить)
|
||||
kit backup # копия сервера: подключения, ключи, пользователи
|
||||
kit version # версии KIT, панели и ядра
|
||||
```
|
||||
|
||||
Копию из `kit backup` на новом чистом сервере поднимает `bash 3x-ui.sh --restore файл`. Если подписка выдавалась
|
||||
на домене, а не на IP, клиентам достаточно сменить адрес в DNS.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Если не подключается</b> – диагностика</summary>
|
||||
|
||||
```bash
|
||||
kit check --deep # проверить сервер и подключиться к нему клиентом: видно, виноват сервер или сеть
|
||||
kit net fp firefox # другой отпечаток клиента (вернуть: kit net fp chrome)
|
||||
kit net site # другой сайт для маскировки
|
||||
kit net on reality2 # дополнительный REALITY на другом порту
|
||||
kit backup # не помогло – копия, затем перенос на другой адрес: на новом сервере bash 3x-ui.sh --restore файл
|
||||
```
|
||||
|
||||
Один протокол не подключается – попробуйте соседний: у каждого свои сильные стороны (Hysteria2 работает по UDP,
|
||||
остальные по TCP). В подписке Clash «Авто» выбирает сама, в остальных приложениях – вручную.
|
||||
</details>
|
||||
|
||||
## VPN на роутере
|
||||
|
||||
@@ -108,14 +158,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main
|
||||
**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)
|
||||
>
|
||||
> Вопросы по самой программе задавайте в её репозитории: так автор узнаёт об ошибках. Если что-то не так
|
||||
> с моей инструкцией или генератором, пишите мне.
|
||||
> На роутере работает [XKeen](https://github.com/jameszeroX/XKeen) автора jameszeroX, спасибо ему.
|
||||
|
||||
<details>
|
||||
<summary><b>📶 Keenetic: XKeen (jameszeroX) · 8 шагов, 20–30 минут</b></summary>
|
||||
@@ -251,7 +294,7 @@ sh -c "$(curl -sSL https://raw.githubusercontent.com/jameszeroX/XKeen/main/insta
|
||||
|
||||
| Способ | |
|
||||
|---|---|
|
||||
| Российской картой, СБП, Tinkoff Pay | [CloudTips](https://pay.cloudtips.ru/p/d4f9e3d1) |
|
||||
| Российской картой, СБП, Tinkoff Pay | [Tribute](https://t.me/tribute/app?startapp=dRsY) |
|
||||
| Зарубежной картой, Apple Pay, Google Pay | [Buy Me a Coffee](https://buymeacoffee.com/relo.cate) |
|
||||
| USDT (TRC-20) | `TS83ViXrdezUpp1eFadqj1rBhGLZaba1c1` |
|
||||
| TON | `UQBchO4XFPwF9MMa_tjXpwqTo8IL2FhUDyllhYuFo8WM-Qbf` |
|
||||
|
||||
+8
-6
@@ -1,6 +1,8 @@
|
||||
# 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.4
|
||||
567e5326890669659bfab769a5ddb2a2863eb51493a975e16c84df6973ec5170 *scripts/3x-ui.sh
|
||||
62fc23d886f91bb59ba47c4fb932d8fe49680271476c94e61238a0dde3002cd9 *scripts/hysteria2.sh
|
||||
f63388be2e61a4fc546b78b3d8c640179f7ce41ac30bf4a329efb1b3e97faa5b *scripts/kit.sh
|
||||
eac3e47491ce86487f05160952ff5b4ad8a3821954f082eee3516e40575fb574 *scripts/kit-sub.py
|
||||
30579dfae24239d1c797dc8efac5b2b49116bc05058923df184b382f33fe265b *scripts/kit-local.sh
|
||||
e97d3ac23f97d95fcc3d04b5e6751b28a4425ccb1bcb6c5a252bb9a9e52b1ade *scripts/3x-ui-local.sh
|
||||
563a0b4eec3c77bbc34ac630d4fa5214f5c6726ae01468404fd704473110084e *VERSION
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
-----BEGIN SSH SIGNATURE-----
|
||||
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgatMYIOGGebxc7V6Sk8m5JZFUIm
|
||||
IWK6kSyl9DFxsQGbMAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
|
||||
AAALc3NoLWVkMjU1MTkAAABATD/Ch1JeEtozPZPAEhOWTqDWKQbgUw6S23lqC2w1mPtR2E
|
||||
wOCNaNILU48ONBz75i/kIRipFhRTggXuVCLf62Cg==
|
||||
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV
|
||||
FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
|
||||
AAALc3NoLWVkMjU1MTkAAABA3dMDUsb5LWkr0YJ5X/rCfLxNTuwzHymGpDwOd+XIRWsnYY
|
||||
eA60VFXtz5uCxAdiyKXXgg7Prs6nCq819EYCU2Ag==
|
||||
-----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)
|
||||
|
||||
@@ -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,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# 3X-UI KIT (форк hacker07) — установка сервера одной командой из нашего канала.
|
||||
#
|
||||
# Что делает (по шагам):
|
||||
# 1) проверяет, что мы на чистом Debian/Ubuntu и под root, и что есть curl, ssh-keygen, openssl;
|
||||
# 2) берёт из нашего Gitea релиз своей же версии, проверяет ПОДПИСЬ SHA256SUMS и SHA256 установщика;
|
||||
# 3) запускает установщик автора (scripts/3x-ui.sh) — панель 3X-UI, протоколы, сайт-заглушка,
|
||||
# маскировка, сертификат, подписка, ufw; все обычные флаги установщика можно передавать как есть;
|
||||
# 4) досыпает то, чего у автора нет (поведение из StressOzz/vps-setup, но безопаснее):
|
||||
# ответ на ping закрывается правилом ufw, включаются обновления безопасности;
|
||||
# 5) при желании добавляет второй порт SSH (--ssh-port 2222) — прежний остаётся рабочим;
|
||||
# 6) в конце: kit fix (время и безопасные починки), kit check, kit local hardening.
|
||||
#
|
||||
# Запуск (примеры):
|
||||
# bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/tag/v1.2.3/scripts/3x-ui-local.sh) --domain vpn.example.com
|
||||
# ... 3x-ui-local.sh --sni dl.google.com --harden
|
||||
# ... 3x-ui-local.sh --ssh-port 2222 --no-harden
|
||||
#
|
||||
# Флаги самого обёрточного скрипта (остальные уходят установщику автора):
|
||||
# --no-harden не отключать ping и не включать автообновления безопасности
|
||||
# --ssh-port N добавить порт SSH N (прежний порт продолжает работать)
|
||||
# --no-time не запускать kit fix (синхронизация времени и безопасные починки)
|
||||
# --no-check не запускать в конце kit check
|
||||
# --dry-run показать план и ничего не делать
|
||||
# -h | --help эта справка
|
||||
#
|
||||
# Наш файл: авторские релизы его не перезаписывают, а правки форка живут в scripts/kit-local.sh.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
KIT_VERSION="1.2.4" # версия сборки форка; должна совпадать с 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 KIT 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 KIT (форк hacker07) — установка сервера одной командой из нашего канала.
|
||||
|
||||
bash <(curl -fsSL <наш Gitea>/raw/tag/v<версия>/scripts/3x-ui-local.sh) [флаги…]
|
||||
|
||||
Всё, что скрипт не знает сам, он передаёт установщику автора как есть:
|
||||
--domain vpn.example.com свой домен для маскировки (A-запись на этот сервер, свободный 80)
|
||||
--sni сайт.com маскировка под чужой сайт
|
||||
--panel-on domain | ip где открывать панель и подписку
|
||||
--host IP, --user имя, --port N, --protocols …, --restore файл
|
||||
|
||||
Флаги этого скрипта:
|
||||
--no-harden не отключать ответ на ping и не включать автообновления безопасности
|
||||
--ssh-port N добавить порт SSH N (прежний порт продолжает работать)
|
||||
--no-time не запускать kit fix (время и безопасные починки)
|
||||
--no-check не запускать в конце kit check
|
||||
--verify-only только проверить релиз (подпись и SHA256) и выйти
|
||||
--dry-run показать план и ничего не делать
|
||||
-h | --help эта справка
|
||||
|
||||
Что скрипт делает: проверяет сервер → скачивает релиз своей же версии из нашего Gitea,
|
||||
проверяет подпись SHA256SUMS и SHA256 установщика → запускает установщик автора → досыпает
|
||||
защиту (ping off правилом ufw, автообновления безопасности, при желании второй порт SSH) →
|
||||
kit fix и kit check.
|
||||
EOF
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ разбор флагов
|
||||
|
||||
INSTALLER_ARGS=(); harden=yes; ssh_port=""; fix_time=yes; do_check=yes; dry=no; verify_only=no
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--no-harden) harden=no ;;
|
||||
--ssh-port) ssh_port=${2:-}; shift || die "--ssh-port: нужен номер порта" ;;
|
||||
--ssh-port=*) ssh_port=${1#*=} ;;
|
||||
--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}$ ]] || die "--ssh-port: нужен номер порта, например 2222"
|
||||
|
||||
pre() { printf ' %s\n' "$*"; }
|
||||
|
||||
if [[ $dry == yes ]]; then
|
||||
say "план установки (--dry-run: ничего не делаю)"
|
||||
pre "версия сборки: $KIT_VERSION"
|
||||
pre "откуда: $GITEA (тег v$KIT_VERSION), подпись проверяется ключом форка"
|
||||
if ((${#INSTALLER_ARGS[@]})); then pre "установщик автора: bash scripts/3x-ui.sh ${INSTALLER_ARGS[*]}"; else pre "установщик автора: bash scripts/3x-ui.sh (без доп. флагов)"; fi
|
||||
pre "маскировка: своя заглушка/домен — как решит установщик автора"
|
||||
if [[ $harden == yes ]]; then pre "защита: ping off (правилом ufw) + автообновления безопасности"; else pre "защита: пропускаю (--no-harden)"; fi
|
||||
[[ -n $ssh_port ]] && pre "порт SSH: добавляю $ssh_port (прежний остаётся рабочим)"
|
||||
[[ $fix_time == yes ]] && pre "после: kit fix (время, безопасные починки)"
|
||||
[[ $do_check == yes ]] && pre "проверка: kit check + kit local hardening"
|
||||
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 scripts/3x-ui.sh; 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 '$2 == "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 установщика не совпал с подписанным — не запускаю"
|
||||
bash -n "$tmp/scripts/3x-ui.sh" || die "установщик не проходит проверку синтаксиса"
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------ установка
|
||||
|
||||
say "запускаю установщик автора (панель 3X-UI, протоколы, сайт-заглушка, подписка)"
|
||||
if ((${#INSTALLER_ARGS[@]})); then printf ' флаги: %s\n' "${INSTALLER_ARGS[*]}"; fi
|
||||
# stdin: если есть терминал — отдаём его установщику (там есть вопросы), иначе /dev/null,
|
||||
# иначе установщик начнёт читать наш собственный скрипт вместо ответов.
|
||||
if [[ -t 0 ]] || { : </dev/tty; } 2>/dev/null; then
|
||||
bash "$tmp/scripts/3x-ui.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"} </dev/tty || die "установщик остановился с ошибкой — он ничего не тронул дальше своего лога"
|
||||
else
|
||||
bash "$tmp/scripts/3x-ui.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"} </dev/null || die "установщик остановился с ошибкой (нет терминала — вопросы задать некому)"
|
||||
fi
|
||||
command -v kit >/dev/null || die "после установки нет команды kit — что-то пошло не так, смотрите вывод выше"
|
||||
|
||||
# ------------------------------------------------------------------ наши дополнения
|
||||
|
||||
if [[ $fix_time == yes ]]; then
|
||||
say "kit fix: синхронизация времени и безопасные починки (авторская команда, ничего необратимого)"
|
||||
kit fix || warn "kit fix завершился с замечаниями — посмотрите вывод"
|
||||
fi
|
||||
|
||||
if [[ $harden == yes ]]; then
|
||||
say "защита сервера: закрываю ответ на ping и включаю обновления безопасности"
|
||||
kit local ping off || warn "не удалось закрыть ping — повторите: kit local ping off"
|
||||
kit local upgrades on || warn "не удалось включить автообновления — повторите: kit local upgrades on"
|
||||
fi
|
||||
|
||||
if [[ -n $ssh_port ]]; then
|
||||
say "добавляю порт SSH $ssh_port (прежний порт продолжает работать)"
|
||||
kit local ssh "$ssh_port" || warn "порт не добавился — сделайте это отдельно: kit local ssh $ssh_port"
|
||||
cat <<EOF
|
||||
|
||||
${Y}Дальше обязательно:${N}
|
||||
проверьте вход во ВТОРОМ окне: ssh -p $ssh_port root@<IP сервера>
|
||||
убедились — закройте прежние порты: kit local ssh --close-old $ssh_port
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------ проверка
|
||||
|
||||
if [[ $do_check == yes ]]; then
|
||||
say "проверяю, что получилось"
|
||||
kit check || warn "kit check нашёл замечания — читайте выше; безопасные починки: kit fix"
|
||||
kit local hardening
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
${G}${B}Готово.${N} Сервер поставлен из нашей сборки (3X-UI KIT $KIT_VERSION, канал: наш Gitea).
|
||||
|
||||
подписка и ссылки: kit user link admin (или страница подписки из вывода установщика)
|
||||
если что-то не так: kit check --deep, затем kit fix
|
||||
вернуться к авторскому каналу: положить авторский kit поверх и выполнить kit update --force
|
||||
EOF
|
||||
+914
-142
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 KIT (scripts/3x-ui.sh). Файл оставлен, чтобы старая команда `hy2 update`
|
||||
# получила из подписанного релиза понятное сообщение. Ничего не ставит, не удаляет и не
|
||||
# останавливает: сервер Hysteria2, установленный раньше, продолжает работать как был.
|
||||
# Код выхода 1 при любом запуске (и при `__update_binary`) – старый `hy2 update` на нём
|
||||
# останавливается и не заменяет команду hy2.
|
||||
|
||||
set -Eeuo pipefail
|
||||
KIT_VERSION="1.2.4"
|
||||
|
||||
# На свежем 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 KIT (один из 11 протоколов).
|
||||
Если нужен только Hysteria2 без панели – официальный установщик:
|
||||
bash <(curl -fsSL https://get.hy2.sh/)
|
||||
MSG
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
#!/usr/bin/env bash
|
||||
# 3X-UI_KIT (форк hacker07) — локальные дополнения.
|
||||
#
|
||||
# Этот файл наш: автор его не знает и не правит, поэтому обновления автора его не ломают.
|
||||
# kit подключает его одной строкой из kit.sh (шов в tools/fork_apply.py) и передаёт сюда
|
||||
# аргументы командной строки. Всё, что мы дописываем к kit, живёт ЗДЕСЬ.
|
||||
#
|
||||
# Команды: kit local <команда> (справка: kit local help)
|
||||
#
|
||||
# Часть команд повторяет поведение проекта StressOzz/vps-setup (добавить порт SSH, отключить
|
||||
# ответ на ping, сменить пароль root). Там нет файла лицензии, поэтому код свой, а поведение
|
||||
# сделано безопаснее, чем в источнике:
|
||||
# * конфиг sshd проверяется (`sshd -t`) ДО перезапуска, новый порт сначала открывается в ufw;
|
||||
# * прежний порт продолжает работать, пока новый не проверен (`--close-old` — отдельный шаг);
|
||||
# * у каждой правки есть --dry-run и копия затронутого файла в /root/kit-local-backup-<дата>/;
|
||||
# * apt full-upgrade из источника НЕ выполняется (он тянет систему вперёд и может сломать
|
||||
# закреплённые версии панели и ядра) — вместо него автообновления безопасности.
|
||||
#
|
||||
# Правила работы с файлом: только функции, никакого кода на верхнем уровне (он выполняется при
|
||||
# каждом запуске kit), и никаких зависимостей от функций автора: наш вызов идёт в самом начале
|
||||
# kit.sh, когда авторские say/warn ещё не объявлены.
|
||||
|
||||
KL_BACKUP_DIR="/root/kit-local-backup-$(date +%Y%m%d)"
|
||||
KL_SSHD_CONF="/etc/ssh/sshd_config"
|
||||
KL_SSHD_DROPIN="/etc/ssh/sshd_config.d/99-kit-local-port.conf"
|
||||
KL_SOCKET_DROPIN="/etc/systemd/system/ssh.socket.d/99-kit-local-port.conf"
|
||||
KL_UFW_RULES=/etc/ufw/before.rules
|
||||
KL_UFW6_RULES=/etc/ufw/before6.rules
|
||||
KL_SYSCTL_DROPIN=/etc/sysctl.d/99-kit-local-icmp.conf
|
||||
|
||||
kl_say() { printf '\033[1m==>\033[0m %s\n' "$*"; }
|
||||
kl_ok() { printf ' ✓ %s\n' "$*"; }
|
||||
kl_warn() { printf ' ! %s\n' "$*" >&2; }
|
||||
kl_step() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
kl_die() { printf '✗ %s\n' "$*" >&2; return 1; }
|
||||
kl_have() { command -v "$1" >/dev/null 2>&1; }
|
||||
kl_dry() { [[ ${KL_DRY:-no} == yes ]]; }
|
||||
kl_run() { if kl_dry; then printf ' (dry-run) %s\n' "$*"; return 0; fi; "$@"; }
|
||||
|
||||
# Разбор аргументов: --dry-run поднимаем в KL_DRY, остальное складываем в KL_REST.
|
||||
kl_args() {
|
||||
KL_DRY=no; KL_REST=()
|
||||
local a
|
||||
for a in "$@"; do
|
||||
case $a in
|
||||
--dry-run) KL_DRY=yes ;;
|
||||
*) KL_REST+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
kl_backup() { # файл → копия рядом с релизом, не в /tmp (там её переживёт только до перезагрузки)
|
||||
[[ -e $1 ]] || return 0
|
||||
local dst="$KL_BACKUP_DIR/$(printf '%s' "$1" | tr '/' '_')"
|
||||
if kl_dry; then printf ' (dry-run) сохранил бы %s → %s\n' "$1" "$dst"; return 0; fi
|
||||
install -d -m 700 "$KL_BACKUP_DIR"
|
||||
cp -p "$1" "$dst" && printf ' ✓ копия: %s\n' "$dst"
|
||||
return 0
|
||||
}
|
||||
|
||||
kl_need_root() { [[ ${EUID:-0} -eq 0 ]] || kl_die "нужен root"; }
|
||||
|
||||
# ------------------------------------------------------------------ справка
|
||||
|
||||
kit_local_help() {
|
||||
cat <<'EOF'
|
||||
3X-UI KIT — дополнения форка (hacker07):
|
||||
|
||||
Состояние
|
||||
kit local эта справка
|
||||
kit local version версия kit, источник обновлений, дата сборки нашего файла
|
||||
kit local channel откуда берутся обновления и чем проверяется подпись
|
||||
kit local seams какие места форк правит в файлах автора
|
||||
kit local masking состояние маскировки (у REALITY должен быть свой домен)
|
||||
kit local hardening сводка по защите сервера и что ещё можно включить
|
||||
|
||||
Защита сервера (поведение взято из StressOzz/vps-setup, сделано безопаснее)
|
||||
kit local ssh 2222 добавить порт SSH (прежние продолжают работать)
|
||||
kit local ssh --status какие порты SSH слушаются сейчас
|
||||
kit local ssh --close-old 2222 закрыть прежние порты после проверки нового
|
||||
kit local ping off|on отключить/вернуть ответ на ICMP echo (ping) через ufw
|
||||
kit local ping status состояние ответа на ping
|
||||
kit local passwd [юзер] сменить пароль (спросит дважды, в логи не попадает)
|
||||
kit local passwd --generate сгенерировать пароль и показать один раз
|
||||
kit local upgrades on|off|status автообновления безопасности
|
||||
|
||||
У любой правки можно добавить --dry-run: покажет план и ничего не изменит.
|
||||
Копии изменённых файлов — в /root/kit-local-backup-<дата>/.
|
||||
|
||||
Оговорки:
|
||||
* прежний порт SSH сам не закрывается: сначала проверьте новый из отдельного окна;
|
||||
* ping отключаем правилом ufw (видно в `ufw status`), а не общесистемным sysctl: отменяется
|
||||
одной командой и не глушит диагностику для внутренних интерфейсов;
|
||||
* apt full-upgrade из источника не делаем: вместо него — обновления безопасности.
|
||||
EOF
|
||||
}
|
||||
|
||||
kit_local_version() {
|
||||
printf 'kit: %s (форк hacker07)\n' "${KIT_VERSION:-?}"
|
||||
printf 'источник: %s\n' "${KIT_RAW:-?}"
|
||||
local lib=${KIT_LOCAL_LIB:-/usr/local/lib/kit/kit-local.sh}
|
||||
printf 'наш файл: %s\n' "$lib"
|
||||
[[ -r $lib ]] && printf 'собран: %s\n' "$(date -r "$lib" '+%Y-%m-%d %H:%M' 2>/dev/null || echo '?')"
|
||||
local latest
|
||||
latest=$(cat /etc/kit/latest-version 2>/dev/null || true)
|
||||
[[ -n $latest ]] && printf 'последняя: %s\n' "$latest"
|
||||
return 0
|
||||
}
|
||||
|
||||
kit_local_channel() {
|
||||
printf 'файлы релиза: %s\n' "${KIT_RAW:-?}"
|
||||
printf 'подпись: id=%s namespace=%s\n' "${KIT_SIG_ID:-?}" "${KIT_SIG_NS:-?}"
|
||||
local n=0 k
|
||||
for k in "${KIT_SIGNERS[@]:-}"; do [[ -n $k ]] && n=$((n + 1)); done
|
||||
printf 'ключей в KIT_SIGNERS: %d (должен быть только ключ форка)\n' "$n"
|
||||
if [[ $n -eq 0 ]]; then
|
||||
printf 'ВНИМАНИЕ: список ключей пуст — обновление не пройдёт проверку подписи.\n'
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
kit_local_seams() {
|
||||
local bin=/usr/local/bin/kit
|
||||
[[ -r $bin ]] || { printf 'нет %s\n' "$bin"; return 1; }
|
||||
grep -n '3X-UI_KIT (форк hacker07)' "$bin" 2>/dev/null \
|
||||
|| printf 'метки форка в %s не найдены — сборка не наша?\n' "$bin"
|
||||
return 0
|
||||
}
|
||||
|
||||
kit_local_masking() {
|
||||
kl_have kit || { printf 'нет команды kit\n'; return 1; }
|
||||
kit net site 2>/dev/null || printf 'посмотреть: kit net site\n'
|
||||
return 0
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ SSH: порты
|
||||
|
||||
kl_ssh_ports() {
|
||||
{ sshd -T 2>/dev/null | awk '$1 == "port" {print $2}'
|
||||
kl_have ss && ss -H -ltnp 2>/dev/null | awk '/sshd/ {n = split($4, a, ":"); print a[n]}'
|
||||
[[ -n ${SSH_CONNECTION:-} ]] && awk '{print $4}' <<<"$SSH_CONNECTION"
|
||||
[[ -n ${SSH_CLIENT:-} ]] && awk '{print $2}' <<<"$SSH_CLIENT"
|
||||
} 2>/dev/null | grep -E '^[0-9]+$' | sort -un | tr '\n' ' '
|
||||
}
|
||||
|
||||
kl_ssh_socket_active() { kl_have systemctl && systemctl is-active --quiet ssh.socket 2>/dev/null; }
|
||||
|
||||
kl_ufw_active() { kl_have ufw && ufw status 2>/dev/null | grep -q '^Status: active'; }
|
||||
|
||||
kl_ssh_status() {
|
||||
local ports; ports=$(kl_ssh_ports)
|
||||
printf 'порты SSH сейчас: %s\n' "${ports:-не удалось определить}"
|
||||
if kl_ssh_socket_active; then
|
||||
printf 'слушает: systemd (ssh.socket — так в Ubuntu 24.04)\n'
|
||||
[[ -f $KL_SOCKET_DROPIN ]] && grep -H 'ListenStream' "$KL_SOCKET_DROPIN"
|
||||
else
|
||||
printf 'слушает: sshd по %s\n' "$KL_SSHD_CONF"
|
||||
[[ -f $KL_SSHD_DROPIN ]] && grep -H '^Port' "$KL_SSHD_DROPIN"
|
||||
fi
|
||||
kl_ufw_active && printf 'ufw: %s\n' "$(ufw status 2>/dev/null | awk '$2 == "ALLOW" && $1 ~ /\/tcp$/ {printf "%s ", $1}')"
|
||||
return 0
|
||||
}
|
||||
|
||||
kit_local_ssh() {
|
||||
kl_args "$@"
|
||||
local arg=${KL_REST[0]:-} second=${KL_REST[1]:-} close_old=no new
|
||||
[[ $arg == --close-old ]] && { close_old=yes; new=$second; }
|
||||
[[ $second == --close-old ]] && close_old=yes
|
||||
|
||||
if [[ -z $arg || $arg == --status ]]; then kl_ssh_status; return 0; fi
|
||||
kl_need_root || return 1
|
||||
[[ $close_old == no ]] && new=$arg
|
||||
[[ $new =~ ^[0-9]+$ ]] && ((new >= 1 && new <= 65535)) \
|
||||
|| { kl_die "порт — число 1..65535, например: kit local ssh 2222"; return 1; }
|
||||
|
||||
local cur; cur=$(kl_ssh_ports)
|
||||
|
||||
if [[ $close_old == yes ]]; then
|
||||
kl_say "закрываю прежние порты SSH, оставляю только $new"
|
||||
printf ' было: %s\n' "${cur:-?}"
|
||||
if [[ -z $cur ]]; then
|
||||
kl_warn "не удалось определить текущие порты SSH — закройте прежний порт вручную (файл sshd или ssh.socket)"
|
||||
return 1
|
||||
fi
|
||||
if kl_ssh_socket_active; then
|
||||
cat <<EOF
|
||||
! SSH слушает systemd (ssh.socket): закрытие старых портов делается правкой
|
||||
$KL_SOCKET_DROPIN — первой строкой там должен идти «ListenStream=» (сброс списка),
|
||||
затем «ListenStream=$new», после чего: systemctl daemon-reload && systemctl restart ssh.socket.
|
||||
Автоматически не делаю: ошибка здесь запирает доступ к серверу.
|
||||
EOF
|
||||
return 0
|
||||
fi
|
||||
local p
|
||||
for p in $cur; do
|
||||
[[ $p == "$new" ]] && continue
|
||||
kl_backup "$KL_SSHD_CONF"
|
||||
if kl_dry; then
|
||||
printf ' (dry-run) закрыл бы порт %s в %s и снял правило ufw %s/tcp\n' "$p" "$KL_SSHD_CONF" "$p"
|
||||
continue
|
||||
fi
|
||||
sed -i -E "s|^[[:space:]]*Port[[:space:]]+$p[[:space:]]*$|# Port $p (закрыт kit local $(date +%F))|" "$KL_SSHD_CONF"
|
||||
kl_ufw_active && ufw delete allow "$p/tcp" >/dev/null 2>&1
|
||||
kl_ok "порт $p закрыт"
|
||||
done
|
||||
kl_run sshd -t || { kl_warn "sshd -t не принял конфиг — верните файл из $KL_BACKUP_DIR"; return 1; }
|
||||
kl_run systemctl restart ssh
|
||||
kl_say "остался только порт $new. Проверьте вход в НОВОМ окне, это не закрывая."
|
||||
return 0
|
||||
fi
|
||||
|
||||
kl_say "добавляю порт SSH $new (прежние: ${cur:-?})"
|
||||
if [[ " $cur " == *" $new "* ]]; then kl_warn "порт $new уже слушается — ничего не меняю"; return 0; fi
|
||||
if kl_have ss && ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$new\$"; then
|
||||
kl_die "порт $new занят другой службой — выберите другой"; return 1
|
||||
fi
|
||||
|
||||
if kl_ufw_active; then
|
||||
kl_run ufw allow "$new/tcp" && kl_ok "ufw: открыт $new/tcp — сделано до перезапуска, чтобы не запереть себя"
|
||||
else
|
||||
kl_warn "ufw не активен: проверьте, что порт $new открыт у хостера"
|
||||
fi
|
||||
|
||||
if kl_ssh_socket_active; then
|
||||
kl_backup "$KL_SOCKET_DROPIN"
|
||||
if kl_dry; then
|
||||
printf ' (dry-run) записал бы в %s: [Socket] + ListenStream=%s (старый порт остаётся)\n' "$KL_SOCKET_DROPIN" "$new"
|
||||
else
|
||||
install -d -m 755 "$(dirname "$KL_SOCKET_DROPIN")"
|
||||
printf '# 3X-UI KIT (форк): дополнительный порт SSH, добавлен %s\n[Socket]\nListenStream=%s\n' "$(date +%F)" "$new" >"$KL_SOCKET_DROPIN"
|
||||
kl_ok "добавлен $KL_SOCKET_DROPIN"
|
||||
fi
|
||||
kl_run systemctl daemon-reload
|
||||
kl_run systemctl restart ssh.socket
|
||||
else
|
||||
kl_backup "$KL_SSHD_DROPIN"
|
||||
if kl_dry; then
|
||||
printf ' (dry-run) записал бы в %s: Port %s (старый порт остаётся)\n' "$KL_SSHD_DROPIN" "$new"
|
||||
else
|
||||
install -d -m 755 /etc/ssh/sshd_config.d
|
||||
printf '# 3X-UI KIT (форк): дополнительный порт SSH, добавлен %s\nPort %s\n' "$(date +%F)" "$new" >"$KL_SSHD_DROPIN"
|
||||
kl_ok "добавлен $KL_SSHD_DROPIN"
|
||||
fi
|
||||
kl_run sshd -t || { kl_warn "sshd -t не принял конфиг — убираю наш файл, ничего не перезапускаю"; kl_dry || rm -f "$KL_SSHD_DROPIN"; return 1; }
|
||||
kl_run systemctl restart ssh || kl_run systemctl restart sshd
|
||||
fi
|
||||
|
||||
if ! kl_dry; then
|
||||
sleep 2
|
||||
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$new\$"; then kl_ok "sshd слушает $new"
|
||||
else kl_warn "порт $new пока не слушается — смотрите: journalctl -u ssh -n 30"; fi
|
||||
fi
|
||||
cat <<EOF
|
||||
|
||||
Дальше обязательно:
|
||||
1) во ВТОРОМ окне проверьте вход: ssh -p $new root@<IP сервера>
|
||||
2) заработало — закройте прежние порты: kit local ssh --close-old $new
|
||||
3) поменяйте порт у себя в ~/.ssh/config и в мониторинге, если он там есть
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ ping (ICMP echo)
|
||||
|
||||
kl_ping_state() {
|
||||
if kl_have ufw && [[ -f $KL_UFW_RULES ]]; then
|
||||
grep -qE '^-A ufw-before-input -p icmp --icmp-type echo-request -j ACCEPT' "$KL_UFW_RULES" && echo allow || echo deny
|
||||
elif [[ -f $KL_SYSCTL_DROPIN ]]; then
|
||||
grep -q 'icmp_echo_ignore_all = 1' "$KL_SYSCTL_DROPIN" && echo deny || echo allow
|
||||
else
|
||||
echo "?"
|
||||
fi
|
||||
}
|
||||
|
||||
kit_local_ping() {
|
||||
kl_args "$@"
|
||||
local want=${KL_REST[0]:-status}
|
||||
case $want in
|
||||
status | "")
|
||||
printf 'ответ на ping: %s\n' "$(kl_ping_state)"
|
||||
if kl_have ufw && [[ -f $KL_UFW_RULES ]]; then
|
||||
printf ' ufw before.rules: echo-request %s\n' "$(grep -qE '^# -A ufw-before-input -p icmp --icmp-type echo-request' "$KL_UFW_RULES" && echo 'закрыт (строка закомментирована)' || echo 'открыт')"
|
||||
[[ -f $KL_UFW6_RULES ]] && printf ' ufw before6.rules: echo-request %s\n' "$(grep -qE '^# -A ufw6-before-input' "$KL_UFW6_RULES" && echo 'закрыт' || echo 'открыт')"
|
||||
fi
|
||||
printf ' sysctl icmp_echo_ignore_all=%s\n' "$(sysctl -n net.ipv4.icmp_echo_ignore_all 2>/dev/null || echo '?')"
|
||||
return 0 ;;
|
||||
off | on) ;;
|
||||
*) kl_die "kit local ping off|on|status"; return 1 ;;
|
||||
esac
|
||||
kl_need_root || return 1
|
||||
|
||||
if kl_ufw_active; then
|
||||
kl_backup "$KL_UFW_RULES"; kl_backup "$KL_UFW6_RULES"
|
||||
if kl_dry; then
|
||||
printf ' (dry-run) %s в %s и before6.rules правило «icmp echo-request», затем ufw reload\n' \
|
||||
"$([[ $want == off ]] && echo 'закомментировал бы' || echo 'вернул бы')" "$KL_UFW_RULES"
|
||||
return 0
|
||||
fi
|
||||
local f
|
||||
for f in "$KL_UFW_RULES" "$KL_UFW6_RULES"; do
|
||||
[[ -f $f ]] || continue
|
||||
if [[ $want == off ]]; then
|
||||
sed -i -E 's|^(-A ufw6?-before-input -p icmp6? --icmp6?-type echo-request -j ACCEPT)|# \1 # kit local: закрыт|' "$f"
|
||||
else
|
||||
sed -i -E 's|^# (-A ufw6?-before-input -p icmp6? --icmp6?-type echo-request -j ACCEPT).*$|\1|' "$f"
|
||||
fi
|
||||
done
|
||||
ufw reload >/dev/null 2>&1 || kl_warn "ufw reload не прошёл — проверьте: ufw status"
|
||||
rm -f "$KL_SYSCTL_DROPIN"
|
||||
if [[ $want == off ]]; then
|
||||
kl_ok "сервер больше не отвечает на ping"
|
||||
kl_warn "ICMP-мониторинг этого сервера из дома работать перестанет (проба «ping» даст сбой)"
|
||||
kl_warn "вернуть: kit local ping on"
|
||||
else
|
||||
kl_ok "сервер снова отвечает на ping"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
kl_warn "ufw не активен — использую общесистемный запрет ICMP echo (это шире: глушит ping и для внутренних интерфейсов)"
|
||||
if kl_dry; then
|
||||
printf ' (dry-run) записал бы %s с icmp_echo_ignore_all=%s\n' "$KL_SYSCTL_DROPIN" "$([[ $want == off ]] && echo 1 || echo 0)"
|
||||
return 0
|
||||
fi
|
||||
printf 'net.ipv4.icmp_echo_ignore_all = %s\n' "$([[ $want == off ]] && echo 1 || echo 0)" >"$KL_SYSCTL_DROPIN"
|
||||
sysctl -p "$KL_SYSCTL_DROPIN" >/dev/null 2>&1
|
||||
kl_ok "готово (файл $KL_SYSCTL_DROPIN); вернуть: kit local ping on"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ пароль
|
||||
|
||||
kit_local_passwd() {
|
||||
kl_args "$@"
|
||||
local user=${KL_REST[0]:-root} gen=no
|
||||
[[ $user == --generate ]] && { user=root; gen=yes; }
|
||||
[[ ${KL_REST[1]:-} == --generate ]] && gen=yes
|
||||
kl_need_root || return 1
|
||||
id "$user" >/dev/null 2>&1 || { kl_die "нет пользователя $user"; return 1; }
|
||||
|
||||
if [[ $gen == yes ]]; then
|
||||
if kl_dry; then printf ' (dry-run) сгенерировал бы пароль пользователю %s и показал бы один раз\n' "$user"; return 0; fi
|
||||
local pw; pw=$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-20)
|
||||
printf '%s:%s\n' "$user" "$pw" | chpasswd || { kl_die "chpasswd не сработал"; return 1; }
|
||||
cat <<EOF
|
||||
|
||||
Пароль пользователя $user сменён. Показываю ОДИН раз:
|
||||
|
||||
$pw
|
||||
|
||||
Перенесите в CREDENTIALS.md и очистите прокрутку терминала (clear).
|
||||
EOF
|
||||
else
|
||||
kl_say "меняю пароль $user — спросит дважды, в историю команд не попадёт"
|
||||
if kl_dry; then printf ' (dry-run) запустил бы passwd %s\n' "$user"; return 0; fi
|
||||
passwd "$user" || return 1
|
||||
fi
|
||||
kl_dry || passwd -S "$user" 2>/dev/null
|
||||
sshd -T 2>/dev/null | grep -qi '^passwordauthentication no' \
|
||||
&& kl_warn "в sshd вход по паролю выключен — пароль пригодится только для консоли хостера"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ автообновления безопасности
|
||||
|
||||
kit_local_upgrades_status() {
|
||||
printf 'автообновления безопасности: %s\n' "$([[ -f /etc/apt/apt.conf.d/20auto-upgrades ]] && echo включены || echo выключены)"
|
||||
kl_have systemctl && printf ' служба unattended-upgrades: %s\n' "$(systemctl is-active unattended-upgrades 2>/dev/null || echo 'нет')"
|
||||
[[ -f /etc/apt/apt.conf.d/20auto-upgrades ]] && sed -n '1,4p' /etc/apt/apt.conf.d/20auto-upgrades | sed 's/^/ /'
|
||||
if [[ -f /var/log/unattended-upgrades/unattended-upgrades.log ]]; then
|
||||
printf ' последние запуски:\n'
|
||||
tail -n 3 /var/log/unattended-upgrades/unattended-upgrades.log | cut -c1-110 | sed 's/^/ /'
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
kit_local_upgrades() {
|
||||
kl_args "$@"
|
||||
local want=${KL_REST[0]:-status}
|
||||
case $want in
|
||||
status | "") kit_local_upgrades_status; return 0 ;;
|
||||
on | off) ;;
|
||||
*) kl_die "kit local upgrades on|off|status"; return 1 ;;
|
||||
esac
|
||||
kl_need_root || return 1
|
||||
|
||||
if [[ $want == off ]]; then
|
||||
kl_say "выключаю автообновления безопасности"
|
||||
kl_run rm -f /etc/apt/apt.conf.d/20auto-upgrades /etc/apt/apt.conf.d/52-kit-local-upgrades
|
||||
kl_run systemctl disable --now unattended-upgrades
|
||||
kl_ok "готово (пакет оставлен)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
kl_say "включаю автообновления безопасности (только security, без перезагрузки)"
|
||||
if ! kl_dry && ! dpkg -s unattended-upgrades >/dev/null 2>&1; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unattended-upgrades \
|
||||
|| { kl_die "пакет не поставился"; return 1; }
|
||||
fi
|
||||
if kl_dry; then
|
||||
printf ' (dry-run) записал бы /etc/apt/apt.conf.d/20auto-upgrades и 52-kit-local-upgrades, включил бы службу\n'
|
||||
return 0
|
||||
fi
|
||||
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::Download-Upgradeable-Packages "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
EOF
|
||||
cat >/etc/apt/apt.conf.d/52-kit-local-upgrades <<'EOF'
|
||||
// 3X-UI KIT (форк): только обновления безопасности, никакой самовольной перезагрузки.
|
||||
Unattended-Upgrade::Automatic-Reboot "false";
|
||||
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
|
||||
EOF
|
||||
systemctl enable --now unattended-upgrades >/dev/null 2>&1 || kl_warn "служба не включилась — проверьте: systemctl status unattended-upgrades"
|
||||
kl_ok "включено: обновления безопасности ставятся сами"
|
||||
kl_ok "панель и ядро это не затрагивает — они ставятся не из apt, а из своих релизов"
|
||||
kl_warn "обновление ядра применится только после перезагрузки, сам сервер не перезагружается"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ сводка
|
||||
|
||||
kit_local_hardening() {
|
||||
kl_args "$@"
|
||||
local ports; ports=$(kl_ssh_ports)
|
||||
kl_step "Защита сервера — состояние"
|
||||
printf 'порты SSH: %s\n' "${ports:-?}$(kl_ssh_socket_active && echo ' — через systemd ssh.socket')"
|
||||
printf 'ответ на ping: %s\n' "$(kl_ping_state)"
|
||||
printf 'ufw: %s\n' "$(kl_ufw_active && echo 'активен' || echo 'не активен')"
|
||||
kl_ufw_active && printf 'открытые порты: %s\n' "$(ufw status 2>/dev/null | awk '$2 == "ALLOW" {printf "%s ", $1}')"
|
||||
printf 'автообновления: %s\n' "$([[ -f /etc/apt/apt.conf.d/20auto-upgrades ]] && echo включены || echo выключены)"
|
||||
printf 'вход по паролю в sshd: %s\n' "$(sshd -T 2>/dev/null | awk '/^passwordauthentication/ {print $2}' || echo '?')"
|
||||
printf 'смена пароля root: %s\n' "$(chage -l root 2>/dev/null | awk -F': ' '/Last password change/ {print $2}')"
|
||||
printf 'продление сертификата: %s\n' "$(crontab -l 2>/dev/null | grep -c acme) задач acme в cron"
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
Чего здесь НЕТ и почему (в отличие от vps-setup):
|
||||
* apt full-upgrade — тянет систему вперёд и может сломать закреплённые панель и ядро;
|
||||
вместо него включены обновления безопасности;
|
||||
* автоматическая перезагрузка после правок — сервер не должен вставать без вашего решения;
|
||||
* общесистемный icmp_echo_ignore_all — правило ufw видно в `ufw status` и отменяется одной
|
||||
командой, не глушит диагностику внутренних интерфейсов.
|
||||
|
||||
Что включить при желании:
|
||||
kit local ssh 2222 добавить порт SSH
|
||||
kit local ping off не отвечать на ping
|
||||
kit local upgrades on обновления безопасности
|
||||
kit local passwd --generate сменить пароль root
|
||||
EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
kit_local_cmd() {
|
||||
case ${1:-help} in
|
||||
help | -h | --help) kit_local_help ;;
|
||||
version | -v) kit_local_version ;;
|
||||
channel) kit_local_channel ;;
|
||||
seams) kit_local_seams ;;
|
||||
masking | mask) kit_local_masking ;;
|
||||
ssh | ssh-port) shift; kit_local_ssh "$@" ;;
|
||||
ping | icmp) shift; kit_local_ping "$@" ;;
|
||||
passwd | pass) shift; kit_local_passwd "$@" ;;
|
||||
upgrades | auto) shift; kit_local_upgrades "$@" ;;
|
||||
hardening | hard) shift; kit_local_hardening "$@" ;;
|
||||
*)
|
||||
printf 'Неизвестная команда форка: %s\n' "$1" >&2
|
||||
kit_local_help >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Точка входа: вызывается из kit.sh с аргументами командной строки.
|
||||
# Если команда не наша — возвращаем 0, и kit работает ровно как раньше.
|
||||
kit_local_dispatch() {
|
||||
case ${1:-} in
|
||||
local)
|
||||
shift
|
||||
kit_local_cmd "$@"
|
||||
exit $?
|
||||
;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
+334
-13
@@ -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
|
||||
@@ -37,6 +40,8 @@ CLASH_UA = re.compile(r"clash|mihomo|flclash|stash|nyanpasu|meta", re.I)
|
||||
# тоже могут просить формат 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)
|
||||
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 +55,32 @@ 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)]
|
||||
|
||||
|
||||
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,6 +91,42 @@ def fix_userinfo(value):
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
JSON_FIX_MAX = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def fix_vless_encryption(body):
|
||||
"""JSON-подписка панели: у vless-пользователя без encryption (нет поля или пусто) ставим "none" – Xray иначе
|
||||
не принимает конфиг. Две формы: vnext[].users[] и плоская (settings.id/encryption). Непустое значение
|
||||
(например, mlkem) не трогаем. Не разобралось, слишком большое или менять нечего – тело как пришло."""
|
||||
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 []:
|
||||
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):
|
||||
"""Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение
|
||||
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram."""
|
||||
@@ -134,11 +185,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 +361,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, 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 +429,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)
|
||||
elif code == 200 and "text/plain" in headers.get("content-type", ""):
|
||||
body = strip_links(body)
|
||||
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 +458,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,7 +473,74 @@ 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 KIT", "GlobalProxy": "false", "DomainStrategy": "IPIfNonMatch", "ProxySites": sites,
|
||||
"ProxyIp": ips, "DirectIp": ["geoip:private"]}
|
||||
else:
|
||||
# localhost – системный DNS клиента для прямых сайтов (как в подписке Mihomo); список direct_dns его заменяет.
|
||||
data = {"Name": "3X-UI KIT", "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")
|
||||
@@ -231,6 +551,7 @@ def main():
|
||||
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)]
|
||||
|
||||
|
||||
+1917
-81
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` –
|
||||
|
||||
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 KIT fork releases (hacker07)
|
||||
@@ -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,243 @@
|
||||
#!/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")
|
||||
|
||||
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 KIT (нет 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 -------------------------------------------------
|
||||
("scripts/kit.sh", version_re, True, 'KIT_VERSION="%s"' % version, "версия сборки форка"),
|
||||
(
|
||||
"scripts/kit.sh",
|
||||
'KIT_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main"',
|
||||
False,
|
||||
'KIT_RAW="%s/raw/branch/main"' % GITEA,
|
||||
"источник VERSION и подсказки об обновлении",
|
||||
),
|
||||
(
|
||||
"scripts/kit.sh",
|
||||
'kit_ref_raw() { echo "https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v$1"; }',
|
||||
False,
|
||||
'kit_ref_raw() { echo "%s/raw/tag/v$1"; }' % GITEA + "\n" + HOOK,
|
||||
"файлы релиза из нашего тега + подключение kit-local.sh",
|
||||
),
|
||||
(
|
||||
"scripts/kit.sh",
|
||||
r' # SHA256:[^\n]*\n "ssh-ed25519 [^\n]*"\n',
|
||||
True,
|
||||
' # Ключ подписи форка (владелец: hacker07). Закрытая часть — только на рабочей станции.\n'
|
||||
' "%s"\n' % pub,
|
||||
"ключ проверки подписи релизов",
|
||||
),
|
||||
(
|
||||
"scripts/kit.sh",
|
||||
"for f in scripts/kit.sh scripts/kit-sub.py; do",
|
||||
False,
|
||||
"for f in scripts/kit.sh scripts/kit-sub.py scripts/kit-local.sh; do",
|
||||
"наш файл в списке проверяемых и скачиваемых",
|
||||
),
|
||||
(
|
||||
"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',
|
||||
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',
|
||||
"установка kit-local.sh при обновлении",
|
||||
),
|
||||
# --- scripts/3x-ui.sh -----------------------------------------------
|
||||
("scripts/3x-ui.sh", version_re, True, 'KIT_VERSION="%s"' % version, "версия сборки форка"),
|
||||
(
|
||||
"scripts/3x-ui.sh",
|
||||
'KIT_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v$KIT_VERSION"',
|
||||
False,
|
||||
'KIT_RAW="%s/raw/tag/v$KIT_VERSION"' % GITEA,
|
||||
"запасная загрузка kit из нашего релиза",
|
||||
),
|
||||
(
|
||||
"scripts/3x-ui.sh",
|
||||
'KIT_CLI_URL="$KIT_RAW/scripts/kit.sh"',
|
||||
False,
|
||||
'KIT_CLI_URL="$KIT_RAW/scripts/kit.sh"\nKIT_LOCAL_URL="$KIT_RAW/scripts/kit-local.sh"',
|
||||
"адрес нашего файла для установщика",
|
||||
),
|
||||
(
|
||||
"scripts/3x-ui.sh",
|
||||
'KIT_INSTALL_CMD="bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh)"',
|
||||
False,
|
||||
'KIT_INSTALL_CMD="bash <(curl -fsSL %s/raw/branch/main/scripts/3x-ui.sh)"' % GITEA,
|
||||
"команда установки, которую показывает панель",
|
||||
),
|
||||
(
|
||||
"scripts/3x-ui.sh",
|
||||
' bash -n /usr/local/bin/kit || die "Команда kit скачалась повреждённой"',
|
||||
False,
|
||||
" # Форк: ставим наши локальные дополнения (см. 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"
|
||||
' bash -n /usr/local/bin/kit || die "Команда kit скачалась повреждённой"',
|
||||
"установка kit-local.sh установщиком",
|
||||
),
|
||||
# --- scripts/hysteria2.sh -------------------------------------------
|
||||
("scripts/hysteria2.sh", version_re, True, 'KIT_VERSION="%s"' % version, "версия сборки форка"),
|
||||
# --- tools/release.sh -----------------------------------------------
|
||||
(
|
||||
"tools/release.sh",
|
||||
"FILES=(scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh scripts/kit-sub.py VERSION)",
|
||||
False,
|
||||
"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)",
|
||||
"наш файл в подписанном списке релиза",
|
||||
),
|
||||
# --- свои записи о версии (наш файл, авторский CHANGELOG не трогаем) ----
|
||||
(
|
||||
"tools/release.sh",
|
||||
'grep -q "^## v$v" CHANGELOG.md || die "В CHANGELOG.md нет раздела ## v$v"',
|
||||
False,
|
||||
'(grep -q "^## v$v" CHANGELOG.md || grep -q "^## v$v" FORK-CHANGELOG.md)'
|
||||
' || die "Нет раздела ## v$v ни в CHANGELOG.md, ни в FORK-CHANGELOG.md (форк ведёт свои записи в FORK-CHANGELOG.md)"',
|
||||
"раздел версии форка — в нашем файле",
|
||||
),
|
||||
(
|
||||
"tools/release.sh",
|
||||
'[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md) ]]',
|
||||
False,
|
||||
'[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md FORK-CHANGELOG.md) ]]',
|
||||
"наш файл записей тоже должен быть закоммичен",
|
||||
),
|
||||
(
|
||||
"scripts/kit.sh",
|
||||
' curl -fsS -m 5 "$KIT_RAW/CHANGELOG.md" 2>/dev/null | awk -v v="## v$1" \'',
|
||||
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" \'',
|
||||
"«что нового» ищет и в наших записях",
|
||||
),
|
||||
]
|
||||
|
||||
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))
|
||||
|
||||
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()
|
||||
+21
-6
@@ -19,17 +19,17 @@ key=${1:-$HOME/.ssh/3xui_kit_release}
|
||||
[[ -f $key ]] || die "Нет закрытого ключа $key. Создать: ssh-keygen -t ed25519 -C \"3X-UI KIT 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 "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
|
||||
[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md FORK-CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
|
||||
|
||||
# Установщик 3X-UI: хеш в скрипте должен совпадать с тем, что сейчас лежит на GitHub.
|
||||
xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh)
|
||||
@@ -47,13 +47,28 @@ for a in 64 arm64-v8a; do
|
||||
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=$(curl -fsSL "https://github.com/MHSanaei/3x-ui/releases/download/$kp/x-ui-linux-$a.tar.gz.sha256" | awk 'NR == 1 {print $1}')
|
||||
[[ -n $pin && $pin == "$off" ]] || die "Сумма архива панели $kp ($a) в kit.sh не совпадает с официальной: «$pin» вместо «$off»."
|
||||
done
|
||||
shs=$(sed -n 's/^XUI_SH_SHA256=\(.*\)/\1/p' scripts/kit.sh)
|
||||
got=$(curl -fsSL "https://raw.githubusercontent.com/MHSanaei/3x-ui/$kp/x-ui.sh" | sha | awk '{print $1}')
|
||||
[[ $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
|
||||
rm -f SHA256SUMS.sig
|
||||
|
||||
@@ -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 KIT", 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 KIT – данные для входа\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 KIT", 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 KIT", 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 KIT")
|
||||
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