Compare commits

...
117 Commits
Author SHA1 Message Date
hacker07 13c86723be Релиз v1.2.6: подпись
CI / check (push) Canceled after 0s
2026-10-12 20:54:25 +03:00
hacker07 96823d74f1 README переписан целиком (команда, флаги, протоколы, заглушки, команды, канал) + свои картинки 2026-10-12 20:42:07 +03:00
hacker07 ca5a57db55 v1.2.6: заглушка №1 — дата-аналитика вместо хостинга; гейт по переводам строк выше проверки коммита 2026-10-12 20:37:50 +03:00
hacker07 628130992d LF во всех файлах релиза + гейт в release.sh (CR ломает суммы и запуск на сервере) 2026-10-12 20:31:20 +03:00
hacker07 445c066791 v1.2.6: 10 ИТ-сайтов-заглушек вместо авторских + умолчание протоколов REALITY и AWG-3.1 2026-10-12 20:30:14 +03:00
hacker07 1fdc4945f1 v1.2.6: вопрос про смену порта SSH вместо второго порта; меню и вопросы больше не попадают в значения переменных 2026-10-12 20:25:15 +03:00
hacker07 cc17b11bd1 v1.2.6: вопрос о протоколах при установке (+--yes, --harden) и правка разбора ответов 2026-10-12 20:22:17 +03:00
hacker07 4ed9c5ceb5 v1.2.6: обёртка спрашивает про ping, автообновления, порт SSH и пароль 2026-10-12 20:14:07 +03:00
hacker07 0f31fc1739 docs: шапка README с предупреждением о форке + пояснение в FORK.md
CI / check (push) Canceled after 0s
2026-10-12 20:01:01 +03:00
hacker07 619b2120ce docs: FORK.md под новую установку одной командой и все команды kit local
CI / check (push) Canceled after 0s
2026-10-12 19:57:46 +03:00
hacker07 b61a3225d8 Релиз v1.2.5: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:46:46 +03:00
hacker07 70e2a499c7 v1.2.5: суммы релиза в POSIX-виде (без звёздочки) — kit сверяет имя строго 2026-10-12 19:40:04 +03:00
hacker07 a70dd14542 Релиз v1.2.4: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:37:43 +03:00
hacker07 934077c56f v1.2.4: флаг --verify-only и проверка подписи до всех предполётных проверок 2026-10-12 19:37:36 +03:00
hacker07 ada50ae24e Релиз v1.2.3: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:36:08 +03:00
hacker07 2e875aaa2f v1.2.3: установка одной командой (3x-ui-local.sh) — релиз из нашего канала + защита сервера 2026-10-12 19:36:00 +03:00
hacker07 88af9444c0 docs: команды защиты сервера в FORK.md
CI / check (push) Canceled after 0s
2026-10-12 19:25:19 +03:00
hacker07 e0ddf8fe25 Релиз v1.2.2: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:24:26 +03:00
hacker07 c83fde40e4 v1.2.2: команды защиты сервера (ssh/ping/passwd/upgrades/hardening из vps-setup, безопаснее) 2026-10-12 19:24:18 +03:00
hacker07 211053a36b Релиз v1.2.1: подпись ключом форка
CI / check (push) Canceled after 0s
2026-10-12 19:04:16 +03:00
hacker07 80258f1d45 fork: полный набор швов + свои записи о версии (FORK-CHANGELOG.md) 2026-10-12 19:04:01 +03:00
hacker07 7b30cd9b23 fork: швы поверх upstream 1.2 — источник Gitea, ключ подписи форка, версия 1.2.1 2026-10-12 19:03:16 +03:00
hacker07 45b8d1c783 fork: инструменты сборки и локальные дополнения (швы, синхронизация с автором) 2026-10-12 15:16:32 +03:00
maxdmitrov 47cdfd34a5 Подпись релиза 1.2 (с подсказкой о новой версии)
CI / check (push) Canceled after 0s
2026-10-06 19:33:59 +03:00
maxdmitrov ddfbabf0c2 kit: подсказка о новой версии в меню, после kit user add и kit net 2026-10-06 19:32:49 +03:00
maxdmitrov a94f755317 CHANGELOG 1.2: «Спасибо …» в конце пункта, чтобы терминал не показывал имена 2026-10-06 19:30:32 +03:00
maxdmitrov cd99a12c29 CHANGELOG 1.2: короче и от первого лица; README: без строки про вопросы по XKeen 2026-10-06 19:30:23 +03:00
maxdmitrov 2c6549a3af README: протоколы в строку, «распознают», без блока про DNS, диагностика в командах KIT, KIT заглавными 2026-10-06 19:26:04 +03:00
maxdmitrov 64fffa5e73 Подпись релиза 1.2 2026-10-06 19:19:12 +03:00
maxdmitrov 5913dcee09 CI: тесты проверки копии для --restore на GNU grep и bash 2026-10-06 18:58:50 +03:00
maxdmitrov 018d9cefa2 --restore: файлы настроек копии – только печатные ASCII, нулевой байт больше не выключает проверку
Нулевой байт в kit.env, install-result.env или kit-backup.env заставлял GNU grep считать
файл двоичным и молчать: safe_env «проходил», а bash при «.» выкидывал нулевые байты
и выполнял остальное (лишние имена вроде PATH и KIT_CLI_URL).

- проверка архива отклоняет три файла настроек с байтами вне 0x20–0x7E и \n (и больше 64 КиБ),
  3x-ui.txt – не UTF-8 или с управляющими символами (кроме \n и \t), больше 1 МиБ;
  проверяются все записи с таким именем, в том числе повторные;
- safe_env: сначала tr – только печатные ASCII и \n, затем grep -a под LC_ALL=C;
- grep -a там, где kit и установщик читают свои файлы настроек (kit.sh: замена токена API);
- отрицательные тесты: \0, \r, \x1b, \x80, \xff в начале, в середине, в конце, отдельной строкой
  и в значении в каждом из трёх файлов, повторная запись в архиве, 3x-ui.txt.
2026-10-06 18:58:38 +03:00
maxdmitrov 436acffc22 Мануал: --restore проверяет и значения в настройках копии 2026-10-06 18:40:45 +03:00
maxdmitrov 25df91e79a Hysteria2: заглушка байт в байт как у nginx
$(cat …) срезал последний перевод строки, и страница masquerade была на
байт короче той, что отдаёт nginx. Читаю файл целиком (установщик и
kit net masq on), запасная запись страницы – без лишнего перевода строки.
2026-10-06 18:40:01 +03:00
maxdmitrov 387d6ae66e nginx: путь панели и подписки перед сборкой конфига – только буквы, цифры, _ и -
XUI_WEB_BASE_PATH из install-result.env (при --restore – из копии) шёл в
«location ... {» как есть: строка с фигурными скобками добавляла в nginx
чужие директивы. Значение теперь проверяется при --restore (прошлая правка)
и ещё раз там, где собирается nginx: setup_nginx в 3x-ui.sh, kit net panel
и пути подписки в kit.
2026-10-06 18:38:08 +03:00
maxdmitrov 397804faa7 --restore: значения в файлах настроек копии – по формату каждого поля
Имена в kit.env, kit-backup.env и install-result.env проверялись по белому
списку, а значения – нет: MTPROTO_INNER из чужой копии попадал в выражение
sed в kit (флаг e – выполнение команды). Теперь у каждого имени свой формат
(порты – цифры, пути – буквы и цифры, хосты, флаги – допустимые слова),
кавычки – только пустые (''), неверное значение – копия отклоняется.
kit: sed с портом MTProto – только если порт число. Тесты на все поля.
2026-10-06 18:36:48 +03:00
maxdmitrov 5449a73fd8 Заглушка: окончания названий распределены ровнее
«Studio» было в вариантах у девяти тем из двадцати и заканчивало каждое пятое
название. Теперь ни одно окончание не встречается больше чем в двух вариантах
(около 3% названий); тест проверяет, что самое частое окончание – меньше 8%.
2026-10-06 17:06:26 +03:00
maxdmitrov f7a8475c88 kit-sub: Connection: keep-alive и ALPN http/1.1, как у nginx
На ответах, после которых соединение остаётся открытым, – «Connection: keep-alive»,
перед закрытием – «Connection: close» (раньше заголовка не было вовсе). На своём TLS
(--multi-port) kit-sub соглашается на ALPN http/1.1, как nginx без http2; клиент,
предложивший только h2, получает соединение без ALPN. Тесты – в kitsub_http_test.py.
2026-10-06 17:03:57 +03:00
maxdmitrov 220d82c01e kit-sub: на мусорную строку запроса – «HTTP/1.1 400 Bad Request», как у nginx
Python считал такой запрос HTTP/0.9 и отвечал одним телом, без строки статуса и
заголовков. Теперь ошибки разбора запроса идут со строкой статуса HTTP/1.1,
Server: nginx и Connection: close. Тесты – в tools/test/kitsub_http_test.py.
2026-10-06 17:02:34 +03:00
maxdmitrov 1aa9a84e96 --restore: копия с пустой папкой сертификата отклоняется до установки
Архив с root/cert/self или root/cert/custom без fullchain.pem/privkey.pem проходил
проверку, ставил 3X-UI и падал на копировании сертификата. Теперь проверка архива
требует оба непустых файла, а для копии со своим сертификатом (BACKUP_SSL=custom) –
папку root/cert/custom. Отрицательные тесты – в tools/test/restore_archive_test.py.
2026-10-06 17:00:59 +03:00
maxdmitrov 25848a24c1 --restore: самоподписанный сертификат, если Let's Encrypt не выдал его на новый IP
Подключения из копии ссылаются на /root/cert/ip, а без файла Xray не стартует,
хотя установщик писал «Готово». Теперь перед запуском панели проверяю файлы
сертификатов из базы; если нет – кладу самоподписанный на тот же путь (только
/root/cert/ip или /root/cert/self) и закрепляю отпечаток в подключениях, как при
обычной установке. Предупреждение – сразу и в блоке «Внимание».

В конце жду процесс Xray: если не поднялся – вместо «Готово» красное сообщение
и код возврата 1. Тест: tools/test/restore_archive_test.py.
2026-10-06 16:58:53 +03:00
maxdmitrov 972757ed75 kit net: перезапуск ядра Xray, если правка на ходу закрыла порт; kit check видит упавшее ядро и UDP
Панель 3.9.0 меняет подключение через API ядра: старое удаляет, а новое с маскировкой
Hysteria2 ядро 26.6.27 не принимает, и 443/udp закрывается (kit net masq on, kit net fp,
kit net on). Теперь после правки kit сверяет порты и при нужде перезапускает ядро
(server/restartXrayService, запасной путь – systemctl restart x-ui).

kit check: процесс Xray (дочерний у x-ui) и порты всех включённых подключений,
TCP и UDP отдельно (nginx на 443/tcp больше не считается за Hysteria2). kit fix
перезапускает ядро. Тест: tools/test/kit_ports_test.py.
2026-10-06 16:50:04 +03:00
maxdmitrov 159c1b271d Hysteria2: отдельный установщик убран из 1.2, на его месте заглушка
scripts/hysteria2.sh остаётся в подписанном релизе: старая `hy2 update`
скачает его, сверит SHA256, запустит `__update_binary`, получит код 1
с подсказкой и не заменит команду hy2. Заглушка ничего не удаляет и не
останавливает. Hysteria2 по-прежнему входит в основную установку.

Убраны мануал hysteria2.md и его картинки, проверка одинаковых ключей
kit/hy2 в release.sh и CI, строка Hysteria в check-upstream.sh.
2026-10-06 16:10:07 +03:00
maxdmitrov 888a4cb46b Заглушка: случайная страница при установке вместо девяти фиксированных
stub_site собирает страницу из частей: 20 тем (кафе, пекарня, фотограф, заметки,
мастерские, документация и т. п.) × 5 вёрсток = 100 вариантов, плюс случайное
название, подзаголовок, 3 пункта из 5, адрес, палитра, шрифт и скругление.
Без внешних ресурсов, UTF-8. Вымышленный бренд из старых шаблонов убран, в том числе
из запасной страницы kit net masq. Страница сохраняется в /var/www/kit/index.html
в обоих режимах: её показывают nginx и Hysteria2, её берёт kit net masq, её
возвращает --restore. Тест: tools/test/stub_test.py.
2026-10-06 15:54:00 +03:00
maxdmitrov da07108a83 core-compat: mihomo и sing-box – только с проверкой SHA256
Если GitHub не прислал digest архива, клиенты больше не ставятся без проверки:
скрипт останавливается с понятной ошибкой. Сумму можно закрепить вручную
(MIHOMO_SHA256, SINGBOX_SHA256), формат – 64 hex.
2026-10-06 15:43:30 +03:00
maxdmitrov 42be16a5a2 kit-sub: ответы по HTTP/1.1, как у nginx
В режиме --multi-port kit-sub отвечал строкой статуса HTTP/1.0 и «Server: nginx »
с пробелом. Теперь protocol_version = HTTP/1.1, заголовок Server без хвоста;
на ответы, после которых соединение закрывается (ошибки разбора, POST и т. п.),
добавлен Connection: close. Тест: tools/test/kitsub_http_test.py.
2026-10-06 15:42:30 +03:00
maxdmitrov 58fc3b4704 CHANGELOG: опечатка «иностранских» → «иностранных» 2026-10-06 15:41:20 +03:00
maxdmitrov 43835928bb --restore: файлы настроек из копии – только по белому списку имён, без подключения в основной shell
kit-backup.env больше не подключается через «.» в установщике: значения читаются
в подоболочке (env_get). safe_env проверяет имена для каждого файла (kit-backup.env,
install-result.env, kit.env); лишнее имя – «Это не резервная копия 3X-UI KIT…».
RESULT, XUI_ENV, PATH, IFS, KIT_* из архива не принимаются, HOST в kit.env должен
совпадать с адресом копии, порт панели – число. Тест: tools/test/restore_env_test.py.
2026-10-06 15:41:16 +03:00
maxdmitrov 7741f26eb4 Убрана фраза «проверено настоящими клиентами» из changelog, инструкции, комментария и README матрицы 2026-10-06 15:23:34 +03:00
maxdmitrov d2a299a088 README: плашка со звёздами 2026-10-06 15:22:32 +03:00
maxdmitrov 69796f9ed8 README: «Возможности» без команд, список 11 протоколов, переезд на новый сервер 2026-10-06 15:22:03 +03:00
maxdmitrov 1f495d70f3 README: предупреждение под шапкой, без меню-ссылок, плашка версии, новые «Возможности», без отдельной установки Hysteria2 2026-10-06 15:16:36 +03:00
maxdmitrov 53823c6734 README: врезка про XKeen в одну строку 2026-10-06 15:09:26 +03:00
maxdmitrov 5dc1140bf3 README: донат через Tribute вместо CloudTips 2026-10-06 15:08:32 +03:00
maxdmitrov 51388780dc README: команды kit свёрнутыми группами (details), все основные на месте 2026-10-06 15:07:15 +03:00
maxdmitrov 6e3da5774d README: команды kit коротко (8 строк), полный список – в инструкции 2026-10-06 15:05:38 +03:00
maxdmitrov a2e67e2521 README: команды kit по группам (пользователи, сеть, раздельная маршрутизация, обслуживание, hy2) таблицами 2026-10-06 15:04:14 +03:00
maxdmitrov 795a71f54b README: kit net split в таблице команд 2026-10-06 15:03:17 +03:00
maxdmitrov da6abc665d CHANGELOG 1.2: маскировка (свой домен, затем соседи по подсети), короче про DNS 2026-10-06 15:02:23 +03:00
maxdmitrov 1b11c966ff README: врезка о версии 1.2 вместо 1.1.2 2026-10-06 14:58:01 +03:00
maxdmitrov 635c909426 Откат 28a7917: формат sing-box для Karing и Hiddify не применяется клиентами (Karing игнорирует правила, Hiddify не показывает серверы) 2026-10-06 14:12:41 +03:00
maxdmitrov 8e2215f9f4 kit net split: Karing и Hiddify получают конфиг sing-box с правилами (route, dns без утечек, наборы .srs через VPN); kit net split check сверяет и наборы sing-box; тесты 2026-10-06 13:58:01 +03:00
maxdmitrov bb42586454 hysteria2.sh: убран перенос старого файла настроек прежнего проекта 2026-10-06 13:48:02 +03:00
maxdmitrov c1710d10d5 kit-sub: JSON-подписка с Content-Type text/plain определяется по телу и получает encryption none; RecursionError ловится; интеграционные тесты 2026-10-06 13:28:48 +03:00
maxdmitrov 81ae0337b4 kit-sub: JSON-подписка панели – vless без encryption получает none (обе формы, непустое не трогаем) 2026-10-06 13:25:14 +03:00
maxdmitrov 7d6f13d315 --restore: адреса из таблицы hosts и link_host меняются при смене IP; проверка панели перебирает http и https, пока панель не ответит 2026-10-06 12:08:36 +03:00
maxdmitrov 3efbc327f5 kit: при отказе токена API берёт новый командой панели (cli-fallback) и сохраняет в install-result.env 2026-10-06 11:21:28 +03:00
maxdmitrov 73bbaadd48 kit net split: маршрутизация для Happ (заголовок Routing) и JSON-подписки Xray через настройки панели, kit net split apply; проверено клиентами Mihomo и Xray 2026-10-06 11:16:20 +03:00
maxdmitrov d74ef02d65 kit net split: правила только приложениям на ядре Mihomo (Karing и другие на sing-box получают подписку прежней) 2026-10-05 23:10:15 +03:00
maxdmitrov 3a7b760dfc Тексты: без ссылок на операторов и блокировки в мануале и комментарии установщика 2026-10-05 21:57:06 +03:00
maxdmitrov a8a4aae6b3 kit net split: полный список популярных иностранных сервисов, проверка GEOSITE и GEOIP по базе, прощающий разбор файла, проверка прав на файл 2026-10-05 21:54:28 +03:00
maxdmitrov 88f2c1066b kit net split: больше категорий в шаблоне (комментарии), проверка названий GEOSITE по базе в kit net split check, честное сообщение об ошибке 2026-10-05 21:50:38 +03:00
maxdmitrov 3ea6c0c868 kit net split: раздельная маршрутизация в Clash-подписке (rules.yaml, без утечек DNS), в копию и восстановление; kit backup работает не только из /; понятное сообщение при отказе панели 2026-10-05 21:46:51 +03:00
maxdmitrov 1c71ff5c24 tools/core-compat.sh: проверка нового ядра Xray клиентами Mihomo и sing-box с подменой ядра и возвратом; check-upstream показывает версии клиентов 2026-10-05 18:15:45 +03:00
maxdmitrov 31bc2d5480 Установщик: «рекомендуем» у своего домена, без «mihomo» в строке о проекте, без ссылок «открыть в приложении» и без деления протоколов на группы; обновление панели проверяет подключения клиентом и откатывает при сбое; README: отдельная Hysteria2 наравне с 11 протоколами 2026-10-05 18:01:28 +03:00
maxdmitrov 97718bd3e6 Страница подписки: адреса Clash и JSON работают через kit-sub и nginx (с «Авто» и DNS), подключаются при установке и при kit update 2026-10-05 17:48:10 +03:00
maxdmitrov 2a8cfe6ea2 kit-sub: файлы страницы подписки для браузера (assets); ссылки «открыть в приложении» для Hiddify, Clash Verge, FlClash, Mihomo Party, v2rayNG 2026-10-05 17:40:48 +03:00
maxdmitrov c4e6ed1a5d Ссылки «открыть в приложении» (Karing, Happ) рядом с подпиской: в kit user link и в итоге установки 2026-10-05 17:39:43 +03:00
maxdmitrov e818d22c65 Сертификат домена: до трёх попыток при временных сбоях Let's Encrypt 2026-10-05 17:20:00 +03:00
maxdmitrov 7425c672b8 Правки по независимому аудиту: --restore (сертификат домена, xver при сбое, LINK_HOST), REALITY-2 при панели по домену, резервная копия базы через SQLite, ответы kit-sub как у nginx, меньше параллельность подбора сайта, перемешивание запасных сайтов, нейтральные тексты 2026-10-05 16:51:26 +03:00
maxdmitrov 00319a6655 Панель и подписка по домену или по IP: kit net panel, --panel-on при установке, выбор в диалоге, возврат при --restore 2026-10-05 16:38:41 +03:00
maxdmitrov f22e043902 Тексты: нейтральные формулировки 2026-10-05 16:28:55 +03:00
maxdmitrov 26019b39f4 README: подсказка про выбор протокола 2026-10-05 16:28:55 +03:00
maxdmitrov 3422bee5e1 README: подсказка про выбор протокола (2) 2026-10-05 16:28:55 +03:00
maxdmitrov 7161ef6fe8 systemd старше 247: установщик останавливается с понятным сообщением (kit-sub без root требует LoadCredential), kit update не трогает юнит 2026-10-05 16:01:52 +03:00
maxdmitrov 21c3ac18c2 DNS: безопасный dns в Clash-подписке (kit-sub), DoH в Xray JSON (kit net dns), AmneziaWG вне автовыбора; проверено клиентами Mihomo и Xray 2026-10-05 16:00:59 +03:00
maxdmitrov eb152b201f Hysteria 2.13.0: закреплена проверенная версия (суммы сверены, чистая установка и подключение клиента проверены) 2026-10-05 11:18:46 +03:00
maxdmitrov a6b70c154a shellcheck: SC2155 в kit sni rotate 2026-10-04 20:46:31 +03:00
maxdmitrov 2b5b9c8d9e manuals: меню kit, kit net, kit check --deep/--fix, --restore 2026-10-04 20:46:03 +03:00
maxdmitrov 58af616ced changelog 1.2: уточнил, за что спасибо 2026-10-04 20:45:32 +03:00
maxdmitrov 0f5912b198 README и changelog 1.2: меню kit, kit net, --restore, раздел «Если связь пропала» 2026-10-04 20:45:24 +03:00
maxdmitrov 85f3535a1c Hysteria2: ответ страницей сайта на чужой HTTP/3-запрос (masquerade, kit net masq); kit check --deep проверяет и Hysteria2, с повтором при перезапуске ядра 2026-10-04 20:44:45 +03:00
maxdmitrov 844ac083b3 Установщик: соседи для XHTTP и MTProto, повтор итоговой проверки, имена с сомнительными словами не берём; kit net site предупреждает о запасном сайте 2026-10-04 20:41:24 +03:00
maxdmitrov 4c795ea6e0 kit check: предупреждения о сайте-бренде на чужом IP и о запасных портах с пустым ответом 2026-10-04 20:36:12 +03:00
maxdmitrov f7ac1ddf49 Установщик: шаги [1/6]–[6/6], выбор маскировки (домен, сосед по подсети, свой сайт), блок ссылок по группам, итог с блоком «Внимание», адрес в ссылках LINK_HOST (домен в ссылках и MTProto), REALITY-2 не ставится по умолчанию, новая заглушка вместо «Welcome to nginx» 2026-10-04 20:28:24 +03:00
maxdmitrov 346159210b Подбор сайта по соседям: блок /23, проверка ответа на «/», без Cloudflare и брендов; подтверждение перед сменой 2026-10-04 20:22:54 +03:00
maxdmitrov 0a9cce18b6 kit 1.2: меню как у x-ui (kit без аргументов), kit net (протоколы, порты, сайт, отпечаток, вкл/выкл), блок ссылок по группам, kit check --fix, kit update --panel; версия 1.2 2026-10-04 20:20:20 +03:00
maxdmitrov 4246031ff9 Второй REALITY на высоком порту: ставится установщиком, kit reality add для существующих серверов; kit check --deep подписывает XHTTP и порт 2026-10-04 19:57:17 +03:00
maxdmitrov 4b336d7395 --restore: чистка WAL-файлов перед подменой базы (иначе база «битая»), включение таймера общего лимита 2026-10-04 19:39:02 +03:00
maxdmitrov 31db2d30a4 --restore: восстановление из kit backup возвращено (проверка архива, смена IP), будет проверено на чистом сервере 2026-10-04 19:28:32 +03:00
maxdmitrov 2bcfb95b05 kit port, kit port set: смена порта подключения с ufw и проверкой занятости по tcp и udp 2026-10-04 19:27:00 +03:00
maxdmitrov c9fc0c25be kit check --deep: порты всех подключений и подключение настоящим клиентом Xray через REALITY и XHTTP 2026-10-04 19:25:24 +03:00
maxdmitrov a19f60c8b0 kit sni rotate --nearby: без динамических доменов, только сайты с проверенным сертификатом; подсказка проверить сайты 2026-10-04 19:23:17 +03:00
maxdmitrov d6109a9b3a kit-sub: в Clash-подписке группа «Авто» (url-test) по умолчанию, ручной выбор протокола остаётся 2026-10-04 19:04:00 +03:00
maxdmitrov 88feba6190 Маскировка: свой домен предлагается первым, предупреждение о чужом сайте; три заглушки под облачный сервис; kit sni и kit sni rotate (в том числе --nearby) 2026-10-04 19:03:01 +03:00
maxdmitrov 5232e431de REALITY: у новых пользователей включён xtls-rprx-vision; kit user vision для существующих; kit check сообщает, у кого он выключен 2026-10-04 02:03:56 +03:00
maxdmitrov 2e40b85fd4 tools/check-upstream.sh: сравнение закреплённых версий с последними релизами 2026-10-04 01:48:13 +03:00
maxdmitrov d751093bbd kit panel update: проверенная версия панели без смены ядра и настроек, откат при сбое; kit fix возвращает проверенное ядро; предупреждения о версии панели; tools/release.sh сверяет суммы 2026-10-04 01:42:55 +03:00
maxdmitrov ec25f0e0e8 3X-UI v3.9.0: закреплён установщик; установка ядра не падает на ответе панели с ошибкой GitHub API 2026-10-04 00:59:03 +03:00
maxdmitrov ca74a1dee3 1.2: kit user link --amnezia и --telegram; kit fix: ставит systemd-timesyncd на Debian 13 без NTP-клиента; kit check проверяет содержимое подписки 2026-10-03 23:23:59 +03:00
maxdmitrov 0f52237286 kit: общий лимит трафика (сумма по записям пользователя), kit user enforce, таймер раз в 5 минут, проверка в kit check и kit fix 2026-10-03 23:20:04 +03:00
maxdmitrov 173c227c2e README: команды kit и hy2 списком с короткими комментариями 2026-10-03 23:16:54 +03:00
maxdmitrov 0e7372efb7 kit user link: в режиме «панель через SSH-туннель» показываются отдельные ссылки на протоколы, --all работает с https-подпиской 2026-10-03 22:35:51 +03:00
maxdmitrov 67bb5bc231 Релиз v1.1.2: подпись
CI / check (push) Canceled after 0s
2026-10-03 21:38:08 +03:00
maxdmitrov 3d3f3e2d9d 1.1.2: исправление установки с сертификатом Let's Encrypt на IP, ufw и порты SSH, параметры, kit-sub, kit user 2026-10-03 21:36:22 +03:00
maxdmitrovandClaude Sonnet 5.5 fe065d445a Баннер: ровная табличка
CI / check (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:05:19 +03:00
maxdmitrovandClaude Sonnet 5.5 71d40360ab Релиз v1.1.1: подпись
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:00:30 +03:00
099117c2a7 1.1.1: kit check и kit fix, заголовки Happ, безопасная загрузка ядра, инструкция XKeen
- kit check и kit fix: проверка служб, сертификата, подписки, сайта маскировки, прав на файлы; безопасная починка
- kit-sub: пропускает заголовки Happ (Routing и др.) до клиентов
- ядро Xray: запасная загрузка через зеркало только с проверкой SHA256, tools/release.sh сверяет суммы с официальными
- установка со своим доменом: при отказе Let's Encrypt предлагается самоподписанный сертификат
- README: инструкция XKeen, команды kit, новый баннер; правки инструкций и changelog

Co-authored-by: xlok1e <124461726+xlok1e@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 16:47:12 +03:00
39 changed files with 6749 additions and 1069 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+4 -4
View File
@@ -18,16 +18,16 @@ jobs:
run: python3 -m py_compile scripts/kit-sub.py run: python3 -m py_compile scripts/kit-sub.py
- name: Генераторы - name: Генераторы
run: node --test tools/test/*.test.js 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 - name: Версия в скриптах совпадает с VERSION
run: | run: |
v=$(cat VERSION) v=$(cat VERSION)
grep -q "^KIT_VERSION=\"$v\"" scripts/3x-ui.sh grep -q "^KIT_VERSION=\"$v\"" scripts/3x-ui.sh
grep -q "^KIT_VERSION=\"$v\"" scripts/hysteria2.sh grep -q "^KIT_VERSION=\"$v\"" scripts/hysteria2.sh
grep -q "^KIT_VERSION=\"$v\"" scripts/kit.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: Подпись релиза совпадает с файлами - name: Подпись релиза совпадает с файлами
run: | run: |
[ -f SHA256SUMS ] || { echo "SHA256SUMS нет – релиз ещё не подписан"; exit 0; } [ -f SHA256SUMS ] || { echo "SHA256SUMS нет – релиз ещё не подписан"; exit 0; }
+3
View File
@@ -0,0 +1,3 @@
*.pyc
.DS_Store
__pycache__/
+32 -20
View File
@@ -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: безопасность, автообновление и новый генератор для роутера ## v1.1: безопасность, автообновление и новый генератор для роутера
**Новое** * Обновления приходят сами по ночам и только с моей подписью: «мам, удали старый VPN» больше не нужно. Не любите сюрпризы: `kit update --manual`. Копия сервера: `kit backup`.
* Маскировка на выбор: чужой сайт или свой домен. На случайный заход сервер изображает кофейню, фотографа или блог про Linux.
* Один генератор для роутера вместо двух: Keenetic и OpenWrt, Xray или Mihomo. Меньше вкладок, меньше «а я точно там нажал?».
* Безопасность: установщики проверяются по SHA256 (подмену, как незнакомца с «выгодным предложением», не пускаем), Hysteria2 не исполняет почту как команду, `kit-sub` больше не root.
* Обновления приходят сами. Раз в сутки ночью сервер проверяет подпись автора и обновляет `kit`, а пользователи и ссылки остаются прежними. «Мам, удали старый VPN, вот новый» больше не нужно. Подпись не сошлась или подписка замолчала – откат. Не любите сюрпризы: `kit update --manual`. С 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`
* Копия сервера одной командой. `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`. Новые установки получают всё сразу.
## v1.0 ## v1.0
+100
View File
@@ -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`.
+202
View File
@@ -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`.
+169 -87
View File
@@ -1,114 +1,196 @@
<div align="center"> <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, одна подписка на все устройства**
[![Протоколов](https://img.shields.io/badge/протоколов-11-93E06F?labelColor=221B17)](manuals/3x-ui.md#протоколы) Сборка со **своим каналом обновлений**: сервер ставится и обновляется из этого репозитория, релизы
[![3X-UI](https://img.shields.io/badge/3X--UI-v3.8.5-93E06F?labelColor=221B17)](https://github.com/MHSanaei/3x-ui) подписаны, ночное автообновление не вернёт чужую версию. В основе — открытый проект 3X-UI KIT.
[![Обновлено](https://img.shields.io/github/last-commit/itsnotkubrick/3X-UI_KIT?label=обновлено&color=93E06F&labelColor=221B17)](https://github.com/itsnotkubrick/3X-UI_KIT/commits)
[Возможности](#возможности) · [Установка](#установка) · [Генератор](#генератор-конфигов-для-роутера) · [Полезное](#полезное) · [Поддержать](#поддержать-проект)
</div> </div>
--- ## Что ставится
> [!TIP] | Что | Зачем |
> **Вышла версия 1.1:** исправления по аудиту безопасности, автообновление только подписанных |---|---|
> релизов, выбор маскировки (свой домен). [Что нового](https://github.com/itsnotkubrick/3X-UI_KIT/releases/tag/v1.1) · | Панель **3X-UI** + ядро **Xray** | управление подключениями и пользователями |
> сервер на 1.0 обновляется одной командой: | **nginx** на порту 443 | разводит подключения по именам и путям, отдаёт сайт-заглушку |
> ```bash | VPN-подключения | REALITY, Hysteria2, AmneziaWG, XHTTP, VLESS-WS, Trojan-gRPC, VMess-WS, Shadowsocks, TUIC, MTProto, WireGuard — какие выберете |
> 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 | **Подписка** (`kit-sub`) | одна ссылка на все устройства: Happ, Hiddify, v2rayN, Clash/Mihomo |
> ``` | **Сайт-заглушка** | на 443 и в ответе Hysteria2 — правдоподобный ИТ-сайт из нашего набора |
| **ufw**, обновления безопасности, синхронизация времени | защита сервера и корректная работа REALITY |
**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** – на свежем сервере они свободны
## Установка ## Установка
Подключитесь к серверу по SSH и выполните: От root, на чистом сервере (Debian 11+ / Ubuntu 22.04+, 64-бит):
```bash ```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.
Подробно – подключение приложений, дополнительные пользователи и параметры – <img alt="Установка одной командой" src="manuals/assets/script-3x-ui-local.svg" width="860">
в **[инструкции](manuals/3x-ui.md)**. Нужен только Hysteria2 – есть
[отдельный скрипт](manuals/hysteria2.md).
> [!WARNING] Если вопросы не нужны — те же ответы задаются флагами:
> Проект создан в образовательных целях. Убедитесь, что ваши действия
> соответствуют законодательству вашей страны.
## Генератор конфигов для роутера ```bash
# минимум: свой домен и два протокола, без вопросов
bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols reality,awg3 --domain vpn.example.com
Вставьте ссылку на сервер или подписку, выберите роутер и ядро, отметьте нужные сервисы – # всё, кроме WireGuard, и второй порт SSH
и получите готовый конфиг и одну команду, которая сама положит его на роутер. bash <(curl -fsSL …/scripts/3x-ui-local.sh) --protocols все --ssh-port 2222
Всё считается в браузере, ссылки никуда не отправляются.
Как поставить XKeen на роутер – в [инструкции для Keenetic](manuals/xkeen-keenetic.md).
| Роутер | Ядро | Что получится | Генератор | # ничего не спрашивать: ping закрыть, автообновления включить, порт и пароль не трогать
|---|---|---|---| bash <(curl -fsSL …/scripts/3x-ui-local.sh) --yes
| Keenetic (XKeen) | Xray | `04_outbounds.json` и `05_routing.json`: серверы, выбор сервисов, реклама, свои сайты | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?core=xray) | ```
| Keenetic (XKeen) | Mihomo | `config.yaml` с автовыбором сервера, подпиской, Hysteria2, AmneziaWG и веб-панелью | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?core=mihomo) |
| OpenWrt (Nikki), бета | Mihomo | профиль для Nikki и команда, которая его включит | [Открыть →](https://itsnotkubrick.github.io/3X-UI_KIT/tools/?router=openwrt) |
## Полезное Проверить канал, ничего не меняя:
- [XKeen](https://github.com/jameszeroX/XKeen) и его [вики](https://github.com/jameszeroX/XKeen/wiki) – документация по маршрутизации на Keenetic ```bash
- [XKeen UI](https://github.com/zxc-rv/XKeen-UI) – веб-интерфейс для XKeen bash <(curl -fsSL …/scripts/3x-ui-local.sh) --verify-only # подпись релиза и сумма установщика
- [IP-адреса для AmneziaWG](https://github.com/RockBlack-VPN/ip-address) – актуальные списки от RockBlack bash <(curl -fsSL …/scripts/3x-ui-local.sh) --dry-run # что именно будет сделано
```
## Поддержать проект ### Флаги установки
Скрипты и инструкции бесплатные. Донат добровольный – он помогает оплачивать | Флаг | Что делает |
тестовые серверы и держать скрипты в актуальном состоянии. Спасибо! 💜
| Способ | |
|---|---| |---|---|
| Российской картой, СБП, Tinkoff Pay | [CloudTips](https://pay.cloudtips.ru/p/d4f9e3d1) | | `--protocols all\|minimal\|список` | какие протоколы ставить: `all`, `minimal` (только REALITY) или через запятую. Невыбранные не создаются вообще |
| Зарубежной картой, Apple Pay, Google Pay | [Buy Me a Coffee](https://buymeacoffee.com/relo.cate) | | `--domain ваш.домен` | свой домен для маскировки (A-запись на сервер, свободный порт 80 — сертификат Let's Encrypt выпустит установщик) |
| USDT (TRC-20) | `TS83ViXrdezUpp1eFadqj1rBhGLZaba1c1` | | `--sni сайт.ком` | маскировка под чужой сайт вместо своего домена |
| TON | `UQBchO4XFPwF9MMa_tjXpwqTo8IL2FhUDyllhYuFo8WM-Qbf` | | `--panel-on ip\|domain` | где открывать панель и подписку |
| Ethereum (ERC-20) | `0xC06F6B3A029d7Ea00705B7028490744e2BC16799` | | `--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 построен на работе авторов этих проектов: 1. **Проверка сервера**: root, Debian/Ubuntu, память, место на диске, занятые порты 80/443.
[3X-UI](https://github.com/MHSanaei/3x-ui) · 2. **Проверка релиза**: файлы берутся из этого репозитория по тегу, проверяется **подпись `SHA256SUMS`**
[Xray-core](https://github.com/XTLS/Xray-core) · нашим ключом и SHA256 самого установщика. Не сошлось — ничего не запускается.
[Mihomo](https://github.com/MetaCubeX/mihomo) · 3. **Выбор протоколов** и запуск установщика: панель 3X-UI, выбранные подключения, nginx на 443,
[Hysteria](https://github.com/apernet/hysteria) · сертификат, сайт-заглушка, служба подписки, правила ufw.
[AmneziaWG](https://github.com/amnezia-vpn) · 4. **Сайт-заглушка**: ставится случайный правдоподобный ИТ-сайт из нашего набора (10 штук) и
[XKeen](https://github.com/jameszeroX/XKeen) синхронизируется в ответ 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
View File
@@ -1,6 +1,8 @@
# 3X-UI KIT 1.1 # 3X-UI KIT 1.2.6
313d8d47f425b51dc5888af7fd2015dd99f44c1fac0022f140ef8de91b35d99f scripts/3x-ui.sh db677f17e1c8f59e0c5f329faff6f6178454b75b266966b80c225b8923318831 scripts/3x-ui.sh
3497f4b55aedc91940dd03a3bf0d3c8153f5e86c0c9a4a4895f38da540a645e6 scripts/hysteria2.sh b8bc9cb115e427b601d2d9ea0b3e1c759b4fc0f9f36b267e364e47658422e37d scripts/hysteria2.sh
f585d789d82a326ae1775f74494e7230178969b8a9e8c1fd382c5092877c8d73 scripts/kit.sh 1215b3435a5f476dee26fd732640c6d33a7445585f14108a289ae4eafb05b439 scripts/kit.sh
aae32dea127a50cf6e5b30915a21168d1a88f5c1010d641c19820a52d50afef3 scripts/kit-sub.py d393dc04a69bad2ce9f022b2f51e0d349b6655c5c92fc64cf67402c5a63e2f3a scripts/kit-sub.py
1523e9e982ff804c6d979e1bb2c6f3d7bae35307e8ce458bffc0d12a442b98da VERSION ae81cd252affc1bbfdc59421dfa30f85eb6bbbd3e178faceb7e6f708f36b6730 scripts/kit-local.sh
eaf72eb353c14de8bc9cd567b396efc40eb1493c3839009465f0f8b35f0db557 scripts/3x-ui-local.sh
5844ffcc346f89c07b13ba7596bfb3788ed73f4755e541182d7822d43b7c7a24 VERSION
+4 -4
View File
@@ -1,6 +1,6 @@
-----BEGIN SSH SIGNATURE----- -----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgatMYIOGGebxc7V6Sk8m5JZFUIm U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgozRJ+f9RB6qO9S9j3BWCDtC0bV
IWK6kSyl9DFxsQGbMAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA FHakRSSqtQPljLoQUAAAARM3gtdWkta2l0LXJlbGVhc2UAAAAAAAAABnNoYTUxMgAAAFMA
AAALc3NoLWVkMjU1MTkAAABAsmJHzAWZ9uDnSG5hoMfvH/vgx2rI3qG33quMNBAVsAE5c1 AAALc3NoLWVkMjU1MTkAAABAHRB6TB0NGpmJ1w5Ua1PEGUrmlQUuM99cll5nnhO9EJvMRz
zi16FA4+hxyGYqRsxN/l30ZfyfURrpRKAAoMQHBw== IoDmvnCMGC4Xgh22IeRR88vtLkKz3hgtMoMYRLAg==
-----END SSH SIGNATURE----- -----END SSH SIGNATURE-----
+1 -1
View File
@@ -1 +1 @@
1.1 1.2.6
+110 -18
View File
@@ -30,7 +30,7 @@
| VLESS WebSocket + TLS | 443/tcp | всех приложениях | | VLESS WebSocket + TLS | 443/tcp | всех приложениях |
| Trojan gRPC + TLS | 443/tcp | всех приложениях | | Trojan gRPC + TLS | 443/tcp | всех приложениях |
| VMess WebSocket + 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 | | TUIC v5 | 8444/udp | Hiddify, Clash Verge, FlClash, NekoBox |
| AmneziaWG | 51821/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash | | AmneziaWG | 51821/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash |
| AmneziaWG 3.1 | 51822/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash | | AmneziaWG 3.1 | 51822/udp | AmneziaVPN, AmneziaWG, Clash Verge, FlClash |
@@ -38,17 +38,16 @@
**Все TCP-протоколы, панель и подписка работают через один порт 443.** Его слушает nginx: **Все TCP-протоколы, панель и подписка работают через один порт 443.** Его слушает nginx:
по имени сайта в запросе (SNI) он узнаёт REALITY, XHTTP и MTProto, остальное расшифровывает по имени сайта в запросе (SNI) он узнаёт REALITY, XHTTP и MTProto, остальное расшифровывает
и разводит по секретным путям, а на всё прочее показывает сайт-заглушку. Снаружи у сервера и разводит по секретным путям, а на всё прочее показывает сайт-заглушку. Заглушка своя у каждого сервера:
при установке она собирается случайно (тема, название, тексты, вёрстка, цвета) и лежит в `/var/www/kit/index.html`,
её же показывает Hysteria2. Можно положить туда свою страницу. Снаружи у сервера
открыты только 443, 80 (для продления сертификата), SSH, порт Shadowsocks 8388 и UDP-порты. Настоящие IP клиентов открыты только 443, 80 (для продления сертификата), SSH, порт Shadowsocks 8388 и UDP-порты. Настоящие IP клиентов
доходят до панели, поэтому лимит устройств работает. доходят до панели, поэтому лимит устройств работает.
Каждый протокол проверен настоящим подключением на ядрах Xray, Mihomo и sing-box,
в том числе через интернет к серверу в России – стенд лежит в [tests/matrix](../tests/matrix/).
> [!NOTE] > [!NOTE]
> Обычный WireGuard по умолчанию не ставится: в России его рукопожатие режет DPI, > Обычный WireGuard по умолчанию не ставится: его рукопожатие легко распознаётся сетевым
> а попытки подключения могут привести к блокировке IP сервера. AmneziaWG – тот же WireGuard > оборудованием, из-за чего соединение нестабильно. AmneziaWG – тот же WireGuard с маскировкой –
> с маскировкой – проходит. Если сервер и пользователи за границей, добавьте `wg` в `--protocols`. > работает лучше. Если сервер и пользователи за границей, добавьте `wg` в `--protocols`.
## Что понадобится ## Что понадобится
@@ -92,6 +91,12 @@ Enter) – стандартный чужой сайт, работает сраз
а Happ, v2rayN, Karing и Hiddify – нет, потому что не умеют его. Для AmneziaVPN импортируйте а Happ, v2rayN, Karing и Hiddify – нет, потому что не умеют его. Для AmneziaVPN импортируйте
ссылки `vpn://` из `/root/3x-ui.txt`, для Telegram – ссылку `tg://` оттуда же. ссылки `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-кодом: Одна команда на сервере добавляет пользователя сразу во все протоколы и выдаёт его подписку с QR-кодом:
@@ -104,7 +109,7 @@ kit user add sasha --gb 50 --days 30
|---|---| |---|---|
| `kit user add имя [--gb N] [--days N] [--devices N]` | добавить; лимит трафика, срок и число устройств – по желанию | | `kit user add имя [--gb N] [--days N] [--devices N]` | добавить; лимит трафика, срок и число устройств – по желанию |
| `kit user list` | кто сколько израсходовал, до какого числа и когда был в сети | | `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 limit имя --gb N --days N` | изменить лимиты (0 – без ограничений) |
| `kit user off имя` / `kit user on имя` | временно выключить и включить | | `kit user off имя` / `kit user on имя` | временно выключить и включить |
| `kit user del имя` | удалить | | `kit user del имя` | удалить |
@@ -113,7 +118,7 @@ kit user add sasha --gb 50 --days 30
## Обновление ## Обновление
Обновления kit и подписки kit-sub приходят сами: раз в сутки ночью сервер проверяет, не вышла ли Обновления KIT и подписки kit-sub приходят сами: раз в сутки ночью сервер проверяет, не вышла ли
новая версия 3X-UI KIT, и ставит её. Пользователи, ссылки и подписки не меняются, раздавать новая версия 3X-UI KIT, и ставит её. Пользователи, ссылки и подписки не меняются, раздавать
ничего заново не нужно. ничего заново не нужно.
@@ -135,30 +140,117 @@ kit version # версии kit, панели и ядра, включе
Сервер ставили версией 1.0? В ней команды `kit update` ещё нет, поэтому один раз: Сервер ставили версией 1.0? В ней команды `kit update` ещё нет, поэтому один раз:
```bash ```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. версии: с 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 ```bash
kit backup kit backup
``` ```
Сохранит в `/root/kit-backup-ДАТА.tar.gz` всё, что нужно, чтобы поднять тот же сервер в другом Сохранит в `/root/kit-backup-ДАТА.tar.gz` копию сервера: подключения, ключи, пользователей с их
месте: подключения, ключи, пользователей с их лимитами, настройки подписки и nginx. Файл виден лимитами, настройки подписки и nginx. Файл виден только root, и в нём ключи от сервера, поэтому
только root, и в нём ключи от сервера, поэтому храните его как пароль. Скачать к себе: храните его как пароль. Скачать к себе:
```bash ```bash
scp root@IP:/root/kit-backup-ДАТА.tar.gz . 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
View File
@@ -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> <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"/> <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 y="262" width="1200" height="38" fill="#120e0c"/></g>
<rect x="1.5" y="1.5" width="1197" height="297" rx="19" fill="none" stroke="#15110F" stroke-width="3"/> <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"> <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="104" 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> <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="42" letter-spacing="1"> <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="164" fill="#8A66C4" stroke="#15110F" stroke-width="10" stroke-linejoin="round" transform="translate(3 4)">СВОЙ VPN ЗА МИНУТЫ</text> <text x="82" y="156" fill="#8A66C4" stroke="#15110F" stroke-width="10" stroke-linejoin="round" transform="translate(3 4)">УСТАНОВКА ОДНОЙ КОМАНДОЙ</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="156" fill="#93E06F" stroke="#15110F" stroke-width="8" stroke-linejoin="round" style="paint-order:stroke">УСТАНОВКА ОДНОЙ КОМАНДОЙ</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="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><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 110)"><rect x="783" y="40" width="360" height="140" rx="14" fill="#15110F"/>
<g transform="rotate(2 940 130)"><rect x="763" y="54" width="360" height="156" rx="14" fill="#15110F"/> <rect x="778" y="34" width="360" height="140" rx="14" fill="#221B17" stroke="#15110F" stroke-width="4"/>
<rect x="758" y="48" width="360" height="156" 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"/>
<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="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="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="798" y="116" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" font-weight="700" font-size="13" fill="#CBBFA9">==&gt; Ставлю 3X-UI…</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="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="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="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>
<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(-4 1000 222)"><rect x="854" y="206" width="292" height="52" rx="6" fill="#15110F" transform="translate(4 5)"/>
<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="854" y="206" width="292" height="52" rx="6" fill="#F2E8D2" stroke="#15110F" stroke-width="3"/>
<rect x="880" y="222" width="236" height="46" 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"/>
<circle cx="893" cy="235" r="3.2" fill="#15110F"/><circle cx="1103" cy="235" 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="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="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>
<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>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

+10
View File
@@ -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
View File
@@ -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">==&gt; </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">==&gt; </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
View File
@@ -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 &lt;(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">==&gt; </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">==&gt; </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">==&gt; </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">==&gt; </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

-77
View File
@@ -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)
```
![Установка Hysteria2](assets/script-hysteria2.svg)
1. Скрипт спросит **домен и почту**. Нет домена – просто нажмите Enter.
2. В конце он покажет **ссылку** `hy2://` и QR-код.
> [!TIP]
> Без домена всё тоже работает. Домен даёт настоящий сертификат и
> сайт-заглушку, так что сервер снаружи выглядит обычным сайтом.
## Подключение
Ссылку `hy2://` поддерживают:
| Устройство | Приложение |
|---|---|
| iPhone, iPad | Streisand, Hiddify, Happ |
| Android | Hiddify, NekoBox |
| Windows, macOS, Linux | Hiddify, v2rayN |
## Дополнительные пользователи и управление
![Команды hy2](assets/script-hy2-add.svg)
| Команда | Что делает |
|---|---|
| `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)
+413
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+16 -545
View File
@@ -1,549 +1,20 @@
#!/usr/bin/env bash #!/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) # Отдельный установщик Hysteria2 больше не поддерживается: Hysteria2 входит в основную
# Управление: hy2 help # установку 3X-UI KIT (scripts/3x-ui.sh). Файл оставлен, чтобы старая команда `hy2 update`
# # получила из подписанного релиза понятное сообщение. Ничего не ставит, не удаляет и не
# Ставит официальный бинарник Hysteria2 (версия закреплена ниже, контрольная # останавливает: сервер Hysteria2, установленный раньше, продолжает работать как был.
# сумма проверяется), настраивает сертификат, сайт-заглушку, ufw и выдаёт # Код выхода 1 при любом запуске (и при `__update_binary`) – старый `hy2 update` на нём
# ссылку hy2:// с QR-кодом. # останавливается и не заменяет команду hy2.
set -Eeuo pipefail KIT_VERSION="1.2.6"
# На свежем VPS в фоне идут автообновления системы и держат замок dpkg: ждём его, а не падаем. cat >&2 <<'MSG'
apt-get() { command apt-get -o DPkg::Lock::Timeout=900 "$@"; } Отдельный установщик Hysteria2 больше не поддерживается.
Сервер Hysteria2 продолжает работать как раньше, но не обновляется этим скриптом.
HY_VERSION="2.12.3" Hysteria2 входит в основную установку 3X-UI KIT (один из 11 протоколов).
HY_REPO="HyNetworks/hysteria" Если нужен только Hysteria2 без панели – официальный установщик:
# SHA256 бинарников этой версии, записанные в сам скрипт: проверка не зависит от файла bash <(curl -fsSL https://get.hy2.sh/)
# hashes.txt, который лежит там же, где бинарник (2026-09-30, hashes.txt релиза app/v2.12.3). MSG
declare -A HY_SHA256=( exit 1
[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 "$@"
+888
View File
@@ -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
View File
@@ -14,11 +14,14 @@ https://github.com/itsnotkubrick/3X-UI_KIT
""" """
import base64 import base64
import http.client
import http.server import http.server
import ipaddress
import json import json
import os import os
import re import re
import socket import socket
import sys
import ssl import ssl
import threading import threading
import time import time
@@ -37,28 +40,47 @@ CLASH_UA = re.compile(r"clash|mihomo|flclash|stash|nyanpasu|meta", re.I)
# тоже могут просить формат Clash (Karing так и делает), но AmneziaWG не умеют. # тоже могут просить формат Clash (Karing так и делает), но AmneziaWG не умеют.
NO_AWG_UA = re.compile(r"karing|hiddify|nekobox|sing-?box|husi|stash|shadowrocket|v2box|streisand|happ|loon|surge|quantumult", re.I) NO_AWG_UA = re.compile(r"karing|hiddify|nekobox|sing-?box|husi|stash|shadowrocket|v2box|streisand|happ|loon|surge|quantumult", re.I)
SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$") SUB_ID = re.compile(r"^[A-Za-z0-9_.@-]{1,64}$")
# Страница подписки для браузера (React от 3X-UI) подгружает скрипты и стили из «<путь>/assets/…»: пропускаем только такие файлы.
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", 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: with open(CONFIG, encoding="utf-8") as f:
CONF = json.load(f) CONF = json.load(f)
PATH = "/" + CONF["path"].strip("/") + "/" 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): def log(msg):
print(msg, flush=True) 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"").""" """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 "*/*"}) "User-Agent": ua, "Host": host, "Accept": accept or "*/*"})
try: try:
with urllib.request.urlopen(req, timeout=15) as r: with urllib.request.urlopen(req, timeout=15) as r:
return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read() return r.status, {k.lower(): v for k, v in r.getheaders()}, r.read()
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read() 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}") log(f"upstream недоступен: {e}")
return None, {}, b"" return None, {}, b""
@@ -69,6 +91,42 @@ def fix_userinfo(value):
return "; ".join(parts) 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): def strip_links(body):
"""Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение """Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение
со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram.""" со ссылками: 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() 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): class Handler(http.server.BaseHTTPRequestHandler):
server_version = "nginx" server_version = "nginx"
sys_version = "" sys_version = ""
protocol_version = "HTTP/1.1" # как у nginx; ответы всегда с Content-Length
timeout = 20 # зависшие соединения не держим timeout = 20 # зависшие соединения не держим
def version_string(self): # «Server: nginx» без хвостового пробела
return self.server_version
def setup(self): def setup(self):
# TLS-рукопожатие – в потоке запроса, а не в общем цикле приёма соединений. # TLS-рукопожатие – в потоке запроса, а не в общем цикле приёма соединений.
# Без сертификата (за nginx, на 127.0.0.1) работаем по обычному HTTP. # Без сертификата (за nginx, на 127.0.0.1) работаем по обычному HTTP.
@@ -149,33 +361,66 @@ class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args): # без IP клиентов в логах def log_message(self, fmt, *args): # без IP клиентов в логах
pass 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=""): def send_plain(self, code, text=""):
body = text.encode() body = text.encode()
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_connection()
self.end_headers() self.end_headers()
if self.command != "HEAD": if self.command != "HEAD":
self.wfile.write(body) 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): def do_HEAD(self):
self.do_GET() self.do_GET()
def do_GET(self): def do_GET(self):
path = self.path.split("?", 1)[0] path = self.path.split("?", 1)[0]
if not path.startswith(PATH): prefix = next((x for x in (PATH, *EXTRA) if path.startswith(x)), None)
return self.send_plain(404, "404 page not found") if prefix is None:
sub_id = path[len(PATH):] return self.send_html_error(404)
if not SUB_ID.match(sub_id): sub_id = path[len(prefix):]
return self.send_plain(404, "404 page not found") 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", "") 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", "") 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: 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) awg = clash and not NO_AWG_UA.search(ua)
# В журнал – только приложение и что ему отдали, без IP. # В журнал – только приложение и что ему отдали, без IP.
log(f"{ua[:80]!r} → {'clash+awg' if awg else 'clash' if clash else headers.get('content-type', '?').split(';')[0]}") 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) body = strip_awg(body)
elif code == 200 and awg and not sub_id.endswith(("-awg", "-tg")): elif code == 200 and awg and not sub_id.endswith(("-awg", "-tg")):
# Установки до kit 1.1 держали AmneziaWG в подписке «<id>-awg» – подмешиваем её. # Установки до 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: if acode == 200 and abody:
body = merge_awg(body, 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", ""): elif code == 200 and "text/plain" in headers.get("content-type", ""):
body = strip_links(body) 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: except (yaml.YAMLError, UnicodeError) as e:
log(f"не удалось обработать подписку: {e}") log(f"не удалось обработать подписку: {e}")
@@ -196,9 +455,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
for k in PASS_HEADERS: for k in PASS_HEADERS:
if k in headers: if k in headers:
v = fix_userinfo(headers[k]) if k == "subscription-userinfo" else headers[k] 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(k.title(), v)
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.send_connection()
self.end_headers() self.end_headers()
if self.command != "HEAD": if self.command != "HEAD":
self.wfile.write(body) 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 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(): 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") cert, key = CONF.get("cert"), CONF.get("key")
if cert and CREDS and os.path.exists(os.path.join(CREDS, "cert.pem")): 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") cert, key = os.path.join(CREDS, "cert.pem"), os.path.join(CREDS, "key.pem")
@@ -224,6 +551,7 @@ def main():
return return
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2 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) ctx.load_cert_chain(cert, key)
stamp = [os.path.getmtime(cert)] stamp = [os.path.getmtime(cert)]
@@ -249,3 +577,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+2132 -69
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
# Матрица «протокол × клиент» # Матрица «протокол × клиент»
Проверяет настоящим подключением, что каждый протокол 3X-UI работает с каждым клиентом: Проверяет, что каждый протокол 3X-UI работает с каждым клиентом:
Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. Прогонять на каждой новой версии 3X-UI и Xray. Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. Прогонять на каждой новой версии 3X-UI и Xray.
1. Сервер: чистый Ubuntu в Docker с systemd, на нём `scripts/3x-ui.sh`, затем `mk-inbounds.sh` – 1. Сервер: чистый Ubuntu в Docker с systemd, на нём `scripts/3x-ui.sh`, затем `mk-inbounds.sh` –
@@ -28,7 +28,7 @@ Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. П
| VMess WS + TLS | ✓ | ✓ | ✓ | | VMess WS + TLS | ✓ | ✓ | ✓ |
| Shadowsocks 2022 | ✓ | ✓ | ✓ | | Shadowsocks 2022 | ✓ | ✓ | ✓ |
| TUIC v5 | – | ✓ | ✓ | | TUIC v5 | – | ✓ | ✓ |
| WireGuard | ✓ | ✓ | ✓ | (локально; через DPI в России – ✗ у всех клиентов) | | WireGuard | ✓ | ✓ | ✓ | (локально; через интернет к удалённому серверу – ✗ у всех клиентов) |
| AmneziaWG (классика) | – | ✓ | – | | AmneziaWG (классика) | – | ✓ | – |
| AmneziaWG 3.1 | – | ✓ | – | | AmneziaWG 3.1 | – | ✓ | – |
@@ -38,12 +38,12 @@ MTProto: `mtg doctor` – все дата-центры Telegram доступны
Со своим (самоподписанным) сертификатом подписка Clash от 3X-UI не передаёт Mihomo отпечаток – Со своим (самоподписанным) сертификатом подписка Clash от 3X-UI не передаёт Mihomo отпечаток –
TLS-протоколы в ней не работают; ссылки с `pcs` работают. 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: все протоколы ✓, кроме: клиенты – на Mac через интернет. Подписка-ссылки и подписка Clash: все протоколы ✓, кроме:
- **WireGuard** – ✗ у Xray, Mihomo и sing-box: рукопожатие доходит до сервера, ответ режет DPI. - **WireGuard** – ✗ у Xray, Mihomo и sing-box: рукопожатие доходит до сервера, ответ не возвращается.
Поэтому из набора по умолчанию убран. Поэтому из набора по умолчанию убран.
- **MTProto** – сервер хостера не достаёт до Telegram, прокси бесполезен; установщик теперь - **MTProto** – сервер хостера не достаёт до Telegram, прокси бесполезен; установщик теперь
проверяет это и пропускает MTProto. проверяет это и пропускает MTProto.
+28
View File
@@ -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 "меняется, только когда проверка показывает «работает» у обоих клиентов."
+93
View File
@@ -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 ]]
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKM0Sfn/UQeqjvUvY9wVgg7QtG1RR2pEUkqrUD5Yy6EF 3X-UI KIT fork releases (hacker07)
+93
View File
@@ -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
+253
View File
@@ -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
View File
@@ -7,7 +7,7 @@
// Сервисы: какие наборы правил отправлять через прокси. // Сервисы: какие наборы правил отправлять через прокси.
const SERVICES = [ 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']] }, 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: 'youtube', label: 'YouTube', on: true, sets: [['youtube@domain', META + 'geosite/youtube.mrs', 'domain']] },
{ id: 'telegram', label: 'Telegram', on: true, { id: 'telegram', label: 'Telegram', on: true,
+2 -2
View File
@@ -83,12 +83,12 @@
const BASES = { const BASES = {
zkeen: { label: 'ZKeen', hint: 'ставится XKeen по умолчанию, рекомендуется', site: 'zkeen.dat', ip: 'zkeenip.dat' }, zkeen: { label: 'ZKeen', hint: 'ставится XKeen по умолчанию, рекомендуется', site: 'zkeen.dat', ip: 'zkeenip.dat' },
v2fly: { label: 'V2Fly', hint: 'нужна для отдельных сервисов и блокировки рекламы', site: 'geosite_v2fly.dat', ip: 'geoip_v2fly.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. Берутся все доступные базы. // Сервис → правила по базам. d: домены, i: IP. Берутся все доступные базы.
const SERVICES = [ const SERVICES = [
{ id: 'blocked', label: 'Заблокированное в России', on: true, { id: 'blocked', label: 'Сайты из списка re:filter', on: true,
zkeen: { d: ['domains', 'other', 'politic'] }, refilter: { d: ['refilter'], i: ['refilter'] } }, 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: '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'] } }, { id: 'telegram', label: 'Telegram', on: true, zkeen: { i: ['telegram'] }, v2fly: { d: ['telegram'] } },
+38 -7
View File
@@ -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" [[ -f $key ]] || die "Нет закрытого ключа $key. Создать: ssh-keygen -t ed25519 -C \"3X-UI KIT releases\" -f $key"
NS="3x-ui-kit-release" NS="3x-ui-kit-release"
ID="releases@3x-ui-kit" 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=$(tr -d '[:space:]' <VERSION)
[[ $v =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "VERSION: $v" [[ $v =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "VERSION: $v"
for f in scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh; do 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" grep -qx "KIT_VERSION=\"$v\"" "$f" || die "$f: KIT_VERSION не $v"
done 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" 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. # Установщик 3X-UI: хеш в скрипте должен совпадать с тем, что сейчас лежит на GitHub.
xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh) xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh)
@@ -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. Проверьте, что поменялось, прежде чем выпускать." [[ $got == "$xs" ]] || die "Установщик 3X-UI $xv изменился: $got вместо $xs. Проверьте, что поменялось, прежде чем выпускать."
ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256" 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"; } 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)." [[ -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}') 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 – серверы не примут такую подпись." 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 rm -f SHA256SUMS.sig
ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null
tmp=$(mktemp) tmp=$(mktemp)
+111
View File
@@ -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()
+86
View File
@@ -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()
+110
View File
@@ -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()
+211
View File
@@ -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()
+186
View File
@@ -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()
+153
View File
@@ -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)
+100
View File
@@ -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()