Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13c86723be | ||
|
|
96823d74f1 | ||
|
|
ca5a57db55 | ||
|
|
628130992d | ||
|
|
445c066791 | ||
|
|
1fdc4945f1 | ||
|
|
cc17b11bd1 | ||
|
|
4ed9c5ceb5 | ||
|
|
0f31fc1739 | ||
|
|
619b2120ce | ||
|
|
b61a3225d8 | ||
|
|
70e2a499c7 | ||
|
|
a70dd14542 | ||
|
|
934077c56f | ||
|
|
ada50ae24e | ||
|
|
2e875aaa2f | ||
|
|
88af9444c0 | ||
|
|
e0ddf8fe25 | ||
|
|
c83fde40e4 | ||
|
|
211053a36b | ||
|
|
80258f1d45 | ||
|
|
7b30cd9b23 | ||
|
|
45b8d1c783 | ||
|
|
47cdfd34a5 | ||
|
|
ddfbabf0c2 | ||
|
|
a94f755317 | ||
|
|
cd99a12c29 | ||
|
|
2c6549a3af | ||
|
|
64fffa5e73 | ||
|
|
5913dcee09 | ||
|
|
018d9cefa2 | ||
|
|
436acffc22 | ||
|
|
25df91e79a | ||
|
|
387d6ae66e | ||
|
|
397804faa7 | ||
|
|
5449a73fd8 | ||
|
|
f7a8475c88 | ||
|
|
220d82c01e | ||
|
|
1aa9a84e96 | ||
|
|
25848a24c1 | ||
|
|
972757ed75 | ||
|
|
159c1b271d | ||
|
|
888a4cb46b | ||
|
|
da07108a83 | ||
|
|
42be16a5a2 | ||
|
|
58fc3b4704 | ||
|
|
43835928bb | ||
|
|
7741f26eb4 | ||
|
|
d2a299a088 | ||
|
|
69796f9ed8 | ||
|
|
1f495d70f3 | ||
|
|
53823c6734 | ||
|
|
5dc1140bf3 | ||
|
|
51388780dc | ||
|
|
6e3da5774d | ||
|
|
a2e67e2521 | ||
|
|
795a71f54b | ||
|
|
da6abc665d | ||
|
|
1b11c966ff | ||
|
|
635c909426 | ||
|
|
8e2215f9f4 | ||
|
|
bb42586454 | ||
|
|
c1710d10d5 | ||
|
|
81ae0337b4 | ||
|
|
7d6f13d315 | ||
|
|
3efbc327f5 | ||
|
|
73bbaadd48 | ||
|
|
d74ef02d65 | ||
|
|
3a7b760dfc | ||
|
|
a8a4aae6b3 | ||
|
|
88f2c1066b | ||
|
|
3ea6c0c868 | ||
|
|
1c71ff5c24 | ||
|
|
31bc2d5480 | ||
|
|
97718bd3e6 | ||
|
|
2a8cfe6ea2 | ||
|
|
c4e6ed1a5d | ||
|
|
e818d22c65 | ||
|
|
7425c672b8 | ||
|
|
00319a6655 | ||
|
|
f22e043902 | ||
|
|
26019b39f4 | ||
|
|
3422bee5e1 | ||
|
|
7161ef6fe8 | ||
|
|
21c3ac18c2 | ||
|
|
eb152b201f | ||
|
|
a6b70c154a | ||
|
|
2b5b9c8d9e | ||
|
|
58af616ced | ||
|
|
0f5912b198 | ||
|
|
85f3535a1c | ||
|
|
844ac083b3 | ||
|
|
4c795ea6e0 | ||
|
|
f7ac1ddf49 | ||
|
|
346159210b | ||
|
|
0a9cce18b6 | ||
|
|
4246031ff9 | ||
|
|
4b336d7395 | ||
|
|
31db2d30a4 | ||
|
|
2bcfb95b05 | ||
|
|
c9fc0c25be | ||
|
|
a19f60c8b0 | ||
|
|
d6109a9b3a | ||
|
|
88feba6190 | ||
|
|
5232e431de | ||
|
|
2e40b85fd4 | ||
|
|
d751093bbd | ||
|
|
ec25f0e0e8 | ||
|
|
ca74a1dee3 | ||
|
|
0f52237286 | ||
|
|
173c227c2e | ||
|
|
0e7372efb7 | ||
|
|
67bb5bc231 | ||
|
|
3d3f3e2d9d | ||
|
|
fe065d445a | ||
|
|
71d40360ab | ||
|
|
099117c2a7 |
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
*.pyc
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
+32
-20
@@ -1,28 +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` лечит безопасное, без наркоза и без героизма.
|
||||
* Заголовки 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` складывает подключения, ключи и пользователей в один файл. Восстановление из него придёт в 1.2: решили выпускать только то, что успели проверить на настоящем сервере, а не то, что «вроде работает».
|
||||
* Маскировка на выбор. Установщик спрашивает: стандартный чужой сайт (работает сразу) или свой домен (надёжнее: настоящая страница и сертификат 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,100 @@
|
||||
# Что нового в сборке форка
|
||||
|
||||
Здесь записи о наших версиях. Файл автора (`CHANGELOG.md`) мы не трогаем — так обновления
|
||||
автора не конфликтуют с нашими записями.
|
||||
|
||||
## v1.2.6: вопросы при установке
|
||||
|
||||
* **README.md переписан целиком:** убрано авторское описание с его командой установки, добавлены
|
||||
актуальная команда, флаги, таблица протоколов, список сайтов-заглушек, команды `kit` и `kit local`,
|
||||
канал обновлений и оговорки. Две картинки — наши (`manuals/assets/fork-banner.svg` и
|
||||
`manuals/assets/script-3x-ui-local.svg`, терминал собран из настоящего вывода скрипта).
|
||||
* Шов в README больше не нужен (`tools/fork_apply.py`): файл наш целиком, как `kit-local.sh`.
|
||||
* Набор по умолчанию — **REALITY + AmneziaWG-3.1** (самый совместимый минимум); ответ «все»
|
||||
ставит полный набор автора. Умолчание задаётся в одном месте (`DEFAULT_PROTOS`).
|
||||
* **Сайт-заглушка переделан на ИТ-тематику:** десять правдоподобных сайтов (дата-аналитика, DevOps,
|
||||
мониторинг, разработка, аудит, ИТ-поддержка, колокейшн, заметки про Linux, бэкапы, документация)
|
||||
вместо авторских «кофеен и пекарен». Ставится случайный, конкретный — `kit local site 7`,
|
||||
список — `kit local site --list`. Команда обновляет и файл для nginx, и копию страницы внутри
|
||||
подключения Hysteria2 (`kit net masq on`): если менять файл вручную, 443/tcp и UDP-порт начнут
|
||||
отвечать разными страницами — это заметно снаружи.
|
||||
* Обёртка спрашивает, **какие протоколы ставить** (нумерованный список, `1,3,11` или «все»,
|
||||
Enter — набор автора): невыбранные протоколы не создаются вообще — ни подключения в панели,
|
||||
ни записей в nginx, ни ссылок в подписке. Список и умолчание взяты из самого установщика
|
||||
(`ALL_PROTOS`/`DEFAULT_PROTOS`), передаётся его же флагом `--protocols`, так что поведение
|
||||
не расходится с авторским. Свой `--protocols` можно задать флагом — тогда вопроса не будет.
|
||||
* Обёртка `scripts/3x-ui-local.sh` теперь спрашивает, а не решает за вас: закрывать ли ответ на ping,
|
||||
включать ли автообновления безопасности, **менять ли порт SSH** (Enter — оставить текущий,
|
||||
число — перевести на него; мусор и занятый порт переспрашивает), менять ли пароль root.
|
||||
Маскировку и домен, как и раньше, спрашивает установщик автора.
|
||||
* Смена порта SSH никогда не закрывает прежний порт сама: сначала добавляется новый, затем скрипт
|
||||
печатает две команды — проверить вход во втором окне и закрыть прежние (`kit local ssh --close-old`).
|
||||
* Запуск без флагов = «одна команда, дальше вопросы». Флаг `--yes` — наоборот, ничего не спрашивать
|
||||
(для автоматизации): ping закрывается, автообновления включаются, порт SSH и пароль не трогаются.
|
||||
* Флаг `--harden` теперь действительно существует (раньше он уходил установщику автора неизвестным
|
||||
параметром и ронял установку): закрыть ping и включить автообновления без вопросов.
|
||||
* `--dry-run` показывает, что именно будет спрошено или сделано по умолчанию.
|
||||
|
||||
## v1.2.5: суммы релиза в формате, который понимает kit
|
||||
|
||||
* Исправлена ошибка нашего релизного конвейера: на Windows `sha256sum` пишет суммы как
|
||||
`hash *файл`, а `kit` на сервере сверяет имя строго (`$2 == "файл"`) — то есть `kit update`
|
||||
отклонил бы релизы v1.2.1–v1.2.4 как «в подписанном списке нет такого файла». Теперь
|
||||
`tools/release.sh` приводит список к POSIX-виду (`hash файл`) **до** подписи.
|
||||
* `scripts/3x-ui-local.sh` принимает оба формата, чтобы не спотыкаться о суммы, собранные на
|
||||
другой машине.
|
||||
* Проверка теперь повторяет логику самого `kit`: подпись `SHA256SUMS` + строгая сверка имени
|
||||
каждого файла из подписанного списка (а не только `sha256sum -c`, который звёздочку прощает).
|
||||
|
||||
## v1.2.4: проверка релиза отдельной командой
|
||||
|
||||
* Флаг `--verify-only`: скачать релиз, проверить подпись `SHA256SUMS` и SHA256 установщика и выйти —
|
||||
можно проверить канал на сервере, ничего не устанавливая.
|
||||
* Порядок шагов изменён: проверка подписи теперь идёт до предполётных проверок и до любых
|
||||
изменений на сервере, чтобы чужой код не запускался раньше проверки.
|
||||
|
||||
## v1.2.3: установка одной командой (`scripts/3x-ui-local.sh`)
|
||||
|
||||
* Один скрипт на чистом VPS: проверка сервера → скачивание нашего релиза → проверка подписи
|
||||
`SHA256SUMS` и SHA256 самого установщика → установщик автора (панель 3X-UI, протоколы,
|
||||
сайт-заглушка, маскировка, сертификат, подписка, ufw) → наши дополнения → проверка.
|
||||
* По умолчанию досыпает: закрыть ответ на ping (правилом ufw), включить обновления безопасности,
|
||||
`kit fix` (время и безопасные починки), в конце `kit check` и сводку `kit local hardening`.
|
||||
Отключается флагами `--no-harden`, `--no-time`, `--no-check`.
|
||||
* Второй порт SSH — только по явному флагу `--ssh-port N`: прежний порт продолжает работать,
|
||||
закрытие — отдельным шагом `kit local ssh --close-old N`. Автоматически доступ не запираем.
|
||||
* Все флаги установщика автора (`--domain`, `--sni`, `--panel-on`, `--host`, `--restore` …)
|
||||
передаются как есть; есть `--dry-run` (план без изменений) и `--help`.
|
||||
* Публичный ключ форка вшит в обёртку — подпись проверяется до запуска чужого кода; при смене
|
||||
ключа обновляется и здесь, и в `kit`.
|
||||
|
||||
## v1.2.2: команды защиты сервера (поведение из StressOzz/vps-setup)
|
||||
|
||||
* Новые команды `kit local`: `ssh <порт>` (добавить порт SSH), `ssh --close-old` (закрыть прежние
|
||||
порты), `ping off|on` (не отвечать на ICMP echo), `passwd [--generate]` (сменить пароль),
|
||||
`upgrades on|off|status` (автообновления безопасности), `hardening` (сводка по защите сервера).
|
||||
* Сделано безопаснее, чем в источнике: конфиг sshd проверяется через `sshd -t` **до** перезапуска,
|
||||
новый порт сначала открывается в ufw, прежний порт продолжает работать до отдельного шага
|
||||
`--close-old`, у правок есть `--dry-run`, а копии изменённых файлов остаются в
|
||||
`/root/kit-local-backup-<дата>/` (не в `/tmp`).
|
||||
* `apt full-upgrade` из источника не выполняется: вместо него включаются обновления безопасности
|
||||
(только security, без самовольной перезагрузки) — это не затрагивает закреплённые версии панели
|
||||
и ядра, которые ставятся не из apt.
|
||||
* Учтён Ubuntu 24.04: если SSH слушает systemd (`ssh.socket`), порт добавляется socket-drop-in'ом,
|
||||
а закрытие старых портов не делается автоматически — там ошибка запирает доступ к серверу.
|
||||
* Ping отключается правилом ufw (`before.rules`), а не общесистемным `icmp_echo_ignore_all`:
|
||||
видно в `ufw status`, отменяется одной командой и не глушит ICMP для внутренних интерфейсов.
|
||||
|
||||
## v1.2.1: сборка форка поверх 3X-UI 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,202 @@
|
||||
# Форк 3X-UI KIT (hacker07) — своя сборка и свой канал обновлений
|
||||
|
||||
Коротко: это [3X-UI KIT](https://github.com/itsnotkubrick/3X-UI_KIT) со своими доработками. Наш код
|
||||
живёт рядом с кодом автора и не ломается, когда автор выпускает новую версию, а серверы ставятся и
|
||||
обновляются **из этого репозитория** — релизы подписываются нашим ключом.
|
||||
|
||||
## С чего начать: новый VPS
|
||||
|
||||
1. Если нужен свой домен для маскировки — заранее направьте его A-запись на IP сервера и освободите
|
||||
порт 80 (иначе Let's Encrypt не выпустит сертификат). Без домена этот шаг пропускается.
|
||||
2. Одна команда на сервере (от root) — **дальше скрипт спросит сам**: какие протоколы ставить
|
||||
(Enter — REALITY + AmneziaWG-3.1, «все» — набор автора), какую маскировку и домен,
|
||||
закрывать ли ответ на ping, включать ли автообновления, менять ли порт SSH (Enter — оставить
|
||||
текущий, число — перевести на него), менять ли пароль root:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
|
||||
```
|
||||
|
||||
Те же ответы можно задать флагами — тогда вопросов не будет:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols reality,hy2,awg,mtproto --domain vpn.example.com
|
||||
```
|
||||
|
||||
3. Скрипт делает всё сам: проверяет сервер → скачивает наш релиз и **проверяет подпись `SHA256SUMS`
|
||||
и SHA256 установщика** → запускает установщик автора (панель 3X-UI, **только выбранные протоколы**,
|
||||
сайт-заглушка, маскировка, сертификат, подписка, ufw) → наши вопросы и правки (ping,
|
||||
автообновления, второй порт SSH, пароль root) → `kit fix` → в конце показывает `kit check` и сводку
|
||||
`kit local hardening`.
|
||||
4. Забрать ссылки и подписку: `kit user link admin` (или адрес подписки из вывода установки).
|
||||
|
||||
Сомневаетесь в канале — сначала ничего не меняющие режимы:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --verify-only # только проверка подписи релиза
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --dry-run --domain vpn.example.com # план установки
|
||||
```
|
||||
|
||||
### Флаги установки
|
||||
|
||||
| Флаг | Что делает |
|
||||
|---|---|
|
||||
| `--protocols список` | какие протоколы ставить: `all`, `minimal` или через запятую — `reality,reality2,hy2,xhttp,ws,trojan,vmess,ss,tuic,wg,awg,awg3,mtproto`. Невыбранные не создаются вообще. Без флага — вопрос, по умолчанию предлагаются **REALITY + AmneziaWG-3.1** (ответ «все» — набор автора) |
|
||||
| `--site N\|random\|off` | сайт-заглушка: конкретный из нашего набора (10 ИТ-сайтов), случайный (по умолчанию) или не трогать авторскую страницу (то же — `--no-site`) |
|
||||
| `--yes` | не задавать вопросы (ping закрывается, автообновления включаются, порт SSH и пароль не трогаются) |
|
||||
| `--harden` | закрыть ответ на ping и включить автообновления без вопросов |
|
||||
| `--domain домен` | свой домен для маскировки: сайт и REALITY маскируются под него (A-запись на сервер, свободный 80) |
|
||||
| `--sni сайт.com` | маскировка под чужой сайт вместо домена |
|
||||
| `--panel-on domain\|ip` | где открывать панель и подписку |
|
||||
| `--ssh-port N` | перевести SSH на порт N (число 1024–65535). Прежний порт сам не закрывается: сначала проверьте вход во втором окне, потом `kit local ssh --close-old N` |
|
||||
| `--no-harden` | не закрывать ответ на ping и не включать автообновления безопасности |
|
||||
| `--no-time` / `--no-check` | не запускать `kit fix` / `kit check` |
|
||||
| `--verify-only` / `--dry-run` | проверить релиз / показать план и ничего не менять |
|
||||
| остальные | передаются установщику автора как есть: `--host`, `--user`, `--port`, `--protocols`, `--restore`, `--multi-port`, `--cert`/`--key`, `--panel-ssl` |
|
||||
|
||||
Чего скрипт **не** делает (сознательно): `apt full-upgrade` (тянет систему вперёд и может сломать
|
||||
закреплённые панель 3X-UI 3.9.0 и ядро Xray 26.6.27 — вместо него обновления безопасности),
|
||||
автоматическую перезагрузку и смену пароля root (в однострочник пароль не подставляем, он попал бы
|
||||
в список процессов — для этого есть `kit local passwd`).
|
||||
|
||||
### Как проверить, что установка удалась
|
||||
|
||||
```bash
|
||||
kit check # без ❌: службы, порт 443 (nginx), подписка, маскировка, сертификат
|
||||
kit local hardening # сводка: порты SSH, ответ на ping: deny, ufw активен, автообновления: включены
|
||||
kit local channel # источник — этот репозиторий, ключей в KIT_SIGNERS: 1 (наш)
|
||||
kit local version # версия сборки: 1.2.5 или новее
|
||||
ping <IP сервера> # с домашней машины — таймаут: ответ на ping закрыт, это ожидаемо
|
||||
kit user link admin # ссылка подписки; откройте её в браузере
|
||||
```
|
||||
|
||||
Если ставили с доменом, в `kit check` должны быть строки «свой домен … указывает на этот сервер» и
|
||||
«сайт маскировки … отвечает по TLS 1.3 и HTTP/2»; предупреждения про известный сайт на чужом IP
|
||||
быть не должно (это касалось бы вспомогательного XHTTP, у REALITY должен быть ваш домен).
|
||||
|
||||
## Команды форка (`kit local`)
|
||||
|
||||
```bash
|
||||
kit local help # справка по всем командам форка
|
||||
kit local version # версия, источник обновлений, дата сборки нашего файла
|
||||
kit local channel # откуда берутся обновления и чем проверяется подпись
|
||||
kit local seams # какие швы форка сейчас в установленном /usr/local/bin/kit
|
||||
kit local masking # состояние маскировки (у REALITY должен быть свой домен)
|
||||
kit local hardening # сводка по защите сервера
|
||||
```
|
||||
|
||||
Защита сервера (поведение взято из проекта `StressOzz/vps-setup`, реализация своя и безопаснее):
|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `kit local site` | поставить случайный сайт-заглушку из нашего набора (10 ИТ-сайтов) и синхронизировать ответ Hysteria2 |
|
||||
| `kit local site --list` / `kit local site 7` / `--show` | список сайтов с номерами / поставить конкретный / какой стоит сейчас |
|
||||
| `kit local ssh 2222` | добавить порт SSH (прежние продолжают работать; порт сначала открывается в ufw, конфиг проверяется через `sshd -t`) |
|
||||
| `kit local ssh --status` | какие порты SSH слушаются сейчас |
|
||||
| `kit local ssh --close-old 2222` | закрыть прежние порты после того, как новый проверен из второго окна |
|
||||
| `kit local ping off\|on\|status` | ответ на ICMP echo: закрыть/вернуть/показать (правилом ufw, видно в `ufw status`) |
|
||||
| `kit local passwd [юзер]` | сменить пароль (спросит дважды, в историю команд не попадёт) |
|
||||
| `kit local passwd --generate` | сгенерировать пароль и показать один раз |
|
||||
| `kit local upgrades on\|off\|status` | автообновления безопасности (только security, без перезагрузки) |
|
||||
|
||||
К любой правке можно добавить `--dry-run` — покажет план и ничего не изменит. Копии изменённых
|
||||
файлов складываются в `/root/kit-local-backup-<дата>/` (не в `/tmp`).
|
||||
|
||||
Что `ping off` затрагивает: только ICMP echo. Подписка (HTTPS), панель, REALITY/XHTTP (TCP 443),
|
||||
Hysteria2 (UDP 443) работают как работали; исходящий ping с самого сервера тоже остаётся. Если
|
||||
сервер есть в вашем мониторинге — пробу до него делайте по HTTPS/TCP, а не по ICMP.
|
||||
|
||||
## Что где лежит
|
||||
|
||||
| Что | Где | Правит |
|
||||
|---|---|---|
|
||||
| Наши доработки (команды, хуки) | `scripts/kit-local.sh` | только мы |
|
||||
| Установка одной командой (обёртка) | `scripts/3x-ui-local.sh` | только мы |
|
||||
| Описание «швов» в коде автора | `tools/fork_apply.py` | только мы |
|
||||
| Синхронизация с автором и проверки | `tools/fork-sync.sh` | только мы |
|
||||
| Записи о наших версиях | `FORK-CHANGELOG.md` | только мы |
|
||||
| `README.md` | целиком, включая картинки в `manuals/assets/` | только мы |
|
||||
| Файлы автора (`scripts/kit.sh`, `scripts/3x-ui.sh`, …) | как есть | только автор |
|
||||
|
||||
Ключевая идея: в файлах автора мы правим только «швы» — несколько констант, одну строку подключения
|
||||
и нормализацию сумм в релизном скрипте. Всё, что мы дописываем по существу, лежит в наших файлах.
|
||||
Швы описаны в одном месте (`tools/fork_apply.py`) и накатываются только на чистое дерево автора;
|
||||
если автор изменил нужную строку — инструмент останавливается и говорит об этом, а не делает
|
||||
полу-патч.
|
||||
|
||||
## Ветки и релизы
|
||||
|
||||
- `upstream` — точная копия ветки автора (`main`), только вперёд.
|
||||
- `dev` — наша сборка: `upstream` + швы + наши файлы; из неё делаются релизы.
|
||||
- `main` — то же состояние плюс документация после релиза (по нему считается «последняя версия»).
|
||||
- релизы — теги `v<номера>`; номер = `<версия автора>.<наш уровень>` (автор 1.2 → наш 1.2.1; после
|
||||
релиза автора 1.3 → 1.3.1). Номер обязан строго расти: `kit update` не принимает откат назад.
|
||||
|
||||
## Обновление сервера и канал
|
||||
|
||||
```bash
|
||||
kit update --force && kit check # тянет наш релиз из этого репозитория, проверяет нашу подпись
|
||||
```
|
||||
|
||||
Источник файлов — `KIT_RAW` / `kit_ref_raw()` в `scripts/kit.sh` (наш Gitea и наш тег), подпись
|
||||
проверяется ключом из `KIT_SIGNERS` (там только ключ форка). Ночной `kit-update.timer` (03:00) делает
|
||||
то же автоматически — поэтому сервер и должен смотреть в этот репозиторий: иначе наши доработки
|
||||
исчезнут в первую же ночь.
|
||||
|
||||
Вернуться на канал автора: скачать `scripts/kit.sh` из релиза автора с GitHub в `/usr/local/bin/kit`,
|
||||
затем `kit update --force`.
|
||||
|
||||
## Обновление от автора (с рабочей станции)
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Подпись релизов
|
||||
|
||||
- Открытая часть ключа — `tools/fork-signer.pub` и в `KIT_SIGNERS` (в `scripts/kit.sh`); она же
|
||||
вшита в `scripts/3x-ui-local.sh`, чтобы проверять подпись до запуска установщика.
|
||||
- Закрытая часть — только на рабочей станции владельца (`~/.ssh/3x-ui-kit-fork/release_key`).
|
||||
В репозиторий, CI и на серверы не попадает: кто получит ключ — получит root на всех серверах,
|
||||
которые обновляются из этого репозитория. Пароль на ключ пока не установлен
|
||||
(`ssh-keygen -p -f ~/.ssh/3x-ui-kit-fork/release_key`).
|
||||
- Смена ключа: новый релиз подписывается **старым** ключом, иначе серверы его не примут.
|
||||
|
||||
## Грабли, о которых стоит помнить
|
||||
|
||||
1. **Ночное автообновление** (`kit-update.timer`, 03:00) перезаписывает `/usr/local/bin/kit` тем, что
|
||||
лежит в настроенном репозитории. Канал и наши правки — один шаг, а не два.
|
||||
2. **Переводы строк.** Релиз подписывается по суммам рабочей копии, а сервер сверяет их с файлами из
|
||||
Gitea: при `core.autocrlf=true` суммы не сойдутся (CRLF против LF). В клоне должно быть
|
||||
`git config core.autocrlf false` (и `.gitattributes`), проверка есть в `tools/fork-sync.sh`.
|
||||
3. **Наш файл обязан быть в двух списках** — `FILES=(…)` в `tools/release.sh` и список загрузки
|
||||
`fetch_release()` в `scripts/kit.sh`, иначе после обновления `kit-local.sh` останется старым.
|
||||
4. **Панель 3X-UI ставится с GitHub** по закреплённой версии и SHA256 — это шаг автора; для установки
|
||||
с нуля серверу нужен доступ к GitHub.
|
||||
5. **Формат контрольных сумм.** `kit` сверяет имя файла в `SHA256SUMS` строго (`$2 == "путь"`).
|
||||
На Windows `sha256sum` пишет `hash *путь`, и такие релизы `kit update` отклоняет («в подписанном
|
||||
списке нет такого файла»), хотя `sha256sum -c` их принимает. Поэтому `tools/release.sh` приводит
|
||||
список к POSIX-виду **до** подписи, а релизы **v1.2.1–v1.2.4 ставить нельзя** — только v1.2.5+.
|
||||
Проверять релиз надо логикой самого `kit`, а не одной `sha256sum -c`.
|
||||
6. **Сборка релиза обращается к GitHub** (сверка закреплённых версий панели и ядра). Из домашней сети
|
||||
TLS до GitHub иногда рвётся (`schannel: failed to receive handshake`) — просто повторить запуск.
|
||||
7. **`README.md` — наш файл целиком.** Авторское описание убрано: иначе человек, открывший
|
||||
репозиторий, читает команду установки **авторской** сборки, ставит по ней и оказывается на
|
||||
авторском канале обновлений — ночное автообновление вернёт его сборку. При обновлении из
|
||||
авторского проекта README конфликтует: берём нашу версию (`git checkout --ours README.md`),
|
||||
авторские правки в нём нас не интересуют.
|
||||
|
||||
## Что проверено на сегодня
|
||||
|
||||
- Релиз `v1.2.5`: подпись сходится с ключом из опубликованного `kit.sh`, строгая сверка сумм
|
||||
`$2 == путь` для `kit.sh`, `kit-sub.py`, `kit-local.sh` — как это делает `kit` на сервере.
|
||||
- Публичный однострочник в режиме `--verify-only` отвечает «подпись сошлась, SHA256 установщика
|
||||
совпал» — проверено снаружи, из домашней сети.
|
||||
- Установка «в бою» на живом сервере пока не прогонялась: сначала её стоит выполнить на новом VPS,
|
||||
посмотрев `--verify-only` и `--dry-run`.
|
||||
@@ -1,114 +1,196 @@
|
||||
<div align="center">
|
||||
|
||||
<img alt="3X-UI KIT" src="manuals/assets/banner.svg" width="820">
|
||||
<img alt="3X-UI autoVPS" src="manuals/assets/fork-banner.svg" width="860">
|
||||
|
||||
**Свой VPN-сервер одной командой: 11 протоколов, один порт 443, одна подписка на всё**
|
||||
**Сервер одной командой: панель 3X-UI, VPN-протоколы за одним портом 443, одна подписка на все устройства**
|
||||
|
||||
[](manuals/3x-ui.md#протоколы)
|
||||
[](https://github.com/MHSanaei/3x-ui)
|
||||
[](https://github.com/itsnotkubrick/3X-UI_KIT/commits)
|
||||
|
||||
[Возможности](#возможности) · [Установка](#установка) · [Генератор](#генератор-конфигов-для-роутера) · [Полезное](#полезное) · [Поддержать](#поддержать-проект)
|
||||
Сборка со **своим каналом обновлений**: сервер ставится и обновляется из этого репозитория, релизы
|
||||
подписаны, ночное автообновление не вернёт чужую версию. В основе — открытый проект 3X-UI KIT.
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
## Что ставится
|
||||
|
||||
> [!TIP]
|
||||
> **Вышла версия 1.1:** исправления по аудиту безопасности, автообновление только подписанных
|
||||
> релизов, выбор маскировки (свой домен). [Что нового](https://github.com/itsnotkubrick/3X-UI_KIT/releases/tag/v1.1) ·
|
||||
> сервер на 1.0 обновляется одной командой:
|
||||
> ```bash
|
||||
> 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
|
||||
> ```
|
||||
|
||||
**3X-UI KIT** превращает чистый VPS в готовый VPN-сервер за несколько минут. Скрипт ставит
|
||||
официальную панель [3X-UI](https://github.com/MHSanaei/3x-ui), настраивает все популярные
|
||||
протоколы, сертификат и файрвол и выдаёт одну ссылку-подписку. Её можно вставить в любое
|
||||
приложение – оно само получит подходящие ему протоколы. Домен не нужен.
|
||||
|
||||
<div align="center">
|
||||
<img alt="Конец установки 3X-UI KIT" src="manuals/assets/script-3x-ui.svg" width="760">
|
||||
</div>
|
||||
|
||||
## Возможности
|
||||
|
||||
- 🧩 **11 протоколов сразу** – VLESS REALITY, XHTTP, WebSocket, Trojan gRPC, VMess,
|
||||
Shadowsocks 2022, Hysteria2, TUIC, AmneziaWG (классика и 3.1) и MTProto для Telegram.
|
||||
- 🚪 **Всё TCP – через порт 443.** Панель, подписка и протоколы спрятаны за одним портом,
|
||||
а на случайный заход сервер показывает обычный сайт.
|
||||
- 🔗 **Одна подписка на все приложения.** Hiddify, Happ, v2rayN, Karing, Clash Verge и FlClash
|
||||
получают свой формат и только те протоколы, которые умеют.
|
||||
- 👥 **Дополнительные пользователи одной командой** – `kit user add` добавляет пользователя
|
||||
сразу во все протоколы с общим лимитом трафика, сроком и числом устройств.
|
||||
- 🔄 **Обновляется сам, но только подписанными релизами**, а `kit backup` сохраняет
|
||||
копию сервера с пользователями и ключами.
|
||||
- 🔒 **Сертификат Let's Encrypt на IP** выпускается и продлевается сам, панель скрыта на
|
||||
случайном пути со случайными логином и паролем.
|
||||
- ✅ **Проверено настоящими клиентами** – каждый протокол на ядрах Xray, Mihomo и sing-box,
|
||||
в том числе с сервером в России: [tests/matrix](tests/matrix/).
|
||||
|
||||
## Что понадобится
|
||||
|
||||
- VPS с **Ubuntu 22.04/24.04** или **Debian 12/13** и доступом root по SSH
|
||||
- Свободные порты **443** и **80** – на свежем сервере они свободны
|
||||
| Что | Зачем |
|
||||
|---|---|
|
||||
| Панель **3X-UI** + ядро **Xray** | управление подключениями и пользователями |
|
||||
| **nginx** на порту 443 | разводит подключения по именам и путям, отдаёт сайт-заглушку |
|
||||
| VPN-подключения | REALITY, Hysteria2, AmneziaWG, XHTTP, VLESS-WS, Trojan-gRPC, VMess-WS, Shadowsocks, TUIC, MTProto, WireGuard — какие выберете |
|
||||
| **Подписка** (`kit-sub`) | одна ссылка на все устройства: Happ, Hiddify, v2rayN, Clash/Mihomo |
|
||||
| **Сайт-заглушка** | на 443 и в ответе Hysteria2 — правдоподобный ИТ-сайт из нашего набора |
|
||||
| **ufw**, обновления безопасности, синхронизация времени | защита сервера и корректная работа REALITY |
|
||||
|
||||
## Установка
|
||||
|
||||
Подключитесь к серверу по SSH и выполните:
|
||||
От root, на чистом сервере (Debian 11+ / Ubuntu 22.04+, 64-бит):
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh)
|
||||
bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
|
||||
```
|
||||
|
||||
Через пару минут скрипт покажет адрес панели, логин, пароль и подписку с QR-кодом.
|
||||
Без флагов скрипт **спрашивает** всё по ходу: какие протоколы ставить (Enter — REALITY + AmneziaWG-3.1),
|
||||
какую маскировку и домен, закрывать ли ответ на ping, включать ли автообновления безопасности,
|
||||
менять ли порт SSH (Enter — оставить 22), менять ли пароль root.
|
||||
|
||||
Подробно – подключение приложений, дополнительные пользователи и параметры –
|
||||
в **[инструкции](manuals/3x-ui.md)**. Нужен только Hysteria2 – есть
|
||||
[отдельный скрипт](manuals/hysteria2.md).
|
||||
<img alt="Установка одной командой" src="manuals/assets/script-3x-ui-local.svg" width="860">
|
||||
|
||||
> [!WARNING]
|
||||
> Проект создан в образовательных целях. Убедитесь, что ваши действия
|
||||
> соответствуют законодательству вашей страны.
|
||||
Если вопросы не нужны — те же ответы задаются флагами:
|
||||
|
||||
## Генератор конфигов для роутера
|
||||
```bash
|
||||
# минимум: свой домен и два протокола, без вопросов
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols reality,awg3 --domain vpn.example.com
|
||||
|
||||
Вставьте ссылку на сервер или подписку, выберите роутер и ядро, отметьте нужные сервисы –
|
||||
и получите готовый конфиг и одну команду, которая сама положит его на роутер.
|
||||
Всё считается в браузере, ссылки никуда не отправляются.
|
||||
Как поставить XKeen на роутер – в [инструкции для Keenetic](manuals/xkeen-keenetic.md).
|
||||
# всё, кроме WireGuard, и второй порт SSH
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols все --ssh-port 2222
|
||||
|
||||
| Роутер | Ядро | Что получится | Генератор |
|
||||
|---|---|---|---|
|
||||
| Keenetic (XKeen) | Xray | `04_outbounds.json` и `05_routing.json`: серверы, выбор сервисов, реклама, свои сайты | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?core=xray) |
|
||||
| Keenetic (XKeen) | Mihomo | `config.yaml` с автовыбором сервера, подпиской, Hysteria2, AmneziaWG и веб-панелью | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?core=mihomo) |
|
||||
| OpenWrt (Nikki), бета | Mihomo | профиль для Nikki и команда, которая его включит | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?router=openwrt) |
|
||||
# ничего не спрашивать: ping закрыть, автообновления включить, порт и пароль не трогать
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --yes
|
||||
```
|
||||
|
||||
## Полезное
|
||||
Проверить канал, ничего не меняя:
|
||||
|
||||
- [XKeen](https://github.com/jameszeroX/XKeen) и его [вики](https://github.com/jameszeroX/XKeen/wiki) – документация по маршрутизации на Keenetic
|
||||
- [XKeen UI](https://github.com/zxc-rv/XKeen-UI) – веб-интерфейс для XKeen
|
||||
- [IP-адреса для AmneziaWG](https://github.com/RockBlack-VPN/ip-address) – актуальные списки от RockBlack
|
||||
```bash
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --verify-only # подпись релиза и сумма установщика
|
||||
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --dry-run # что именно будет сделано
|
||||
```
|
||||
|
||||
## Поддержать проект
|
||||
### Флаги установки
|
||||
|
||||
Скрипты и инструкции бесплатные. Донат добровольный – он помогает оплачивать
|
||||
тестовые серверы и держать скрипты в актуальном состоянии. Спасибо! 💜
|
||||
|
||||
| Способ | |
|
||||
| Флаг | Что делает |
|
||||
|---|---|
|
||||
| Российской картой, СБП, Tinkoff Pay | [CloudTips](https://pay.cloudtips.ru/p/d4f9e3d1) |
|
||||
| Зарубежной картой, Apple Pay, Google Pay | [Buy Me a Coffee](https://buymeacoffee.com/relo.cate) |
|
||||
| USDT (TRC-20) | `TS83ViXrdezUpp1eFadqj1rBhGLZaba1c1` |
|
||||
| TON | `UQBchO4XFPwF9MMa_tjXpwqTo8IL2FhUDyllhYuFo8WM-Qbf` |
|
||||
| Ethereum (ERC-20) | `0xC06F6B3A029d7Ea00705B7028490744e2BC16799` |
|
||||
| `--protocols all\|minimal\|список` | какие протоколы ставить: `all`, `minimal` (только REALITY) или через запятую. Невыбранные не создаются вообще |
|
||||
| `--domain ваш.домен` | свой домен для маскировки (A-запись на сервер, свободный порт 80 — сертификат Let's Encrypt выпустит установщик) |
|
||||
| `--sni сайт.ком` | маскировка под чужой сайт вместо своего домена |
|
||||
| `--panel-on ip\|domain` | где открывать панель и подписку |
|
||||
| `--site N\|random\|off` | сайт-заглушка: конкретный из нашего набора, случайный (по умолчанию) или не трогать |
|
||||
| `--ssh-port N` | перевести SSH на порт N (прежний закроется только после вашей проверки) |
|
||||
| `--harden` / `--no-harden` | закрыть ping и включить автообновления безопасности / не трогать это |
|
||||
| `--yes` | не задавать вопросы (для автоматизации) |
|
||||
| `--host IP`, `--user имя`, `--port N`, `--restore файл` | адрес в ссылках, имя первого клиента, порт REALITY, восстановление из копии `kit backup` |
|
||||
| `--no-time`, `--no-check`, `--verify-only`, `--dry-run`, `-h` | служебные |
|
||||
|
||||
## Благодарности
|
||||
## Что происходит по шагам
|
||||
|
||||
3X-UI KIT построен на работе авторов этих проектов:
|
||||
[3X-UI](https://github.com/MHSanaei/3x-ui) ·
|
||||
[Xray-core](https://github.com/XTLS/Xray-core) ·
|
||||
[Mihomo](https://github.com/MetaCubeX/mihomo) ·
|
||||
[Hysteria](https://github.com/apernet/hysteria) ·
|
||||
[AmneziaWG](https://github.com/amnezia-vpn) ·
|
||||
[XKeen](https://github.com/jameszeroX/XKeen)
|
||||
1. **Проверка сервера**: root, Debian/Ubuntu, память, место на диске, занятые порты 80/443.
|
||||
2. **Проверка релиза**: файлы берутся из этого репозитория по тегу, проверяется **подпись `SHA256SUMS`**
|
||||
нашим ключом и SHA256 самого установщика. Не сошлось — ничего не запускается.
|
||||
3. **Выбор протоколов** и запуск установщика: панель 3X-UI, выбранные подключения, nginx на 443,
|
||||
сертификат, сайт-заглушка, служба подписки, правила ufw.
|
||||
4. **Сайт-заглушка**: ставится случайный правдоподобный ИТ-сайт из нашего набора (10 штук) и
|
||||
синхронизируется в ответ Hysteria2.
|
||||
5. **Наши вопросы и правки**: закрыть ответ на ping, включить обновления безопасности, сменить порт
|
||||
SSH, сменить пароль root.
|
||||
6. **`kit fix`** — синхронизация времени и безопасные починки.
|
||||
7. **Проверка**: `kit check` (порты, службы, подписка, сертификат, маскировка) и сводка
|
||||
`kit local hardening`. В конце — как забрать ссылки и подписку.
|
||||
|
||||
## Протоколы
|
||||
|
||||
Один порт 443 на всё: nginx разводит подключения по именам и путям, поэтому снаружи видно только
|
||||
443/tcp (и Hysteria2 на 443/udp). Список и умолчание — как у проекта: невыбранные протоколы не
|
||||
создаются вообще, ни подключения в панели, ни записей в nginx, ни ссылок в подписке.
|
||||
|
||||
| № | Протокол | Что это |
|
||||
|---|---|---|
|
||||
| 1 | **REALITY** | VLESS + Reality, основной вход. **По умолчанию** |
|
||||
| 2 | REALITY-2 | второй REALITY на случайном высоком порту |
|
||||
| 3 | Hysteria2 | быстрее на плохой сети (UDP 443) |
|
||||
| 4 | XHTTP | VLESS + Reality под другим именем маскировки |
|
||||
| 5 | VLESS-WS | VLESS через WebSocket |
|
||||
| 6 | Trojan-gRPC | Trojan через gRPC |
|
||||
| 7 | VMess-WS | VMess через WebSocket |
|
||||
| 8 | Shadowsocks | Shadowsocks 2022 |
|
||||
| 9 | TUIC | быстрый UDP-протокол |
|
||||
| 10 | WireGuard | обычный WireGuard: легко распознаётся, поэтому по умолчанию не ставится |
|
||||
| 11 | AmneziaWG | AmneziaWG: Mihomo, роутеры Keenetic |
|
||||
| 12 | **AmneziaWG-3.1** | AmneziaWG с защитой заголовков: приложения AmneziaVPN. **По умолчанию** |
|
||||
| 13 | MTProto | прокси для Telegram |
|
||||
|
||||
Наш набор по умолчанию — **REALITY + AmneziaWG-3.1**: работает почти везде и не требует
|
||||
дополнительных объяснений на клиенте. Маскировку и сайт можно менять потом: `kit net site`,
|
||||
`kit local site`.
|
||||
|
||||
## Сайт-заглушка
|
||||
|
||||
На 443 при неизвестном имени отдаётся страница нашего набора: небольшие правдоподобные ИТ-сайты
|
||||
вместо стандартных «страница в разработке».
|
||||
|
||||
| № | Сайт | Тема |
|
||||
|---|---|---|
|
||||
| 1 | Radian Analytics | дата-аналитика: хранилище, отчёты, дашборды |
|
||||
| 2 | Circuit Nine | DevOps-консалтинг: CI/CD, инфраструктура, наблюдаемость |
|
||||
| 3 | Cortex Monitor | мониторинг серверов: проверки, алерты, сертификаты |
|
||||
| 4 | Bluefin Software | разработка ПО на заказ |
|
||||
| 5 | Vantage Security | аудит и пентест |
|
||||
| 6 | Keystone IT | ИТ-поддержка офисов |
|
||||
| 7 | Meridian Networks | колокейшн и транзит |
|
||||
| 8 | Lumen Notes | заметки про Linux, nginx и сети |
|
||||
| 9 | Evercore Backup | бэкапы и восстановление |
|
||||
| 10 | Halyard Docs | документация проекта |
|
||||
|
||||
```bash
|
||||
kit local site # случайный сайт из набора
|
||||
kit local site --list # список с номерами
|
||||
kit local site 7 # поставить конкретный
|
||||
kit local site --show # какой стоит сейчас
|
||||
```
|
||||
|
||||
Менять страницу нужно **этой командой**: `/var/www/kit/index.html` отдаёт nginx, а Hysteria2 держит
|
||||
свою копию страницы внутри подключения. Команда обновляет обе — иначе 443/tcp и UDP начнут отвечать
|
||||
разными страницами, и это заметно снаружи.
|
||||
|
||||
## Команды
|
||||
|
||||
На сервере после установки доступна команда `kit` (без аргументов — меню).
|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `kit user …` | `add`, `list`, `link`, `limit`, `on`, `off`, `del` — пользователи, ссылки, лимиты трафика и сроков |
|
||||
| `kit net` | состояние подключений; `on`/`off` включить-выключить, `site` — сайт маскировки, `masq` — ответ Hysteria2 страницей, `dns` — DoH в подписках |
|
||||
| `kit port` / `kit port set <имя> <порт>` | какие порты слушают подключения / сменить порт подключения |
|
||||
| `kit reality add [порт]` | добавить второй REALITY |
|
||||
| `kit check [--deep\|--fix]`, `kit fix` | проверка сервера (в том числе с самого сервера, как клиент) и безопасные починки |
|
||||
| `kit update [--panel]`, `--auto`, `--manual` | обновление сборки и панели, режим автообновления |
|
||||
| `kit backup` | копия сервера (панель, сертификаты, настройки) |
|
||||
| `kit version` | версии сборки, панели и ядра |
|
||||
|
||||
Наш слой — `kit local` (справка: `kit local help`):
|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `kit local site` | сайт-заглушка: случайный, `--list`, номер, `--show` |
|
||||
| `kit local ssh 2222` / `--status` / `--close-old 2222` | добавить порт SSH / какие слушают / закрыть прежние после проверки |
|
||||
| `kit local ping off\|on\|status` | ответ на ICMP echo правилом ufw (видно в `ufw status`, отменяется одной командой) |
|
||||
| `kit local passwd [юзер]`, `--generate` | смена пароля (спрашивает дважды, в логи не попадает) |
|
||||
| `kit local upgrades on\|off\|status` | обновления безопасности |
|
||||
| `kit local hardening`, `masking`, `channel`, `seams`, `version` | сводка защиты, состояние маскировки, канал обновлений, какие места форк правит у автора |
|
||||
|
||||
У любой правки есть `--dry-run` — показывает план и ничего не меняет. Копии изменённых файлов
|
||||
складываются в `/root/kit-local-backup-<дата>/`.
|
||||
|
||||
## Обновление и канал
|
||||
|
||||
- Сервер обновляется из **этого репозитория**: `kit update` и ночной таймер берут релиз с подписью
|
||||
нашего ключа. Чужой релиз сервер не примет.
|
||||
- Посмотреть, откуда берутся обновления и чем проверяется подпись: `kit local channel`.
|
||||
- Управление автообновлением: `kit update --auto` (включить) и `kit update --manual` (выключить),
|
||||
`kit update` — обновиться сейчас, `kit update --panel` — обновить саму панель 3X-UI.
|
||||
- Проверить канал, ничего не устанавливая: установщик с `--verify-only` или `kit local channel`.
|
||||
|
||||
## Оговорки
|
||||
|
||||
- **Пароль root** никогда не передаётся флагом: значение в командной строке видно другим процессам.
|
||||
Спрашивается в ходе установки или меняется `kit local passwd`.
|
||||
- **Смена порта SSH** делается в два шага: новый порт добавляется, прежний продолжает работать, пока
|
||||
вы не проверите вход во втором окне. Закрыть прежние: `kit local ssh --close-old 2222`.
|
||||
- **`apt full-upgrade` не выполняется** — только обновления безопасности: панель и ядро Xray
|
||||
закреплены на проверенных версиях.
|
||||
- Версии панели и ядра в сборке сверяются с официальными хешами при выпуске релиза.
|
||||
|
||||
## Документация форка
|
||||
|
||||
| Файл | О чём |
|
||||
|---|---|
|
||||
| [FORK.md](FORK.md) | как устроен форк: где наши правки (швы), регламент обновления из авторского проекта, канал, подпись, грабли |
|
||||
| [FORK-CHANGELOG.md](FORK-CHANGELOG.md) | что менялось в наших версиях |
|
||||
| `kit local help` | команды форка прямо на сервере |
|
||||
|
||||
+8
-6
@@ -1,6 +1,8 @@
|
||||
# 3X-UI KIT 1.1
|
||||
313d8d47f425b51dc5888af7fd2015dd99f44c1fac0022f140ef8de91b35d99f scripts/3x-ui.sh
|
||||
3497f4b55aedc91940dd03a3bf0d3c8153f5e86c0c9a4a4895f38da540a645e6 scripts/hysteria2.sh
|
||||
f585d789d82a326ae1775f74494e7230178969b8a9e8c1fd382c5092877c8d73 scripts/kit.sh
|
||||
aae32dea127a50cf6e5b30915a21168d1a88f5c1010d641c19820a52d50afef3 scripts/kit-sub.py
|
||||
1523e9e982ff804c6d979e1bb2c6f3d7bae35307e8ce458bffc0d12a442b98da VERSION
|
||||
# 3X-UI KIT 1.2.6
|
||||
db677f17e1c8f59e0c5f329faff6f6178454b75b266966b80c225b8923318831 scripts/3x-ui.sh
|
||||
b8bc9cb115e427b601d2d9ea0b3e1c759b4fc0f9f36b267e364e47658422e37d scripts/hysteria2.sh
|
||||
1215b3435a5f476dee26fd732640c6d33a7445585f14108a289ae4eafb05b439 scripts/kit.sh
|
||||
d393dc04a69bad2ce9f022b2f51e0d349b6655c5c92fc64cf67402c5a63e2f3a scripts/kit-sub.py
|
||||
ae81cd252affc1bbfdc59421dfa30f85eb6bbbd3e178faceb7e6f708f36b6730 scripts/kit-local.sh
|
||||
eaf72eb353c14de8bc9cd567b396efc40eb1493c3839009465f0f8b35f0db557 scripts/3x-ui-local.sh
|
||||
5844ffcc346f89c07b13ba7596bfb3788ed73f4755e541182d7822d43b7c7a24 VERSION
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
-----BEGIN SSH SIGNATURE-----
|
||||
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgatMYIOGGebxc7V6Sk8m5JZFUIm
|
||||
IWK6kSyl9DFxsQGbMAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
|
||||
AAALc3NoLWVkMjU1MTkAAABAsmJHzAWZ9uDnSG5hoMfvH/vgx2rI3qG33quMNBAVsAE5c1
|
||||
zi16FA4+hxyGYqRsxN/l30ZfyfURrpRKAAoMQHBw==
|
||||
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV
|
||||
FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
|
||||
AAALc3NoLWVkMjU1MTkAAABAHRB6TB0NGpmJ1w5Ua1PEGUrmlQUuM99cll5nnhO9EJvMRz
|
||||
IoDmvnCMGC4Xgh22IeRR88vtLkKz3hgtMoMYRLAg==
|
||||
-----END SSH SIGNATURE-----
|
||||
|
||||
+110
-18
@@ -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,17 +38,16 @@
|
||||
|
||||
**Все 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 по умолчанию не ставится: в России его рукопожатие режет DPI,
|
||||
> а попытки подключения могут привести к блокировке IP сервера. AmneziaWG – тот же WireGuard
|
||||
> с маскировкой – проходит. Если сервер и пользователи за границей, добавьте `wg` в `--protocols`.
|
||||
> Обычный WireGuard по умолчанию не ставится: его рукопожатие легко распознаётся сетевым
|
||||
> оборудованием, из-за чего соединение нестабильно. AmneziaWG – тот же WireGuard с маскировкой –
|
||||
> работает лучше. Если сервер и пользователи за границей, добавьте `wg` в `--protocols`.
|
||||
|
||||
## Что понадобится
|
||||
|
||||
@@ -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,30 +140,117 @@ kit version # версии kit, панели и ядра, включе
|
||||
Сервер ставили версией 1.0? В ней команды `kit update` ещё нет, поэтому один раз:
|
||||
|
||||
```bash
|
||||
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
|
||||
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 check --deep # то же и подключиться клиентом (REALITY, XHTTP, Hysteria2) с самого сервера
|
||||
kit check --fix # исправить безопасное
|
||||
```
|
||||
|
||||
`kit check` проверяет службы, что ядро Xray запущено и каждое включённое подключение слушает свой порт (TCP и UDP
|
||||
отдельно), версии, срок сертификата, что панель не торчит наружу, права на файлы с
|
||||
паролями и ключами, ответ подписки для Happ, Hiddify, v2rayN и Clash, а также что сайт маскировки отвечает по
|
||||
TLS 1.3 и HTTP/2, и подсказывает, что улучшить (сайт маскировки, лишние порты). Вывод можно просто прислать в чат, если нужна помощь.
|
||||
|
||||
`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 имя`.
|
||||
|
||||
## Резервная копия
|
||||
|
||||
```bash
|
||||
kit backup
|
||||
```
|
||||
|
||||
Сохранит в `/root/kit-backup-ДАТА.tar.gz` всё, что нужно, чтобы поднять тот же сервер в другом
|
||||
месте: подключения, ключи, пользователей с их лимитами, настройки подписки и nginx. Файл виден
|
||||
только root, и в нём ключи от сервера, поэтому храните его как пароль. Скачать к себе:
|
||||
Сохранит в `/root/kit-backup-ДАТА.tar.gz` копию сервера: подключения, ключи, пользователей с их
|
||||
лимитами, настройки подписки и nginx. Файл виден только root, и в нём ключи от сервера, поэтому
|
||||
храните его как пароль. Скачать к себе:
|
||||
|
||||
```bash
|
||||
scp root@IP:/root/kit-backup-ДАТА.tar.gz .
|
||||
```
|
||||
|
||||
Восстановление из копии на новом VPS пока в разработке. Сейчас копия нужна как страховка:
|
||||
храните её отдельно от сервера.
|
||||
Храните копию отдельно от сервера.
|
||||
|
||||
Новый чистый сервер поднимается из копии одной командой (нужны `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…») и ничего не изменит.
|
||||
|
||||
## Параметры
|
||||
|
||||
@@ -196,4 +288,4 @@ x-ui
|
||||
|
||||
---
|
||||
|
||||
[← На главную](../README.md) · [Дальше: 🚀 Hysteria2 →](hysteria2.md)
|
||||
[← На главную](../README.md)
|
||||
|
||||
+20
-22
@@ -1,26 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="300" viewBox="0 0 1200 300" role="img" aria-label="3X-UI KIT: свой VPN-сервер, все протоколы, один порт, одна подписка"><title>3X-UI KIT: свой VPN-сервер, все протоколы, один порт, одна подписка</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="300" viewBox="0 0 1200 300" role="img" aria-label="3X-UI KIT: установка сервера одной командой"><title>3X-UI KIT: установка сервера одной командой</title>
|
||||
<defs><clipPath id="r"><rect width="1200" height="300" rx="20"/></clipPath></defs>
|
||||
<g clip-path="url(#r)"><rect width="1200" height="300" fill="#1a1411"/><path d="M60 0V300" stroke="#1d1713" stroke-width="2"/><path d="M156 0V300" stroke="#1d1713" stroke-width="2"/><path d="M252 0V300" stroke="#1d1713" stroke-width="2"/><path d="M348 0V300" stroke="#1d1713" stroke-width="2"/><path d="M444 0V300" stroke="#1d1713" stroke-width="2"/><path d="M540 0V300" stroke="#1d1713" stroke-width="2"/><path d="M636 0V300" stroke="#1d1713" stroke-width="2"/><path d="M732 0V300" stroke="#1d1713" stroke-width="2"/><path d="M828 0V300" stroke="#1d1713" stroke-width="2"/><path d="M924 0V300" stroke="#1d1713" stroke-width="2"/><path d="M1020 0V300" stroke="#1d1713" stroke-width="2"/><path d="M1116 0V300" stroke="#1d1713" stroke-width="2"/>
|
||||
<rect y="262" width="1200" height="38" fill="#120e0c"/></g>
|
||||
<g clip-path="url(#r)"><rect width="1200" height="300" fill="#1a1411"/><path d="M60 0V300" stroke="#1d1713" stroke-width="2"/><path d="M156 0V300" stroke="#1d1713" stroke-width="2"/><path d="M252 0V300" stroke="#1d1713" stroke-width="2"/><path d="M348 0V300" stroke="#1d1713" stroke-width="2"/><path d="M444 0V300" stroke="#1d1713" stroke-width="2"/><path d="M540 0V300" stroke="#1d1713" stroke-width="2"/><path d="M636 0V300" stroke="#1d1713" stroke-width="2"/><path d="M732 0V300" stroke="#1d1713" stroke-width="2"/><path d="M828 0V300" stroke="#1d1713" stroke-width="2"/><path d="M924 0V300" stroke="#1d1713" stroke-width="2"/><path d="M1020 0V300" stroke="#1d1713" stroke-width="2"/><path d="M1116 0V300" stroke="#1d1713" stroke-width="2"/><rect y="268" width="1200" height="32" fill="#120e0c"/></g>
|
||||
<rect x="1.5" y="1.5" width="1197" height="297" rx="19" fill="none" stroke="#15110F" stroke-width="3"/>
|
||||
<g font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="66" letter-spacing="1">
|
||||
<text x="80" y="108" fill="#15110F" stroke="#15110F" stroke-width="14" stroke-linejoin="round">3X-UI KIT</text>
|
||||
<text x="80" y="108" fill="#FBF6EC" style="paint-order:stroke">3X-UI KIT</text></g>
|
||||
<g font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="42" letter-spacing="1">
|
||||
<text x="82" y="164" fill="#8A66C4" stroke="#15110F" stroke-width="10" stroke-linejoin="round" transform="translate(3 4)">СВОЙ VPN ЗА МИНУТЫ</text>
|
||||
<text x="82" y="164" fill="#93E06F" stroke="#15110F" stroke-width="8" stroke-linejoin="round" style="paint-order:stroke">СВОЙ VPN ЗА МИНУТЫ</text></g>
|
||||
<text x="82" y="204" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-size="19" font-weight="500" fill="#CBBFA9">11 протоколов, один порт 443, одна подписка на все приложения</text>
|
||||
<g><rect x="77" y="228" width="115" height="38" rx="10" fill="#15110F"/><rect x="74" y="225" width="115" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="90" cy="244" r="4.5" fill="#93E06F"/><text x="102" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">REALITY</text></g><g><rect x="204" y="228" width="135" height="38" rx="10" fill="#15110F"/><rect x="201" y="225" width="135" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="217" cy="244" r="4.5" fill="#93E06F"/><text x="229" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">Hysteria2</text></g><g><rect x="351" y="228" width="135" height="38" rx="10" fill="#15110F"/><rect x="348" y="225" width="135" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="364" cy="244" r="4.5" fill="#93E06F"/><text x="376" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">AmneziaWG</text></g><g><rect x="498" y="228" width="95" height="38" rx="10" fill="#15110F"/><rect x="495" y="225" width="95" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="511" cy="244" r="4.5" fill="#93E06F"/><text x="523" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">XHTTP</text></g><g><rect x="605" y="228" width="84" height="38" rx="10" fill="#15110F"/><rect x="602" y="225" width="84" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="618" cy="244" r="4.5" fill="#93E06F"/><text x="630" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">TUIC</text></g><g><rect x="701" y="228" width="84" height="38" rx="10" fill="#15110F"/><rect x="698" y="225" width="84" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="714" cy="244" r="4.5" fill="#93E06F"/><text x="726" y="250" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="15" fill="#FBF6EC">:443</text></g>
|
||||
<g transform="rotate(2 940 130)"><rect x="763" y="54" width="360" height="156" rx="14" fill="#15110F"/>
|
||||
<rect x="758" y="48" width="360" height="156" rx="14" fill="#221B17" stroke="#15110F" stroke-width="4"/>
|
||||
<circle cx="780" cy="68" r="6" fill="#ff5f57"/><circle cx="800" cy="68" r="6" fill="#febc2e"/><circle cx="820" cy="68" r="6" fill="#28c840"/>
|
||||
<text x="778" y="108" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="14" fill="#CBBFA9">$ bash 3x-ui.sh</text>
|
||||
<text x="778" y="136" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="800" font-size="14" fill="#93E06F">Готово! 3X-UI работает:</text>
|
||||
<text x="778" y="158" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="800" font-size="14" fill="#93E06F">11 протоколов.</text>
|
||||
<text x="778" y="186" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="13" fill="#CBBFA9">https://IP/sub/…</text></g>
|
||||
<g transform="rotate(-5 1000 236)"><rect x="880" y="222" width="236" height="46" rx="6" fill="#15110F" transform="translate(4 5)"/>
|
||||
<rect x="880" y="222" width="236" height="46" rx="6" fill="#F2E8D2" stroke="#15110F" stroke-width="3"/>
|
||||
<circle cx="893" cy="235" r="3.2" fill="#15110F"/><circle cx="1103" cy="235" r="3.2" fill="#15110F"/>
|
||||
<text x="998" y="253" text-anchor="middle" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="20" fill="#15110F" letter-spacing=".5">БЕЗ ДОМЕНА</text></g>
|
||||
<text x="1176" y="288" text-anchor="end" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-size="13" font-weight="500" fill="#CBBFA9" font-style="italic">it's not Kubrick. it's just a VPN.</text>
|
||||
<text x="80" y="104" fill="#15110F" stroke="#15110F" stroke-width="14" stroke-linejoin="round">3X-UI KIT</text>
|
||||
<text x="80" y="104" fill="#FBF6EC" style="paint-order:stroke">3X-UI KIT</text></g>
|
||||
<g font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="35" letter-spacing="1">
|
||||
<text x="82" y="156" fill="#8A66C4" stroke="#15110F" stroke-width="10" stroke-linejoin="round" transform="translate(3 4)">УСТАНОВКА ОДНОЙ КОМАНДОЙ</text>
|
||||
<text x="82" y="156" fill="#93E06F" stroke="#15110F" stroke-width="8" stroke-linejoin="round" style="paint-order:stroke">УСТАНОВКА ОДНОЙ КОМАНДОЙ</text></g>
|
||||
<g><rect x="77" y="190" width="130" height="38" rx="10" fill="#15110F"/><rect x="74" y="186" width="130" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="91" cy="205" r="4.5" fill="#93E06F"/><text x="104" y="211" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">Порт 443</text></g><g><rect x="219" y="190" width="183" height="38" rx="10" fill="#15110F"/><rect x="216" y="186" width="183" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="233" cy="205" r="4.5" fill="#93E06F"/><text x="246" y="211" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">Одна подписка</text></g><g><rect x="414" y="190" width="205" height="38" rx="10" fill="#15110F"/><rect x="411" y="186" width="205" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="428" cy="205" r="4.5" fill="#93E06F"/><text x="441" y="211" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">С доменом и без</text></g><g><rect x="77" y="238" width="141" height="38" rx="10" fill="#15110F"/><rect x="74" y="234" width="141" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="91" cy="253" r="4.5" fill="#93E06F"/><text x="104" y="259" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">kit check</text></g><g><rect x="230" y="238" width="194" height="38" rx="10" fill="#15110F"/><rect x="227" y="234" width="194" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="244" cy="253" r="4.5" fill="#93E06F"/><text x="257" y="259" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">Автообновление</text></g><g><rect x="436" y="238" width="236" height="38" rx="10" fill="#15110F"/><rect x="433" y="234" width="236" height="38" rx="10" fill="#2C231D" stroke="#15110F" stroke-width="3"/><circle cx="450" cy="253" r="4.5" fill="#93E06F"/><text x="463" y="259" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="800" font-size="16" fill="#FBF6EC">Keenetic и OpenWrt</text></g>
|
||||
<g transform="rotate(2 940 110)"><rect x="783" y="40" width="360" height="140" rx="14" fill="#15110F"/>
|
||||
<rect x="778" y="34" width="360" height="140" rx="14" fill="#221B17" stroke="#15110F" stroke-width="4"/>
|
||||
<circle cx="800" cy="54" r="6" fill="#ff5f57"/><circle cx="820" cy="54" r="6" fill="#febc2e"/><circle cx="840" cy="54" r="6" fill="#28c840"/>
|
||||
<text x="798" y="92" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="14" fill="#CBBFA9">$ bash 3x-ui.sh</text>
|
||||
<text x="798" y="116" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="13" fill="#CBBFA9">==> Ставлю 3X-UI…</text>
|
||||
<text x="798" y="142" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="800" font-size="14" fill="#93E06F">Готово! 3X-UI работает.</text>
|
||||
<text x="798" y="164" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="13" fill="#CBBFA9">https://IP/sub/…</text></g>
|
||||
<g transform="rotate(-4 1000 222)"><rect x="854" y="206" width="292" height="52" rx="6" fill="#15110F" transform="translate(4 5)"/>
|
||||
<rect x="854" y="206" width="292" height="52" rx="6" fill="#F2E8D2" stroke="#15110F" stroke-width="3"/>
|
||||
<circle cx="868" cy="219" r="3.2" fill="#15110F"/><circle cx="1132" cy="219" r="3.2" fill="#15110F"/>
|
||||
<text x="1000" y="241" text-anchor="middle" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-weight="900" font-size="23" fill="#15110F" letter-spacing=".5">11 ПРОТОКОЛОВ</text></g>
|
||||
<text x="1176" y="290" text-anchor="end" font-family="Rubik,'Arial Black','Helvetica Neue',Arial,system-ui,sans-serif" font-size="13" font-weight="500" fill="#CBBFA9" font-style="italic">it's not Kubrick. it's just a VPN.</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="190" viewBox="0 0 860 190" role="img" aria-label="3X-UI autoVPS — сборка форка">
|
||||
<style>.t{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
|
||||
.m{font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}</style>
|
||||
<rect x="0.5" y="0.5" width="859" height="189" rx="16" fill="#15110f" stroke="#43372e"/>
|
||||
<circle cx="748" cy="46" r="58" fill="#93e06f" opacity="0.07"/>
|
||||
<circle cx="808" cy="140" r="34" fill="#93e06f" opacity="0.05"/>
|
||||
<text x="44" y="86" class="t" font-size="46" font-weight="700" fill="#fbf6ec">3X-UI <tspan fill="#93e06f">autoVPS</tspan></text>
|
||||
<text x="44" y="118" class="t" font-size="16" fill="#cbbfa9">сервер одной командой: панель 3X-UI, VPN-протоколы, один порт 443, одна подписка</text>
|
||||
<text x="44" y="152" class="m" font-size="13" fill="#8a8073">свой канал обновлений · подписанные релизы · 13 протоколов · 10 сайтов-заглушек</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="305" viewBox="0 0 860 305" role="img" aria-label="root@vps: ~"><title>root@vps: ~</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="859" height="304" rx="14" fill="#15110f" stroke="#43372e" stroke-width="1" /><path d="M1 44 H859" stroke="#43372e"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="430.0" y="27" class="m" font-size="13" fill="#cbbfa9" font-weight="500" text-anchor="middle">root@vps: ~</text><text x="22" y="70" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="70" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 add friend</text><text x="22" y="91" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="91" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователь friend добавлен.</text><text x="22" y="112" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2://friend:4b7d…@hy.example.com:443/?sni=hy.example.com#friend</text><text x="22" y="133" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="154" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="154" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 list</text><text x="22" y="175" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователи:</text><text x="22" y="196" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"> • admin</text><text x="22" y="217" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"> • friend</text><text x="22" y="238" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="259" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="259" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2 del friend</text><text x="22" y="280" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="280" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Пользователь friend удалён, его ссылка больше не работает.</text></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="368" viewBox="0 0 860 368" role="img" aria-label="root@vps: ~"><title>root@vps: ~</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="859" height="367" rx="14" fill="#15110f" stroke="#43372e" stroke-width="1" /><path d="M1 44 H859" stroke="#43372e"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="430.0" y="27" class="m" font-size="13" fill="#cbbfa9" font-weight="500" text-anchor="middle">root@vps: ~</text><text x="22" y="70" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve"># </text><text x="38.260000000000005" y="70" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">bash <(curl -fsSL https://raw.githubusercontent.com/…/scripts/hysteria2.sh)</text><text x="22" y="91" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="112" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Установка Hysteria2</text><rect x="10" y="118" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="128.5" r="11" fill="#93e06f"/><text x="832" y="133.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">1</text><text x="22" y="133" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Домен (Enter – без домена): </text><text x="249.64000000000001" y="133" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">hy.example.com</text><rect x="10" y="139" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="149.5" r="11" fill="#93e06f"/><text x="832" y="154.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">1</text><text x="22" y="154" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Почта для Let's Encrypt: </text><text x="225.25000000000003" y="154" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">you@example.com</text><text x="22" y="175" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="175" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Скачиваю Hysteria 2.12.3 (amd64) с GitHub</text><text x="22" y="196" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="196" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Контрольная сумма совпала: 8c7a68a906998b74…</text><text x="22" y="217" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="217" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Настраиваю ufw: SSH 22/tcp, Hysteria 443/udp, сайт 80/tcp и 443/tcp</text><text x="22" y="238" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">==> </text><text x="54.52" y="238" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">Запускаю Hysteria и получаю сертификат для hy.example.com</text><text x="22" y="259" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><text x="22" y="280" class="m" font-size="13.5" fill="#93e06f" xml:space="preserve">Готово! Hysteria2 работает.</text><text x="22" y="301" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve"></text><rect x="10" y="307" width="840" height="22" rx="5" fill="#93e06f22" stroke="#93e06f" stroke-width="1" /><circle cx="832" cy="317.5" r="11" fill="#93e06f"/><text x="832" y="322.0" class="s" font-size="12" fill="#15110f" font-weight="700" text-anchor="middle">2</text><text x="22" y="322" class="m" font-size="13.5" fill="#fbf6ec" xml:space="preserve">hy2://admin:9c1e…@hy.example.com:443/?sni=hy.example.com#admin</text><text x="22" y="343" class="m" font-size="13.5" fill="#cbbfa9" xml:space="preserve">[ здесь QR-код – отсканируйте его телефоном ]</text></svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,77 +0,0 @@
|
||||
# 🚀 Hysteria2
|
||||
|
||||
[← На главную](../README.md)
|
||||
|
||||
[Hysteria2](https://github.com/HyNetworks/hysteria) работает поверх UDP и
|
||||
хорошо держит скорость на плохих каналах – отличная пара к REALITY. Одна
|
||||
команда ставит сервер, сертификат и сайт-заглушку и выдаёт готовую ссылку.
|
||||
|
||||
**Что делает [скрипт](../scripts/hysteria2.sh):**
|
||||
|
||||
- ставит официальный Hysteria2 проверенной версии и сверяет контрольную сумму;
|
||||
- с доменом – получает сертификат Let's Encrypt, без домена – делает свой
|
||||
и закрепляет его отпечаток прямо в ссылке;
|
||||
- поднимает сайт-заглушку: при заходе на ваш домен откроется страница
|
||||
«RETRYING CONNECTION»;
|
||||
- создаёт пользователя со случайным паролем и открывает нужные порты в ufw;
|
||||
- выдаёт ссылку `hy2://` и QR-код;
|
||||
- ставит команду `hy2`, чтобы добавлять и удалять дополнительных пользователей без правки конфигов.
|
||||
|
||||
## Что понадобится
|
||||
|
||||
- VPS с Ubuntu 22.04/24.04 или Debian 12/13, доступ root по SSH
|
||||
- По желанию – домен, A-запись которого указывает на IP сервера
|
||||
|
||||
## Установка
|
||||
|
||||
Подключитесь к серверу по SSH и выполните:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/hysteria2.sh)
|
||||
```
|
||||
|
||||

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

|
||||
|
||||
| Команда | Что делает |
|
||||
|---|---|
|
||||
| `hy2 add имя` | добавить пользователя и показать его ссылку |
|
||||
| `hy2 del имя` | удалить – его ссылка сразу перестанет работать |
|
||||
| `hy2 list` | список пользователей |
|
||||
| `hy2 link имя` | ссылка и QR-код ещё раз |
|
||||
| `hy2 status` | версия, адрес и состояние |
|
||||
| `hy2 restart` | перезапустить, например после исправления DNS |
|
||||
| `hy2 update` | обновить до новой проверенной версии |
|
||||
| `hy2 uninstall` | удалить всё |
|
||||
|
||||
## Если что-то пошло не так
|
||||
|
||||
- **«Не удалось получить сертификат»** – A-запись домена ещё не обновилась
|
||||
или порт 80 закрыт у хостера. Исправьте и выполните `hy2 restart`.
|
||||
- **Клиент не подключается** – проверьте, что у хостера открыт UDP-порт 443
|
||||
(у некоторых хостеров есть свой файрвол в личном кабинете).
|
||||
- **Порт 443 занят** – поставьте на другой: добавьте к команде установки `--port 8443`.
|
||||
|
||||
---
|
||||
|
||||
[← На главную](../README.md) · [Дальше: 📶 XKeen на Keenetic →](xkeen-keenetic.md)
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env bash
|
||||
# 3X-UI KIT (форк hacker07) — установка сервера одной командой из нашего канала.
|
||||
#
|
||||
# Что делает (по шагам):
|
||||
# 1) проверяет сервер и наличие curl, ssh-keygen, openssl;
|
||||
# 2) берёт из нашего Gitea релиз своей же версии, проверяет ПОДПИСЬ SHA256SUMS и SHA256 установщика;
|
||||
# 3) запускает установщик автора (scripts/3x-ui.sh) — панель 3X-UI, протоколы, сайт-заглушка,
|
||||
# маскировка, сертификат, подписка, ufw. Флаги установщика можно передавать как есть,
|
||||
# а можно ничего не передавать — тогда он задаст вопросы сам (в том числе про домен);
|
||||
# 4) спрашивает, какие протоколы ставить: невыбранные не создаются вообще — ни подключения в панели,
|
||||
# ни записей в nginx, ни ссылок в подписке (за это отвечает флаг автора --protocols);
|
||||
# 5) ставит сайт-заглушку: случайный правдоподобный ИТ-сайт из нашего набора (10 штук;
|
||||
# --site N — конкретный, --no-site — не трогать авторскую страницу);
|
||||
# 6) спрашивает и делает то, чего у автора нет: закрыть ответ на ping, включить обновления
|
||||
# безопасности, перевести SSH на другой порт, сменить пароль root (поведение из StressOzz/vps-setup,
|
||||
# но безопаснее);
|
||||
# 7) в конце: kit fix (время и безопасные починки), kit check, kit local hardening.
|
||||
#
|
||||
# Запуск:
|
||||
# # одна команда, дальше — вопросы:
|
||||
# bash <(curl -fsSL https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS/raw/branch/main/scripts/3x-ui-local.sh)
|
||||
# # или сразу с ответами, без вопросов:
|
||||
# ... 3x-ui-local.sh --domain vpn.example.com
|
||||
# ... 3x-ui-local.sh --sni dl.google.com --no-harden --ssh-port 2222
|
||||
# # без вопросов, всё по умолчанию (для автоматизации):
|
||||
# ... 3x-ui-local.sh --yes
|
||||
#
|
||||
# Флаги этого скрипта (остальные уходят установщику автора):
|
||||
# --domain ДОМЕН, --sni САЙТ, --panel-on ip|domain, --host IP, --user ИМЯ, --port N, --restore ФАЙЛ
|
||||
# --harden закрыть ping и включить автообновления, без вопросов
|
||||
# --no-harden не трогать ping и автообновления
|
||||
# --ssh-port N добавить порт SSH N (прежний порт продолжает работать)
|
||||
# --yes ни о чём не спрашивать (ping закрою, автообновления включу, порт и пароль — нет)
|
||||
# --no-time не запускать kit fix
|
||||
# --no-check не запускать в конце kit check
|
||||
# --verify-only только проверить релиз (подпись и SHA256) и выйти
|
||||
# --dry-run показать план и ничего не делать
|
||||
# -h | --help эта справка
|
||||
#
|
||||
# Наш файл: авторские релизы его не перезаписывают, а правки форка живут в scripts/kit-local.sh.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
KIT_VERSION="1.2.6" # версия сборки форка; должна совпадать с VERSION релиза
|
||||
GITEA="${KIT_FORK_BASE:-https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS}"
|
||||
NS="3x-ui-kit-release"
|
||||
SIG_ID="releases@3x-ui-kit"
|
||||
# Публичный ключ форка. Меняется только вместе с ключом подписи (тогда обновляется и здесь, и в kit).
|
||||
SIGNER_PUB='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI 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/branch/main/scripts/3x-ui-local.sh) [флаги…]
|
||||
|
||||
Без флагов скрипт спрашивает: какие протоколы ставить (невыбранные не создаются вообще),
|
||||
маскировку и домен — это вопросы установщика автора; затем про ответ на ping, автообновления,
|
||||
второй порт SSH и пароль root — это наши вопросы.
|
||||
|
||||
Флаги, которые скрипт передаёт установщику автора как есть:
|
||||
--domain vpn.example.com свой домен для маскировки (A-запись на этот сервер, свободный 80)
|
||||
--sni сайт.com маскировка под чужой сайт
|
||||
--panel-on domain | ip где открывать панель и подписку
|
||||
--host IP, --user имя, --port N, --protocols …, --restore файл
|
||||
|
||||
Флаги этого скрипта:
|
||||
--harden закрыть ping и включить автообновления, без вопросов
|
||||
--no-harden не трогать ping и автообновления
|
||||
--ssh-port N перевести SSH на порт N (прежний закроется после проверки входа)
|
||||
--yes ни о чём не спрашивать (для автоматизации)
|
||||
--site N|random|off сайт-заглушка: конкретный из нашего набора, случайный (по умолчанию) или
|
||||
не трогать авторскую страницу (то же: --no-site)
|
||||
--no-time не запускать kit fix (время и безопасные починки)
|
||||
--no-check не запускать в конце kit check
|
||||
--verify-only только проверить релиз (подпись и SHA256) и выйти
|
||||
--dry-run показать план и ничего не делать
|
||||
-h | --help эта справка
|
||||
EOF
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ разбор флагов
|
||||
|
||||
INSTALLER_ARGS=(); harden=auto; ssh_port=""; fix_time=yes; do_check=yes; dry=no; verify_only=no; assume_yes=no
|
||||
site=random # заглушка: случайный сайт из нашего набора (off — не трогать)
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--harden) harden=yes ;;
|
||||
--no-harden) harden=no ;;
|
||||
--ssh-port) ssh_port=${2:-}; shift || die "--ssh-port: нужен номер порта" ;;
|
||||
--ssh-port=*) ssh_port=${1#*=} ;;
|
||||
--yes | -y) assume_yes=yes ;;
|
||||
--site) site=${2:-random}; shift || die "--site: нужен номер сайта (1..10), random или off" ;;
|
||||
--site=*) site=${1#*=} ;;
|
||||
--no-site) site=off ;;
|
||||
--no-time) fix_time=no ;;
|
||||
--no-check) do_check=no ;;
|
||||
--verify-only) verify_only=yes ;;
|
||||
--dry-run) dry=yes ;;
|
||||
-h | --help) usage; exit 0 ;;
|
||||
*) INSTALLER_ARGS+=("$1") ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[[ -z $ssh_port ]] || { [[ $ssh_port =~ ^[0-9]{1,5}$ ]] && ((10#$ssh_port >= 1024 && 10#$ssh_port <= 65535)); } \
|
||||
|| die "--ssh-port: нужно число 1024–65535, например 2222"
|
||||
if [[ $site != off && $site != random ]]; then
|
||||
# 10 — число сайтов в наборе (scripts/kit-local.sh, KL_SITE_TITLES); добавите сайт — поднимите границу
|
||||
[[ $site =~ ^[0-9]{1,2}$ ]] && ((10#$site >= 1 && 10#$site <= 10)) \
|
||||
|| die "--site: random, off или номер 1..10 (список: kit local site --list)"
|
||||
fi
|
||||
|
||||
# Если --protocols задан флагом — не спрашиваем и не дублируем его.
|
||||
proto_flag=""
|
||||
for ((_i = 0; _i < ${#INSTALLER_ARGS[@]}; _i++)); do
|
||||
[[ ${INSTALLER_ARGS[_i]} == --protocols ]] && proto_flag=${INSTALLER_ARGS[_i + 1]:-all}
|
||||
done
|
||||
|
||||
pre() { printf ' %s\n' "$*"; }
|
||||
ASK_SRC=${KIT_ASK_SRC:-/dev/tty} # откуда читать ответы (в тестах подменяется файлом)
|
||||
interactive() { [[ $assume_yes == no && -r $ASK_SRC ]]; }
|
||||
|
||||
if [[ $dry == yes ]]; then
|
||||
say "план установки (--dry-run: ничего не делаю)"
|
||||
pre "версия сборки: $KIT_VERSION"
|
||||
pre "откуда: $GITEA (тег v$KIT_VERSION), подпись проверяется ключом форка"
|
||||
if ((${#INSTALLER_ARGS[@]})); then pre "установщик автора: bash scripts/3x-ui.sh ${INSTALLER_ARGS[*]}"; else pre "установщик автора: bash scripts/3x-ui.sh — спросит про маскировку и домен"; fi
|
||||
if [[ $harden == auto ]]; then
|
||||
if interactive; then pre "защита: спрошу про ответ на ping и автообновления"
|
||||
else pre "защита: без терминала — закрыть ping и включить автообновления"; fi
|
||||
elif [[ $harden == yes ]]; then pre "защита: закрыть ping и включить автообновления (--harden)"
|
||||
else pre "защита: пропускаю (--no-harden)"; fi
|
||||
if [[ -n $proto_flag ]]; then pre "протоколы: ${proto_flag} (задано флагом)"
|
||||
elif interactive; then pre "протоколы: спрошу (Enter — REALITY + AmneziaWG-3.1, «все» — набор автора)"
|
||||
else pre "протоколы: REALITY + AmneziaWG-3.1 (без терминала вопроса нет)"; fi
|
||||
if [[ -n $ssh_port ]]; then pre "порт SSH: перевожу на $ssh_port (прежний — после проверки входа)"
|
||||
elif interactive; then pre "порт SSH: спрошу (Enter — оставить текущий)"; fi
|
||||
if interactive; then pre "пароль root: спрошу (по умолчанию — не менять)"; fi
|
||||
case $site in
|
||||
off) pre "заглушка: оставляю авторскую страницу (--no-site)" ;;
|
||||
random) pre "заглушка: случайный ИТ-сайт из нашего набора (10 штук)" ;;
|
||||
*) pre "заглушка: сайт №$site из нашего набора (список: kit local site --list)" ;;
|
||||
esac
|
||||
[[ $fix_time == yes ]] && pre "после: kit fix (время, безопасные починки)"
|
||||
[[ $do_check == yes ]] && pre "проверка: kit check + kit local hardening"
|
||||
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 '{f = $2; sub(/^\*/, "", f)} f == "scripts/3x-ui.sh" {print $1}' "$tmp/SHA256SUMS")
|
||||
[[ $(printf '%s' "$sum" | wc -c) -eq 64 ]] || die "в SHA256SUMS нет установщика — релиз собран неверно"
|
||||
got=$(sha256sum "$tmp/scripts/3x-ui.sh" | awk '{print $1}')
|
||||
[[ $got == "$sum" ]] || die "SHA256 установщика не совпал с подписанным — не запускаю"
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------ какие протоколы ставить
|
||||
|
||||
# Список, порядок и набор по умолчанию — как у установщика автора (scripts/3x-ui.sh:
|
||||
# ALL_PROTOS / DEFAULT_PROTOS). Невыбранные протоколы не создаются вообще: под них не будет
|
||||
# ни подключения в панели, ни записей в nginx, ни ссылок в подписке.
|
||||
ALL_PROTOS=(reality reality2 hy2 xhttp ws trojan vmess ss tuic wg awg awg3 mtproto)
|
||||
PROTO_HINT=(
|
||||
"REALITY основной: VLESS + Reality, порт 443"
|
||||
"REALITY-2 второй REALITY на случайном высоком порту"
|
||||
"Hysteria2 быстрее на плохой сети (UDP 443)"
|
||||
"XHTTP VLESS + Reality под другим именем маскировки"
|
||||
"VLESS-WS VLESS через WebSocket"
|
||||
"Trojan-gRPC Trojan через gRPC"
|
||||
"VMess-WS VMess через WebSocket"
|
||||
"Shadowsocks Shadowsocks 2022"
|
||||
"TUIC быстрый UDP-протокол"
|
||||
"WireGuard обычный WireGuard: легко распознаётся, обычно не ставят"
|
||||
"AmneziaWG AmneziaWG: Mihomo, роутеры Keenetic"
|
||||
"AmneziaWG-3.1 AmneziaWG с защитой заголовков: AmneziaVPN"
|
||||
"MTProto прокси для Telegram"
|
||||
)
|
||||
# Наш набор по умолчанию: VLESS REALITY и AmneziaWG-3.1 — самый совместимый минимум.
|
||||
# Набор автора (флаг --protocols all) доступен ответом «все».
|
||||
AUTHOR_PROTOS=(reality hy2 xhttp ws trojan vmess ss tuic awg awg3 mtproto)
|
||||
DEFAULT_PROTOS=(reality awg3)
|
||||
|
||||
proto_menu() {
|
||||
local i n mark
|
||||
say "какие протоколы поставить?" >&2
|
||||
for i in "${!ALL_PROTOS[@]}"; do
|
||||
n=$((i + 1)) mark=" "
|
||||
[[ " ${DEFAULT_PROTOS[*]} " == *" ${ALL_PROTOS[i]} "* ]] && mark="*"
|
||||
printf ' %s %2d. %s\n' "$mark" "$n" "${PROTO_HINT[i]}" >&2
|
||||
done
|
||||
printf ' %s\n' "${D}[* — по умолчанию: REALITY + AmneziaWG-3.1. «все» — набор автора (всё, кроме WireGuard).${N}" >&2
|
||||
printf ' %s\n' "${D}[Невыбранные протоколы не создаются вообще: ни подключения, ни записей в nginx, ни ссылок в подписке.]${N}" >&2
|
||||
}
|
||||
|
||||
proto_parse() { # ответ → выбранные имена через запятую; код 1, если не разобрал
|
||||
local ans=${1,,} tok i all=" ${ALL_PROTOS[*]} " picked=""
|
||||
ans=${ans//[[:space:]]/,} # «1 3 11» и «1,3,11» — одно и то же
|
||||
[[ -n $ans ]] || { printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"; return 0; }
|
||||
case $ans in
|
||||
all | все | всё) printf '%s' "$(IFS=,; echo "${AUTHOR_PROTOS[*]}")"; return 0 ;;
|
||||
minimal | минимум) printf '%s' "reality"; return 0 ;;
|
||||
esac
|
||||
local IFS=','
|
||||
for tok in $ans; do
|
||||
[[ -n $tok ]] || continue # двойные запятые и пробелы — не ошибка
|
||||
if [[ $tok =~ ^[0-9]{1,2}$ ]]; then
|
||||
i=$((10#$tok - 1))
|
||||
((i >= 0 && i < ${#ALL_PROTOS[@]})) || return 1
|
||||
tok=${ALL_PROTOS[i]}
|
||||
fi
|
||||
[[ $all == *" $tok "* ]] || return 1 # только имена из списка автора
|
||||
[[ ,$picked, == *",$tok,"* ]] || picked+="${picked:+,}$tok"
|
||||
done
|
||||
[[ -n $picked ]] || return 1
|
||||
printf '%s' "$picked"
|
||||
}
|
||||
|
||||
ask_protocols() { # печатает итоговый список через запятую
|
||||
local i ans picked
|
||||
interactive || { printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"; return 0; }
|
||||
proto_menu
|
||||
for i in 1 2 3; do
|
||||
printf '\n%sНомера через запятую (например 1,12) или «все»; Enter — REALITY + AmneziaWG-3.1: %s' "$B" "$N" >&2
|
||||
ans=""
|
||||
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
|
||||
if picked=$(proto_parse "$ans"); then printf '%s' "$picked"; return 0; fi
|
||||
warn "не понял «$ans» — нужны номера из списка через запятую или «все»"
|
||||
done
|
||||
warn "оставляю набор по умолчанию"
|
||||
printf '%s' "$(IFS=,; echo "${DEFAULT_PROTOS[*]}")"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ установка
|
||||
|
||||
if [[ -z $proto_flag ]]; then
|
||||
protos=$(ask_protocols)
|
||||
INSTALLER_ARGS+=(--protocols "$protos")
|
||||
else
|
||||
protos=$proto_flag
|
||||
fi
|
||||
say "протоколы: $protos — под остальные не создаётся ничего"
|
||||
|
||||
say "запускаю установщик автора (панель 3X-UI, протоколы, сайт-заглушка, подписка)"
|
||||
if ((${#INSTALLER_ARGS[@]})); then printf ' флаги: %s\n' "${INSTALLER_ARGS[*]}"; else printf ' флагов нет: установщик задаст вопросы (маскировка, домен)\n'; fi
|
||||
# stdin: если есть терминал — отдаём его установщику (там есть вопросы), иначе /dev/null,
|
||||
# иначе установщик начнёт читать наш собственный скрипт вместо ответов.
|
||||
if [[ -r /dev/tty ]]; then
|
||||
bash "$tmp/scripts/3x-ui.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"} </dev/tty || die "установщик остановился с ошибкой — смотрите его вывод выше"
|
||||
else
|
||||
bash "$tmp/scripts/3x-ui.sh" ${INSTALLER_ARGS[@]+"${INSTALLER_ARGS[@]}"} </dev/null || die "установщик остановился с ошибкой (нет терминала — вопросы задать некому)"
|
||||
fi
|
||||
command -v kit >/dev/null || die "после установки нет команды kit — что-то пошло не так, смотрите вывод выше"
|
||||
|
||||
# ------------------------------------------------------------------ наши дополнения
|
||||
|
||||
ask_yes() { # "вопрос" y|n — по умолчанию
|
||||
local q=$1 def=$2 ans=""
|
||||
interactive || { [[ $def == y ]]; return; }
|
||||
printf '\n%s [%s]: ' "$q" "$([[ $def == y ]] && echo 'Y/n' || echo 'y/N'])"
|
||||
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
|
||||
ans=${ans,,}; ans=${ans//[[:space:]]/}
|
||||
[[ -z $ans && $def == y ]] && return 0
|
||||
[[ $ans =~ ^(y|yes|д|да)$ ]]
|
||||
}
|
||||
|
||||
# Сайт-заглушка: у автора это случайные «кофейни и пекарни», у нас — правдоподобный ИТ-сайт.
|
||||
# Меняем не файл вручную, а командой форка: она же обновляет копию страницы в ответе Hysteria2,
|
||||
# иначе 443/tcp и UDP-порт начнут отвечать разными страницами (это видно снаружи).
|
||||
case $site in
|
||||
off) say "заглушку не трогаю (--no-site): остаётся страница автора" ;;
|
||||
random)
|
||||
say "ставлю сайт-заглушку: случайный ИТ-сайт из нашего набора"
|
||||
kit local site || warn "не удалось поставить заглушку — позже: kit local site"
|
||||
;;
|
||||
*)
|
||||
say "ставлю сайт-заглушку №$site"
|
||||
kit local site "$site" || warn "не удалось поставить заглушку — позже: kit local site $site"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $fix_time == yes ]]; then
|
||||
say "kit fix: синхронизация времени и безопасные починки (авторская команда, ничего необратимого)"
|
||||
kit fix || warn "kit fix завершился с замечаниями — посмотрите вывод"
|
||||
fi
|
||||
|
||||
# ping и автообновления: --harden/--no-harden решают без вопросов, иначе спрашиваем
|
||||
want_ping=$harden; want_update=$harden
|
||||
[[ $want_ping == auto ]] && want_ping=yes
|
||||
[[ $want_update == auto ]] && want_update=yes
|
||||
if [[ $harden == auto ]] && interactive; then
|
||||
if ask_yes "Закрыть ответ сервера на ping? Снаружи пинг перестанет отвечать (подписка и VPN не затрагиваются)." y; then want_ping=yes; else want_ping=no; fi
|
||||
if ask_yes "Включить автообновления безопасности? Только security-обновления, без перезагрузки." y; then want_update=yes; else want_update=no; fi
|
||||
fi
|
||||
if [[ $want_ping == yes ]]; then
|
||||
say "закрываю ответ на ping (правилом ufw)"
|
||||
kit local ping off || warn "не удалось — повторите: kit local ping off"
|
||||
fi
|
||||
if [[ $want_update == yes ]]; then
|
||||
say "включаю автообновления безопасности"
|
||||
kit local upgrades on || warn "не удалось — повторите: kit local upgrades on"
|
||||
fi
|
||||
|
||||
# порт SSH: по флагу или по вопросу. Смена делается в два шага: сначала ДОБАВИТЬ новый порт
|
||||
# (прежний продолжает работать), потом закрыть прежние — иначе одна опечатка оставляет сервер без доступа.
|
||||
ssh_ports_now() {
|
||||
local p
|
||||
p=$(kit local ssh --status 2>/dev/null | sed -n 's/^порты SSH сейчас: //p')
|
||||
[[ -n $p ]] || p=$(sshd -T 2>/dev/null | awk '$1 == "port" {print $2}' | paste -sd, -)
|
||||
[[ -n $p ]] || p=$(grep -rhiE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config /etc/ssh/sshd_config.d 2>/dev/null | awk '{print $2}' | paste -sd, -)
|
||||
printf '%s' "${p:-22}"
|
||||
}
|
||||
|
||||
ask_ssh_port() { # пусто — оставить как есть; иначе номер нового порта
|
||||
local cur ans try
|
||||
cur=$(ssh_ports_now)
|
||||
interactive || return 0
|
||||
for try in 1 2 3; do
|
||||
printf '\n%sСменить порт SSH? Сейчас %s. Введите новый номер, Enter — оставить как есть: %s' "$B" "$cur" "$N" >&2
|
||||
ans=""
|
||||
read -r -t 300 ans <"$ASK_SRC" 2>/dev/null || true
|
||||
ans=${ans//[[:space:]]/}
|
||||
[[ -n $ans ]] || { say "порт SSH оставляю как есть ($cur)" >&2; return 0; }
|
||||
if ! [[ $ans =~ ^[0-9]{1,5}$ ]] || ((10#$ans < 1024 || 10#$ans > 65535)); then
|
||||
warn "«$ans» не похоже на порт (нужно число 1024–65535)"
|
||||
continue
|
||||
fi
|
||||
if [[ ,$cur, == *",$ans,"* ]]; then
|
||||
say "порт $ans уже слушает SSH — менять нечего" >&2; return 0
|
||||
fi
|
||||
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$ans\$"; then
|
||||
warn "порт $ans/tcp занят другой программой"
|
||||
continue
|
||||
fi
|
||||
printf '%s' "$ans"; return 0
|
||||
done
|
||||
warn "оставляю прежний порт ($cur)"
|
||||
}
|
||||
|
||||
[[ -n $ssh_port ]] || ssh_port=$(ask_ssh_port)
|
||||
if [[ -n $ssh_port ]]; then
|
||||
say "перевожу SSH на порт $ssh_port: новый добавляю, прежний пока остаётся рабочим"
|
||||
kit local ssh "$ssh_port" || warn "порт не добавился — сделайте отдельно: kit local ssh $ssh_port"
|
||||
cat <<EOF
|
||||
|
||||
${Y}Дальше обязательно (иначе можно потерять доступ к серверу):${N}
|
||||
1) откройте ВТОРОЕ окно и проверьте вход: ssh -p $ssh_port root@<IP сервера>
|
||||
2) вход работает — закройте прежние порты: kit local ssh --close-old $ssh_port
|
||||
EOF
|
||||
fi
|
||||
|
||||
# пароль root: в флагах его нет намеренно — значение в командной строке видно в списке процессов
|
||||
if interactive; then
|
||||
if ask_yes "Сменить пароль root прямо сейчас?" n; then
|
||||
kit local passwd || warn "не получилось — повторите: kit local passwd"
|
||||
fi
|
||||
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
|
||||
+820
-184
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.6"
|
||||
|
||||
# На свежем VPS в фоне идут автообновления системы и держат замок dpkg: ждём его, а не падаем.
|
||||
apt-get() { command apt-get -o DPkg::Lock::Timeout=900 "$@"; }
|
||||
|
||||
HY_VERSION="2.12.3"
|
||||
HY_REPO="HyNetworks/hysteria"
|
||||
# SHA256 бинарников этой версии, записанные в сам скрипт: проверка не зависит от файла
|
||||
# hashes.txt, который лежит там же, где бинарник (2026-09-30, hashes.txt релиза app/v2.12.3).
|
||||
declare -A HY_SHA256=(
|
||||
[amd64]=8c7a68a906998b747a0db87586e364f995fbfddb95693ae6e2fdb68a6e920d3e
|
||||
[arm64]=c8dc653c3ba0a28d29a26b8fa52d2086f27c0927afddce95c09965e7174e78b0
|
||||
)
|
||||
KIT_VERSION="1.1"
|
||||
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,888 @@
|
||||
#!/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 сводка по защите сервера и что ещё можно включить
|
||||
|
||||
Заглушка (сайт на 443 и ответ Hysteria2 — одна и та же страница)
|
||||
kit local site поставить случайный сайт из нашего набора (10 штук)
|
||||
kit local site --list список сайтов с номерами
|
||||
kit local site 7 поставить конкретный
|
||||
kit local site --show какой сайт стоит сейчас
|
||||
|
||||
Защита сервера (поведение взято из 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
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ заглушка: ИТ-сайты (наши)
|
||||
# Десять правдоподобных ИТ-сайтов: хостинг, DevOps, мониторинг, разработка, безопасность,
|
||||
# поддержка, сети и колокейшн, заметки про Linux, бэкапы, документация проекта.
|
||||
# Ставится случайный (kit local site), конкретный — по номеру (kit local site 7).
|
||||
# Страница отдаётся nginx на 443 при неизвестном SNI и синхронизируется в ответ Hysteria2
|
||||
# (kit net masq on) — поэтому менять её нужно этой командой, иначе 443/tcp и UDP-порт начнут
|
||||
# отвечать разными страницами (это заметно снаружи).
|
||||
|
||||
KL_SITE_FILE=/var/www/kit/index.html
|
||||
KL_SITE_TITLES=(
|
||||
"Radian Analytics — data platforms and BI"
|
||||
"Circuit Nine — DevOps consulting"
|
||||
"Cortex Monitor — server monitoring"
|
||||
"Bluefin Software — custom development"
|
||||
"Vantage Security — audits and penetration tests"
|
||||
"Keystone IT — managed systems support"
|
||||
"Meridian Networks — colocation and transit"
|
||||
"Lumen Notes — Linux and networking"
|
||||
"Evercore Backup — backup and recovery"
|
||||
"Halyard Docs — project documentation"
|
||||
)
|
||||
KL_SITE_TOPICS=(
|
||||
"дата-аналитика: хранилище, отчёты, дашборды"
|
||||
"DevOps-консалтинг: CI/CD, Kubernetes, наблюдаемость"
|
||||
"мониторинг серверов: метрики, алерты, статус-страница"
|
||||
"разработка ПО на заказ: портфолио, стек, процесс"
|
||||
"аудит и пентест: этапы, отчёт, повторная проверка"
|
||||
"ИТ-поддержка: серверы, сети, рабочие места"
|
||||
"колокейшн и транзит: стойки, каналы, BGP"
|
||||
"заметки про Linux, nginx и сети"
|
||||
"бэкапы и восстановление: политика, проверки, тесты"
|
||||
"документация проекта: установка, конфигурация, справка"
|
||||
)
|
||||
|
||||
KL_SITE_CSS=':root{--fg:#1b1f24;--mu:#5b6670;--bd:#e3e6ea;--ac:#1f6feb;--bg:#fff}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);background:var(--bg)}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:0 20px}
|
||||
header{border-bottom:1px solid var(--bd)}
|
||||
header .wrap{display:flex;align-items:center;justify-content:space-between;height:64px}
|
||||
.logo{font-weight:700;text-decoration:none;color:var(--fg)}
|
||||
nav a{color:var(--mu);text-decoration:none;margin-left:18px;font-size:15px}
|
||||
.hero{padding:72px 0 40px}
|
||||
.hero h1{font-size:40px;line-height:1.15;margin:0 0 16px;letter-spacing:-.02em}
|
||||
.hero p{font-size:18px;color:var(--mu);max-width:640px;margin:0 0 28px}
|
||||
.btn{display:inline-block;background:var(--ac);color:#fff;text-decoration:none;padding:12px 22px;border-radius:6px;font-weight:600}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px;padding:8px 0 44px}
|
||||
.card{border:1px solid var(--bd);border-radius:8px;padding:22px}
|
||||
.card h3{margin:0 0 8px;font-size:18px}
|
||||
.card p{margin:0;color:var(--mu)}
|
||||
.prose{padding:0 0 56px;max-width:780px}
|
||||
.prose h2{font-size:22px;margin:34px 0 10px}
|
||||
.prose p,.prose li{color:var(--mu)}
|
||||
code{background:#f4f6f8;padding:2px 5px;border-radius:4px;font-size:14px}
|
||||
footer{border-top:1px solid var(--bd);padding:26px 0;color:var(--mu);font-size:14px}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
th,td{border-bottom:1px solid var(--bd);text-align:left;padding:10px 8px;font-size:15px}
|
||||
ul{padding-left:20px}
|
||||
@media(max-width:600px){.hero{padding:44px 0 28px}.hero h1{font-size:29px}}'
|
||||
|
||||
kl_site_open() { # заголовок описание бренд «навигация как HTML»
|
||||
cat <<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>$1</title>
|
||||
<meta name="description" content="$2">
|
||||
<meta name="generator" content="static">
|
||||
<style>$KL_SITE_CSS</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="wrap"><a class="logo" href="/">$3</a><nav>$4</nav></div></header>
|
||||
HTML
|
||||
}
|
||||
|
||||
kl_site_close() { # бренд
|
||||
cat <<HTML
|
||||
<footer><div class="wrap"><p>© $(date +%Y) $1 · <a href="#">Imprint</a> · <a href="#">Privacy</a> · <a href="#">Status</a></p></div></footer>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
}
|
||||
|
||||
kl_site_html() { # номер (с нуля) → страница на stdout
|
||||
case $1 in
|
||||
0) # дата-аналитика
|
||||
kl_site_open "Radian Analytics — data platforms and BI" "We collect data from accounting, warehouses and websites into one place and build the few reports the business actually reads." "Radian Analytics" '<a href="#services">Services</a><a href="#approach">Approach</a><a href="#contact">Contact</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>Reports that arrive before the meeting</h1>
|
||||
<p>We pull data out of your accounting system, warehouse and website, load it into one warehouse, and build the handful of dashboards people actually open.</p>
|
||||
<a class="btn" href="#services">What we build</a>
|
||||
</div></section>
|
||||
<section id="services" class="wrap cards">
|
||||
<div class="card"><h3>Data pipelines</h3><p>Scheduled loads from databases, APIs and spreadsheets, with checks that catch a broken source early.</p></div>
|
||||
<div class="card"><h3>Warehouse</h3><p>PostgreSQL or ClickHouse, modelled so that a new question does not need a new table every time.</p></div>
|
||||
<div class="card"><h3>Dashboards</h3><p>The five numbers that matter on one page, with the detail one click behind each of them.</p></div>
|
||||
</section>
|
||||
<section id="approach" class="wrap prose">
|
||||
<h2>How it usually goes</h2>
|
||||
<p>Two weeks to collect the sources and agree on definitions — most arguments turn out to be about definitions, not about software. Then the pipeline runs nightly and the first reports go out a week later.</p>
|
||||
<h2>One warning</h2>
|
||||
<p>A dashboard nobody opens is expensive decoration. One report that somebody uses beats ten that nobody does.</p>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Nordhost" ;;
|
||||
1) # DevOps-консалтинг
|
||||
kl_site_open "Circuit Nine — DevOps consulting" "We help small teams ship safely: CI/CD, Kubernetes, infrastructure as code and observability." "Circuit Nine" '<a href="#work">Work</a><a href="#approach">Approach</a><a href="#about">About</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>Ship on Fridays without holding your breath</h1>
|
||||
<p>We set up pipelines, infrastructure and monitoring so that releasing is a boring, repeatable event — and so that on-call does not ruin anyone’s week.</p>
|
||||
<a class="btn" href="#work">What we do</a>
|
||||
</div></section>
|
||||
<section id="work" class="wrap cards">
|
||||
<div class="card"><h3>CI/CD</h3><p>Build pipelines, artefact registries, staging environments and one-command rollbacks.</p></div>
|
||||
<div class="card"><h3>Infrastructure</h3><p>Terraform and Ansible for servers and cloud, with reviews and tests in the pipeline.</p></div>
|
||||
<div class="card"><h3>Observability</h3><p>Metrics, logs and traces wired together, with alerts that people actually trust.</p></div>
|
||||
</section>
|
||||
<section id="approach" class="wrap prose">
|
||||
<h2>How we work</h2>
|
||||
<p>A two-week discovery, then small changes with a review after each one. Everything lands in your repository and your accounts — no private tooling, no lock-in.</p>
|
||||
<table>
|
||||
<tr><th>Week</th><th>What happens</th></tr>
|
||||
<tr><td>1–2</td><td>Audit of the current setup, a written report with priorities</td></tr>
|
||||
<tr><td>3–6</td><td>Pipelines and infrastructure, pairing with your engineers</td></tr>
|
||||
<tr><td>7–8</td><td>Monitoring, runbooks and a handover session</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Circuit Nine" ;;
|
||||
2) # мониторинг
|
||||
kl_site_open "Cortex Monitor — server monitoring" "Uptime, resource and certificate monitoring with alerting to Telegram, e-mail and webhooks." "Cortex Monitor" '<a href="#checks">Checks</a><a href="#alerts">Alerts</a><a href="#pricing">Pricing</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>Know about the outage before your users do</h1>
|
||||
<p>HTTP, TCP, ICMP and TLS checks from six locations, host metrics through a lightweight agent, and alerts that reach you in seconds.</p>
|
||||
<a class="btn" href="#checks">See checks</a>
|
||||
</div></section>
|
||||
<section id="checks" class="wrap cards">
|
||||
<div class="card"><h3>Availability</h3><p>HTTP and TCP checks with keyword matching, redirects and status codes.</p></div>
|
||||
<div class="card"><h3>Resources</h3><p>CPU, memory, disk and network of every host on one page.</p></div>
|
||||
<div class="card"><h3>Certificates</h3><p>Expiry warnings 30, 14 and 3 days ahead, with a list of what needs renewing.</p></div>
|
||||
</section>
|
||||
<section id="alerts" class="wrap prose">
|
||||
<h2>Alerting</h2>
|
||||
<p>Alerts are grouped so that a single broken router does not produce forty messages. Quiet hours, maintenance windows and escalation to a second contact are configurable per check.</p>
|
||||
<h2>Maybe you do not need us yet</h2>
|
||||
<p>If you run one server, an uptime check and a cron job with certificate renewal will do. Come back when there are more of them than you can remember.</p>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Cortex Monitor" ;;
|
||||
3) # разработка ПО
|
||||
kl_site_open "Bluefin Software — custom development" "A small team building web services, integrations and internal tools for companies that grew out of spreadsheets." "Bluefin Software" '<a href="#services">Services</a><a href="#stack">Stack</a><a href="#contact">Contact</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>Software for the part of the business nobody sells</h1>
|
||||
<p>Integrations, back-office tools and web services. We take a process that lives in three spreadsheets and one person’s head, and give it a proper home.</p>
|
||||
<a class="btn" href="#services">Services</a>
|
||||
</div></section>
|
||||
<section id="services" class="wrap cards">
|
||||
<div class="card"><h3>Web services</h3><p>APIs and admin panels in Python or Go, deployed to your infrastructure.</p></div>
|
||||
<div class="card"><h3>Integrations</h3><p>Accounting, warehouses, CRM and everything that has an API or a database.</p></div>
|
||||
<div class="card"><h3>Rescue work</h3><p>Taking over an unfinished project: audit, plan, tidy up, finish.</p></div>
|
||||
</section>
|
||||
<section id="stack" class="wrap prose">
|
||||
<h2>Stack</h2>
|
||||
<p>Python (Django, FastAPI), PostgreSQL, Redis, Docker, and whatever the customer already runs. We keep deployments simple enough that a new colleague can understand them in a day.</p>
|
||||
<h2>Team</h2>
|
||||
<p>Four engineers and a part-time designer. We take on three projects at a time so that nobody is queued behind somebody else’s deadline.</p>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Bluefin Software" ;;
|
||||
4) # безопасность
|
||||
kl_site_open "Vantage Security — audits and penetration tests" "External and internal penetration tests, configuration reviews and help with fixing what we find." "Vantage Security" '<a href="#services">Services</a><a href="#process">Process</a><a href="#contact">Contact</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>Find the problems before somebody else does</h1>
|
||||
<p>Penetration tests and configuration reviews for web services, networks and cloud accounts — with a report your engineers can act on and a retest when the fixes are in.</p>
|
||||
<a class="btn" href="#services">Services</a>
|
||||
</div></section>
|
||||
<section id="services" class="wrap cards">
|
||||
<div class="card"><h3>External test</h3><p>Everything reachable from the internet: services, TLS, DNS, mail.</p></div>
|
||||
<div class="card"><h3>Internal test</h3><p>What an attacker does after getting a foothold in an office or a VPN.</p></div>
|
||||
<div class="card"><h3>Review</h3><p>Settings, backups, access rights and monitoring — usually the cheapest findings.</p></div>
|
||||
</section>
|
||||
<section id="process" class="wrap prose">
|
||||
<h2>Process</h2>
|
||||
<p>Scope, rules of engagement and a test window are agreed in writing first. We work against a staging copy where possible, and we never touch production data beyond what the scope allows.</p>
|
||||
<h2>Report</h2>
|
||||
<p>Findings are ranked by what they let an attacker do, with reproduction steps and concrete fixes. A retest of the fixed issues is included in the price.</p>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Vantage Security" ;;
|
||||
5) # ИТ-поддержка
|
||||
kl_site_open "Keystone IT — managed systems support" "We look after servers, networks, backups and workstations for offices of ten to two hundred people." "Keystone IT" '<a href="#services">Services</a><a href="#support">Support</a><a href="#contact">Contact</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>An IT department, without hiring one</h1>
|
||||
<p>Monitoring, updates, backups and a real person on the phone. Contracts start at a fixed monthly fee, and you can leave with thirty days’ notice.</p>
|
||||
<a class="btn" href="#services">What is included</a>
|
||||
</div></section>
|
||||
<section id="services" class="wrap cards">
|
||||
<div class="card"><h3>Servers</h3><p>Updates, monitoring, disk space and certificate renewal handled for you.</p></div>
|
||||
<div class="card"><h3>Backups</h3><p>Nightly copies, offsite storage and a restore test twice a year.</p></div>
|
||||
<div class="card"><h3>Workplaces</h3><p>Accounts, laptops, printers and the printer driver that never works.</p></div>
|
||||
</section>
|
||||
<section id="support" class="wrap prose">
|
||||
<h2>Support</h2>
|
||||
<p>Weekdays from 8 to 19, with an on-call line for outages. Response time for a full outage is one hour, for ordinary requests one working day.</p>
|
||||
<p>We keep a written inventory of your systems, so that a new engineer — ours or yours — can take over without a two-week interview with the person who built it.</p>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Keystone IT" ;;
|
||||
6) # сети и колокейшн
|
||||
kl_site_open "Meridian Networks — colocation and transit" "Rack space, cross-connects and IP transit in two carrier-neutral facilities." "Meridian Networks" '<a href="#colo">Colocation</a><a href="#transit">Transit</a><a href="#contact">Contact</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>Racks, cross-connects and transit</h1>
|
||||
<p>Quarter, half and full racks in Frankfurt and Amsterdam, with remote hands, redundant power and a choice of transit providers.</p>
|
||||
<a class="btn" href="#colo">Colocation</a>
|
||||
</div></section>
|
||||
<section id="colo" class="wrap cards">
|
||||
<div class="card"><h3>Quarter rack</h3><p>10 U, 2 kW, 1 Gbps port and one IPv4 prefix.</p></div>
|
||||
<div class="card"><h3>Half rack</h3><p>22 U, 4 kW and a redundant 10 Gbps pair.</p></div>
|
||||
<div class="card"><h3>Private cage</h3><p>From eight racks, with your own access control and monitoring.</p></div>
|
||||
</section>
|
||||
<section id="transit" class="wrap prose">
|
||||
<h2>Transit and peering</h2>
|
||||
<p>We announce your prefix over two upstreams and the local internet exchange. BGP communities for traffic engineering, RPKI filtering and a looking glass are available to customers.</p>
|
||||
<table>
|
||||
<tr><th>Port</th><th>Committed rate</th></tr>
|
||||
<tr><td>1 Gbps</td><td>from 50 Mbps</td></tr>
|
||||
<tr><td>10 Gbps</td><td>from 1 Gbps</td></tr>
|
||||
<tr><td>2×10 Gbps</td><td>from 2 Gbps, redundant</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Meridian Networks" ;;
|
||||
7) # блог
|
||||
kl_site_open "Lumen Notes — Linux and networking" "Short notes about servers, nginx, storage and networks — written while working, mostly for myself." "Lumen Notes" '<a href="#posts">Posts</a><a href="#about">About</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>Notes from a server room</h1>
|
||||
<p>Things I worked out once and do not want to work out again: nginx, systemd, ZFS, routing and the odd shell trick.</p>
|
||||
<a class="btn" href="#posts">Read the posts</a>
|
||||
</div></section>
|
||||
<section id="posts" class="wrap prose">
|
||||
<h2>Replacing a certificate without dropping connections</h2>
|
||||
<p>Reload, not restart. <code>systemctl reload nginx</code> keeps the listeners open and swaps the configuration, so long downloads and websockets survive. A restart does not.</p>
|
||||
<h2>Finding what filled the disk</h2>
|
||||
<p><code>du -xh --max-depth=1 / | sort -h</code> first, then follow the biggest branch. Journals under <code>/var/log/journal</code> are a common answer; <code>journalctl --vacuum-size=200M</code> trims them.</p>
|
||||
<h2>Checking that a port is really listening</h2>
|
||||
<p><code>ss -ltnp</code> shows TCP listeners and the process behind each one. If something answers on 443 but is not in the list, look for a proxy in front of it.</p>
|
||||
<h2>Backup is not a copy</h2>
|
||||
<p>A copy on the same disk protects against nothing but a fat finger. The test that matters is a restore, done on purpose, on a machine that is not the original.</p>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Lumen Notes" ;;
|
||||
8) # бэкапы
|
||||
kl_site_open "Evercore Backup — backup and recovery" "Offsite backups with a documented restore procedure and a restore test every quarter." "Evercore Backup" '<a href="#policy">Policy</a><a href="#restore">Restore</a><a href="#contact">Contact</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>A backup you have never restored is a rumour</h1>
|
||||
<p>We copy your servers and databases to a separate location, keep versions, and prove once a quarter that a restore actually works.</p>
|
||||
<a class="btn" href="#policy">How it works</a>
|
||||
</div></section>
|
||||
<section id="policy" class="wrap cards">
|
||||
<div class="card"><h3>Nightly copies</h3><p>Files and databases, encrypted before they leave the server.</p></div>
|
||||
<div class="card"><h3>Versions</h3><p>Hourly for a day, daily for a month, monthly for a year.</p></div>
|
||||
<div class="card"><h3>Restore test</h3><p>A quarterly restore into a clean machine, with a short report.</p></div>
|
||||
</section>
|
||||
<section id="restore" class="wrap prose">
|
||||
<h2>Recovery</h2>
|
||||
<p>A lost file takes minutes: we restore the version you pick. A lost server takes an hour or two: a prepared image plus your latest copy, booted and checked before it goes back into service.</p>
|
||||
<p>Encryption keys are kept by you as well as by us, so that neither side alone can read the copies.</p>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Evercore Backup" ;;
|
||||
9) # документация проекта
|
||||
kl_site_open "Halyard Docs — project documentation" "Installation, configuration and reference for a self-hosted service: everything in one place." "Halyard Docs" '<a href="#install">Install</a><a href="#config">Config</a><a href="#reference">Reference</a>'
|
||||
cat <<'HTML'
|
||||
<main>
|
||||
<section class="hero"><div class="wrap">
|
||||
<h1>Documentation</h1>
|
||||
<p>Installation, configuration and the reference pages. Written for the current release; older versions keep their own section.</p>
|
||||
<a class="btn" href="#install">Start here</a>
|
||||
</div></section>
|
||||
<section id="install" class="wrap prose">
|
||||
<h2>Installation</h2>
|
||||
<p>Download the archive, unpack it into <code>/opt/halyard</code> and run the setup script. The script asks three questions and writes a configuration file next to the binary.</p>
|
||||
<h2>Configuration</h2>
|
||||
<p>Settings live in <code>/etc/halyard/config.toml</code>. Every option has a default, so a minimal file is often enough. Changes are applied with <code>halyard reload</code> — no restart, no dropped connections.</p>
|
||||
</section>
|
||||
<section id="reference" class="wrap prose">
|
||||
<h2>Reference</h2>
|
||||
<table>
|
||||
<tr><th>Command</th><th>What it does</th></tr>
|
||||
<tr><td><code>halyard status</code></td><td>Shows the running version and the state of each component</td></tr>
|
||||
<tr><td><code>halyard check</code></td><td>Validates the configuration and the listening ports</td></tr>
|
||||
<tr><td><code>halyard logs</code></td><td>Follows the log with readable timestamps</td></tr>
|
||||
</table>
|
||||
<p>Questions and patches are welcome through the issue tracker; the address is on the contact page.</p>
|
||||
</section>
|
||||
</main>
|
||||
HTML
|
||||
kl_site_close "Halyard Docs" ;;
|
||||
*) kl_die "нет такого сайта: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
kl_site_current() { # какой из наших сайтов стоит сейчас (пусто — не наш)
|
||||
local i t
|
||||
[[ -s $KL_SITE_FILE ]] || return 0
|
||||
for i in "${!KL_SITE_TITLES[@]}"; do
|
||||
t=${KL_SITE_TITLES[i]}
|
||||
if grep -qF "<title>$t</title>" "$KL_SITE_FILE" 2>/dev/null; then
|
||||
printf '%s' "$((i + 1)). $t"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
kit_local_site() {
|
||||
kl_args "$@"
|
||||
local arg=${KL_REST[0]:-} n cur
|
||||
case $arg in
|
||||
--list | list)
|
||||
kl_say "сайты-заглушки в наборе (ставится случайный)"
|
||||
local i
|
||||
for i in "${!KL_SITE_TITLES[@]}"; do
|
||||
printf ' %2d. %-22s %s\n' "$((i + 1))" "${KL_SITE_TITLES[i]%% — *}" "${KL_SITE_TOPICS[i]}"
|
||||
done
|
||||
printf ' %s\n' "поставить конкретный: kit local site 7"
|
||||
return 0 ;;
|
||||
--show | show | status)
|
||||
cur=$(kl_site_current)
|
||||
if [[ -n $cur ]]; then kl_ok "сейчас стоит наш сайт: $cur"
|
||||
elif [[ -s $KL_SITE_FILE ]]; then kl_ok "сейчас стоит страница автора (случайная из его наборов)"
|
||||
else kl_warn "файла $KL_SITE_FILE нет"; fi
|
||||
return 0 ;;
|
||||
"" | random | --random) n=$((RANDOM % ${#KL_SITE_TITLES[@]})) ;;
|
||||
*)
|
||||
[[ $arg =~ ^[0-9]{1,2}$ ]] && ((10#$arg >= 1 && 10#$arg <= ${#KL_SITE_TITLES[@]})) \
|
||||
|| { kl_die "kit local site [номер 1..${#KL_SITE_TITLES[@]}|--list|--show]"; return 1; }
|
||||
n=$((10#$arg - 1)) ;;
|
||||
esac
|
||||
if kl_dry; then
|
||||
kl_say "заглушка: ${KL_SITE_TITLES[n]} (${KL_SITE_TOPICS[n]})"
|
||||
printf ' (dry-run) записал бы %s и синхронизировал бы ответ Hysteria2 (kit net masq on)\n' "$KL_SITE_FILE"
|
||||
return 0
|
||||
fi
|
||||
kl_need_root || return 1
|
||||
kl_say "заглушка: ${KL_SITE_TITLES[n]} (${KL_SITE_TOPICS[n]})"
|
||||
install -d -m 755 /var/www/kit
|
||||
[[ -f $KL_SITE_FILE ]] && kl_backup "$KL_SITE_FILE"
|
||||
if ! kl_site_html "$n" >"$KL_SITE_FILE.new"; then
|
||||
rm -f "$KL_SITE_FILE.new"; kl_die "не удалось собрать страницу"; return 1
|
||||
fi
|
||||
chmod 644 "$KL_SITE_FILE.new" && mv "$KL_SITE_FILE.new" "$KL_SITE_FILE" \
|
||||
|| { rm -f "$KL_SITE_FILE.new"; kl_die "не удалось заменить $KL_SITE_FILE"; return 1; }
|
||||
kl_ok "страница обновлена: $KL_SITE_FILE ($(wc -c <"$KL_SITE_FILE") байт)"
|
||||
# Порт 443 отдаёт файл через nginx (перезагрузка не нужна). UDP-порт Hysteria2 держит копию
|
||||
# страницы внутри подключения — её надо обновить, иначе ответы разойдутся.
|
||||
if kl_have kit; then
|
||||
if kit net masq on >/dev/null 2>&1; then
|
||||
kl_ok "Hysteria2 отвечает той же страницей"
|
||||
else
|
||||
kl_warn "Hysteria2 не синхронизирован (может, его нет в этой установке) — проверьте: kit net masq on"
|
||||
fi
|
||||
fi
|
||||
kl_ok "посмотреть снаружи: curl -k https://<IP-или-домен>/ ; состояние: kit local site --show, kit check"
|
||||
}
|
||||
|
||||
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 "$@" ;;
|
||||
site | stub | stub-site) shift; kit_local_site "$@" ;;
|
||||
*)
|
||||
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
|
||||
}
|
||||
+344
-15
@@ -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,28 +40,47 @@ 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",
|
||||
"exclude-routes", "exclude-apns-enable", "per-app-proxy-mode", "per-app-proxy-list",
|
||||
"notification-subs-expire", "sub-expire", "sub-expire-button-link", "sub-info-text",
|
||||
"sub-info-color", "sub-info-button-text", "sub-info-button-link",
|
||||
"subscription-autoconnect", "subscription-autoconnect-type", "subscription-always-hwid-enable")
|
||||
PASS_HEADERS = ("content-type", "content-disposition", "profile-title", "profile-update-interval",
|
||||
"profile-web-page-url", "subscription-userinfo", "support-url", "cache-control")
|
||||
"profile-web-page-url", "subscription-userinfo", "support-url", "cache-control") + HAPP_HEADERS
|
||||
|
||||
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""
|
||||
|
||||
@@ -69,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."""
|
||||
@@ -127,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.
|
||||
@@ -149,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]}")
|
||||
@@ -184,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}")
|
||||
|
||||
@@ -196,9 +455,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
for k in PASS_HEADERS:
|
||||
if k in headers:
|
||||
v = fix_userinfo(headers[k]) if k == "subscription-userinfo" else headers[k]
|
||||
if v:
|
||||
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)
|
||||
@@ -213,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")
|
||||
@@ -224,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)]
|
||||
|
||||
@@ -249,3 +577,4 @@ def main():
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
+2132
-69
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` –
|
||||
@@ -28,7 +28,7 @@ Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. П
|
||||
| VMess WS + TLS | ✓ | ✓ | ✓ |
|
||||
| Shadowsocks 2022 | ✓ | ✓ | ✓ |
|
||||
| TUIC v5 | – | ✓ | ✓ |
|
||||
| WireGuard | ✓ | ✓ | ✓ | (локально; через DPI в России – ✗ у всех клиентов) |
|
||||
| WireGuard | ✓ | ✓ | ✓ | (локально; через интернет к удалённому серверу – ✗ у всех клиентов) |
|
||||
| AmneziaWG (классика) | – | ✓ | – |
|
||||
| AmneziaWG 3.1 | – | ✓ | – |
|
||||
|
||||
@@ -38,12 +38,12 @@ MTProto: `mtg doctor` – все дата-центры Telegram доступны
|
||||
Со своим (самоподписанным) сертификатом подписка Clash от 3X-UI не передаёт Mihomo отпечаток –
|
||||
TLS-протоколы в ней не работают; ссылки с `pcs` работают.
|
||||
|
||||
## Через интернет к серверу в России, 2026-09-27
|
||||
## Через интернет к удалённому серверу, 2026-09-27
|
||||
|
||||
Сервер Ubuntu 24.04 в России, `scripts/3x-ui.sh` с настоящим сертификатом Let's Encrypt на IP,
|
||||
Удалённый сервер Ubuntu 24.04, `scripts/3x-ui.sh` с настоящим сертификатом Let's Encrypt на IP,
|
||||
клиенты – на Mac через интернет. Подписка-ссылки и подписка Clash: все протоколы ✓, кроме:
|
||||
|
||||
- **WireGuard** – ✗ у Xray, Mihomo и sing-box: рукопожатие доходит до сервера, ответ режет DPI.
|
||||
- **WireGuard** – ✗ у Xray, Mihomo и sing-box: рукопожатие доходит до сервера, ответ не возвращается.
|
||||
Поэтому из набора по умолчанию убран.
|
||||
- **MTProto** – сервер хостера не достаёт до Telegram, прокси бесполезен; установщик теперь
|
||||
проверяет это и пропускает MTProto.
|
||||
|
||||
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,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""3X-UI_KIT (форк hacker07): накатывает наши «швы» на чистый код автора.
|
||||
|
||||
Это источник правды для форка: единственное место, где описано, ЧТО именно мы правим в
|
||||
файлах автора. Всё, что мы дописываем по существу, живёт в scripts/kit-local.sh — файле,
|
||||
который автор не трогает, поэтому обновления автора его не ломают.
|
||||
|
||||
Правила:
|
||||
* каждый якорь обязан найтись РОВНО один раз, иначе — стоп (автор изменил код, правку
|
||||
надо пересмотреть руками);
|
||||
* при любой ошибке ничего не пишется наполовину: сначала прогоняй --check;
|
||||
* открытый ключ подписи берётся из tools/fork-signer.pub (закрытая часть — только на
|
||||
рабочей станции владельца, в репозиторий не попадает).
|
||||
|
||||
Использование:
|
||||
python3 tools/fork_apply.py --check [корень-репозитория]
|
||||
python3 tools/fork_apply.py [корень-репозитория]
|
||||
"""
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
GITEA = os.environ.get("FORK_GITEA_BASE", "https://git.rukavishnikoff.ru/hacker07/3X-UI_autoVPS")
|
||||
SIGNER_FILE = os.environ.get("FORK_SIGNER_PUB", "tools/fork-signer.pub")
|
||||
|
||||
# README.md форка — НАШ файл целиком: авторское описание из него убрано, чтобы никто не
|
||||
# ставил сервер авторской командой и не оказался на авторском канале обновлений. Шва здесь нет:
|
||||
# при обновлении из авторского проекта README конфликтует и решается в нашу пользу (см. FORK.md).
|
||||
HOOK = """
|
||||
# --- 3X-UI_KIT (форк hacker07): локальные команды и хуки ---
|
||||
# Подключаем scripts/kit-local.sh, если он установлен. Наш файл: правки автора его не касаются.
|
||||
# Все команды форка — под «kit local …» (см. kit local help).
|
||||
KIT_LOCAL_LIB="${KIT_LOCAL_LIB:-/usr/local/lib/kit/kit-local.sh}"
|
||||
if [[ -r $KIT_LOCAL_LIB ]]; then . "$KIT_LOCAL_LIB"; kit_local_dispatch "$@"; fi
|
||||
"""
|
||||
|
||||
|
||||
def die(msg):
|
||||
print("! " + msg, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print("==> " + msg)
|
||||
|
||||
|
||||
def count(text, anchor, regex):
|
||||
if regex:
|
||||
return len(re.findall(anchor, text, re.MULTILINE))
|
||||
return text.count(anchor)
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:]]
|
||||
check = False
|
||||
if "--check" in args:
|
||||
check = True
|
||||
args.remove("--check")
|
||||
root = pathlib.Path(args[0] if args else ".").resolve()
|
||||
if not (root / "VERSION").exists():
|
||||
die("%s не похож на репозиторий 3X-UI 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)",
|
||||
"наш файл в подписанном списке релиза",
|
||||
),
|
||||
(
|
||||
"tools/release.sh",
|
||||
'{ echo "# 3X-UI KIT $v"; sha "${FILES[@]}"; } >SHA256SUMS',
|
||||
False,
|
||||
"{ echo \"# 3X-UI KIT $v\"; sha \"${FILES[@]}\" | awk '{ sub(/^\\*/, \"\", $2); print $1\" \"$2 }'; } >SHA256SUMS",
|
||||
"суммы в POSIX-виде без звёздочки (на Windows sha256sum её ставит, и kit её не понимает)",
|
||||
),
|
||||
# --- свои записи о версии (наш файл, авторский 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()
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
|
||||
// Сервисы: какие наборы правил отправлять через прокси.
|
||||
const SERVICES = [
|
||||
{ id: 'blocked', group: 'Заблокированное', label: 'Заблокированное в России', hint: 'список re:filter – основные заблокированные сайты', on: true,
|
||||
{ id: 'blocked', group: 'Re:filter', label: 'Сайты из списка re:filter', hint: 'список re:filter – основные сайты', on: true,
|
||||
sets: [['refilter@domain', REFILTER + 'domain-rule.mrs', 'domain'], ['refilter@ipcidr', REFILTER + 'ip-rule.mrs', 'ipcidr']] },
|
||||
{ id: 'youtube', label: 'YouTube', on: true, sets: [['youtube@domain', META + 'geosite/youtube.mrs', 'domain']] },
|
||||
{ id: 'telegram', label: 'Telegram', on: true,
|
||||
|
||||
+2
-2
@@ -83,12 +83,12 @@
|
||||
const BASES = {
|
||||
zkeen: { label: 'ZKeen', hint: 'ставится XKeen по умолчанию, рекомендуется', site: 'zkeen.dat', ip: 'zkeenip.dat' },
|
||||
v2fly: { label: 'V2Fly', hint: 'нужна для отдельных сервисов и блокировки рекламы', site: 'geosite_v2fly.dat', ip: 'geoip_v2fly.dat' },
|
||||
refilter: { label: 'Re:filter', hint: 'большой список заблокированного', site: 'geosite_refilter.dat', ip: 'geoip_refilter.dat' },
|
||||
refilter: { label: 'Re:filter', hint: 'большой список сайтов', site: 'geosite_refilter.dat', ip: 'geoip_refilter.dat' },
|
||||
};
|
||||
|
||||
// Сервис → правила по базам. d: домены, i: IP. Берутся все доступные базы.
|
||||
const SERVICES = [
|
||||
{ id: 'blocked', label: 'Заблокированное в России', on: true,
|
||||
{ id: 'blocked', label: 'Сайты из списка re:filter', on: true,
|
||||
zkeen: { d: ['domains', 'other', 'politic'] }, refilter: { d: ['refilter'], i: ['refilter'] } },
|
||||
{ id: 'youtube', label: 'YouTube', on: true, zkeen: { d: ['youtube'], i: ['youtube'] }, v2fly: { d: ['youtube'] } },
|
||||
{ id: 'telegram', label: 'Telegram', on: true, zkeen: { i: ['telegram'] }, v2fly: { d: ['telegram'] } },
|
||||
|
||||
+38
-7
@@ -19,17 +19,24 @@ 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 "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
|
||||
# Переводы строк. Сервер получает файл из git (там LF), а суммы считаются по рабочей копии:
|
||||
# если в рабочей копии окажутся CR, суммы не сойдутся и kit update откажется ставить релиз.
|
||||
for f in "${FILES[@]}"; do
|
||||
[[ -z $(tr -cd "\r" <"$f") ]] || die "$f: переводы строк Windows (CR). Уберите: tr -d '\r' < \"$f\" > \"$f.lf\" && mv \"$f.lf\" \"$f\""
|
||||
done
|
||||
ok "Переводы строк во всех файлах релиза — LF"
|
||||
|
||||
[[ -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)
|
||||
@@ -38,15 +45,39 @@ got=$(curl -fsSL "https://raw.githubusercontent.com/MHSanaei/3x-ui/$xv/install.s
|
||||
[[ $got == "$xs" ]] || die "Установщик 3X-UI $xv изменился: $got вместо $xs. Проверьте, что поменялось, прежде чем выпускать."
|
||||
ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256"
|
||||
|
||||
# Открытые ключи из kit и hy2 – одинаковые, и среди них есть ключ, которым подписываем.
|
||||
# Суммы архивов ядра Xray в скрипте должны совпадать с официальными (.dgst релиза Xray-core).
|
||||
xc=$(sed -n 's/^XRAY_CORE="\(.*\)"/\1/p' scripts/3x-ui.sh)
|
||||
for a in 64 arm64-v8a; do
|
||||
pin=$(sed -n "s/^ \[$a\]=\([0-9a-f]\{64\}\)\$/\1/p" scripts/3x-ui.sh)
|
||||
off=$(curl -fsSL "https://github.com/XTLS/Xray-core/releases/download/$xc/Xray-linux-$a.zip.dgst" | awk '/^SHA2-256=/ {print $2}')
|
||||
[[ -n $pin && $pin == "$off" ]] || die "Сумма ядра Xray $xc ($a) в 3x-ui.sh не совпадает с официальной: «$pin» вместо «$off»."
|
||||
done
|
||||
ok "Суммы архивов ядра Xray $xc совпадают с официальными"
|
||||
|
||||
# Проверенная панель и ядро в 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
|
||||
{ echo "# 3X-UI KIT $v"; sha "${FILES[@]}" | awk '{ sub(/^\*/, "", $2); print $1" "$2 }'; } >SHA256SUMS
|
||||
rm -f SHA256SUMS.sig
|
||||
ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null
|
||||
tmp=$(mktemp)
|
||||
|
||||
@@ -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