diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b60323..72296f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: ShellCheck - run: shellcheck -S warning -e SC1090,SC2034,SC2211 scripts/*.sh + run: shellcheck -S warning -e SC1090,SC2034,SC2211 scripts/*.sh tools/release.sh - name: Python run: python3 -m py_compile scripts/kit-sub.py - name: Генераторы @@ -24,3 +24,14 @@ jobs: grep -q "^KIT_VERSION=\"$v\"" scripts/3x-ui.sh grep -q "^KIT_VERSION=\"$v\"" scripts/hysteria2.sh grep -q "^KIT_VERSION=\"$v\"" scripts/kit.sh + - name: Ключ подписи одинаковый в kit и hy2 + run: | + signers() { awk '/^KIT_SIGNERS=\(/ {on = 1; next} on && /^\)/ {exit} on' "$1"; } + diff <(signers scripts/kit.sh) <(signers scripts/hysteria2.sh) + - name: Подпись релиза совпадает с файлами + run: | + [ -f SHA256SUMS ] || { echo "SHA256SUMS нет – релиз ещё не подписан"; exit 0; } + grep -qx "# 3X-UI KIT $(cat VERSION)" SHA256SUMS + grep -v '^#' SHA256SUMS | sha256sum -c - + awk '/^KIT_SIGNERS=\(/ {on = 1; next} on && /^\)/ {exit} on && /^ *"/ {gsub(/^ *"|"$/, ""); print "releases@3x-ui-kit namespaces=\"3x-ui-kit-release\" " $0}' scripts/kit.sh >allowed_signers + ssh-keygen -Y verify -f allowed_signers -I releases@3x-ui-kit -n 3x-ui-kit-release -s SHA256SUMS.sig [!TIP] +> **Вышла версия 1.1:** исправления по аудиту безопасности, автообновление только подписанных +> релизов, резервная копия и переезд. [Что нового](CHANGELOG.md) · сервер на 1.0 обновляется +> одной командой: +> ```bash +> curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update +> ``` + **3X-UI KIT** превращает чистый VPS в готовый VPN-сервер за несколько минут. Скрипт ставит официальную панель [3X-UI](https://github.com/MHSanaei/3x-ui), настраивает все популярные протоколы, сертификат и файрвол и выдаёт одну ссылку-подписку. Её можно вставить в любое -приложение — оно само получит подходящие ему протоколы. Домен не нужен. +приложение – оно само получит подходящие ему протоколы. Домен не нужен.
Конец установки 3X-UI KIT @@ -25,23 +33,25 @@ ## Возможности -- 🧩 **11 протоколов сразу** — VLESS REALITY, XHTTP, WebSocket, Trojan gRPC, VMess, +- 🧩 **11 протоколов сразу** – VLESS REALITY, XHTTP, WebSocket, Trojan gRPC, VMess, Shadowsocks 2022, Hysteria2, TUIC, AmneziaWG (классика и 3.1) и MTProto для Telegram. -- 🚪 **Всё TCP — через порт 443.** Панель, подписка и протоколы спрятаны за одним портом, +- 🚪 **Всё TCP – через порт 443.** Панель, подписка и протоколы спрятаны за одним портом, а на случайный заход сервер показывает обычный сайт. - 🔗 **Одна подписка на все приложения.** Hiddify, Happ, v2rayN, Karing, Clash Verge и FlClash получают свой формат и только те протоколы, которые умеют. -- 👥 **Дополнительные пользователи одной командой** — `kit user add` добавляет пользователя +- 👥 **Дополнительные пользователи одной командой** – `kit user add` добавляет пользователя сразу во все протоколы с общим лимитом трафика, сроком и числом устройств. +- 🔄 **Обновляется сам, но только подписанными релизами**, а `kit backup` переносит сервер + на новый VPS вместе с пользователями и ключами. - 🔒 **Сертификат Let's Encrypt на IP** выпускается и продлевается сам, панель скрыта на случайном пути со случайными логином и паролем. -- ✅ **Проверено настоящими клиентами** — каждый протокол на ядрах Xray, Mihomo и sing-box, +- ✅ **Проверено настоящими клиентами** – каждый протокол на ядрах Xray, Mihomo и sing-box, в том числе с сервером в России: [tests/matrix](tests/matrix/). ## Что понадобится - VPS с **Ubuntu 22.04/24.04** или **Debian 12/13** и доступом root по SSH -- Свободные порты **443** и **80** — на свежем сервере они свободны +- Свободные порты **443** и **80** – на свежем сервере они свободны ## Установка @@ -56,24 +66,40 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main
🔍 Хотите сначала посмотреть скрипт? Установка с проверкой -Скачайте скрипт конкретной версии, прочитайте его и сверьте SHA256 с тем, что указан -на странице [релиза](https://github.com/itsnotkubrick/3X-UI_KIT/releases/latest): +Скачайте скрипт конкретной версии, прочитайте его и сверьте SHA256 с файлом `SHA256SUMS` +из того же релиза. Этот файл подписан ключом автора, подпись тоже можно проверить: ```bash -curl -fsSLO https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/3x-ui.sh +v=1.1; raw=https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v$v +curl -fsSLO $raw/scripts/3x-ui.sh -O $raw/SHA256SUMS -O $raw/SHA256SUMS.sig less 3x-ui.sh -sha256sum 3x-ui.sh +grep 3x-ui.sh SHA256SUMS && sha256sum 3x-ui.sh bash 3x-ui.sh ``` +
+Проверка подписи + +Открытый ключ автора записан в [`scripts/kit.sh`](scripts/kit.sh) (`KIT_SIGNERS`). Сохраните его +строку в файл `allowed_signers` в виде `releases@3x-ui-kit namespaces="3x-ui-kit-release" ssh-ed25519 AAAA…` +и выполните: + +```bash +ssh-keygen -Y verify -f allowed_signers -I releases@3x-ui-kit -n 3x-ui-kit-release -s SHA256SUMS.sig < SHA256SUMS +``` + +«Good signature» – файл подписан автором. Так же каждую ночь проверяет обновления ваш сервер. + +
+ Скрипт версии 1.1 скачивает свои части (`kit`, `kit-sub`) из того же релиза, а установщик 3X-UI сверяет по SHA256 и не запускает, если он изменился. Первый раз удобнее ставить на свежий VPS без ваших данных.
-Подробно — подключение приложений, дополнительные пользователи и параметры — -в **[инструкции](manuals/3x-ui.md)**. Нужен только Hysteria2 — есть +Подробно – подключение приложений, дополнительные пользователи и параметры – +в **[инструкции](manuals/3x-ui.md)**. Нужен только Hysteria2 – есть [отдельный скрипт](manuals/hysteria2.md). > [!WARNING] @@ -82,10 +108,10 @@ bash 3x-ui.sh ## Генераторы конфигов для XKeen -Вставьте ссылку на сервер или подписку, отметьте нужные сервисы — и получите +Вставьте ссылку на сервер или подписку, отметьте нужные сервисы – и получите готовый конфиг и одну команду, которая сама положит его на роутер. Всё считается в браузере, ссылки никуда не отправляются. -Как поставить XKeen на роутер — в [инструкции для Keenetic](manuals/xkeen-keenetic.md). +Как поставить XKeen на роутер – в [инструкции для Keenetic](manuals/xkeen-keenetic.md). | | Генератор | Что получится | |---|---|---| @@ -94,13 +120,13 @@ bash 3x-ui.sh ## Полезное -- [XKeen](https://github.com/jameszeroX/XKeen) и его [вики](https://github.com/jameszeroX/XKeen/wiki) — документация по маршрутизации на Keenetic -- [XKeen UI](https://github.com/zxc-rv/XKeen-UI) — веб-интерфейс для XKeen -- [IP-адреса для AmneziaWG](https://github.com/RockBlack-VPN/ip-address) — актуальные списки от RockBlack +- [XKeen](https://github.com/jameszeroX/XKeen) и его [вики](https://github.com/jameszeroX/XKeen/wiki) – документация по маршрутизации на Keenetic +- [XKeen UI](https://github.com/zxc-rv/XKeen-UI) – веб-интерфейс для XKeen +- [IP-адреса для AmneziaWG](https://github.com/RockBlack-VPN/ip-address) – актуальные списки от RockBlack ## Поддержать проект -Скрипты и инструкции бесплатные. Донат добровольный — он помогает оплачивать +Скрипты и инструкции бесплатные. Донат добровольный – он помогает оплачивать тестовые серверы и держать скрипты в актуальном состоянии. Спасибо! 💜 | Способ | | diff --git a/SECURITY.md b/SECURITY.md index 4eb9100..511a1a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,12 @@ скрипт его не запустит. - Бинарник Hysteria сверяется с SHA256, записанным в самом скрипте. - Свои части (`kit`, `kit-sub`, `hy2 update`) скрипт берёт из тега релиза, а не из ветки `main`. +- Обновления (`kit update`, ночное автообновление, `hy2 update`) ставятся, только если файл + `SHA256SUMS` релиза подписан ключом автора (`ssh-keygen -Y sign`), версия внутри подписи + совпадает, а каждый файл сошёлся с подписанным SHA256. Закрытый ключ не хранится на GitHub. + Откат на старую версию запрещён, после обновления проверяется подписка, при сбое – возврат. +- Резервная копия `kit backup` доступна только root (права 600); `--restore` принимает только + локальный файл и отклоняет архивы с лишними файлами, ссылками и путями вида `../`. - Подписка `kit-sub` работает без прав root: systemd `DynamicUser` и `LoadCredential`. - Все данные, которые попадают в файлы настроек, проверяются и экранируются. - Генераторы конфигов для роутеров работают только в браузере и никуда не отправляют ваши ссылки. @@ -22,4 +28,4 @@ Скрипт ставит стороннее ПО: панель 3X-UI, ядро Xray, Hysteria, nginx. Мы закрепляем их версии, но не проверяем их исходный код целиком. Сгенерированный конфиг Mihomo скачивает списки -сайтов (rule-set) и веб-панель из их проектов на GitHub. +сайтов (rule-set) и веб-панель zashboard (закреплённой версии) из их проектов на GitHub. diff --git a/manuals/3x-ui.md b/manuals/3x-ui.md index 14d81ac..d00c7d4 100644 --- a/manuals/3x-ui.md +++ b/manuals/3x-ui.md @@ -12,11 +12,11 @@ и ядро Xray, с которым работают все клиенты (Hiddify, Mihomo, v2rayN, Happ); - выпускает сертификат Let's Encrypt на IP сервера и сам его продлевает; - прячет панель на случайном порту и пути, со случайными логином и паролем; -- подбирает сайт для маскировки REALITY — с TLS 1.3 и HTTP/2; +- подбирает сайт для маскировки REALITY – с TLS 1.3 и HTTP/2; - создаёт подключения на всех протоколах из таблицы ниже, генерирует ключи и обфускацию AmneziaWG; - включает единую подписку с учётом приложения: Clash Verge и FlClash получают готовый - конфиг Mihomo с AmneziaWG, остальные приложения — ссылки только на то, что они умеют; + конфиг Mihomo с AmneziaWG, остальные приложения – ссылки только на то, что они умеют; - открывает в ufw только нужные порты. ## Протоколы @@ -42,17 +42,17 @@ доходят до панели, поэтому лимит устройств работает. Каждый протокол проверен настоящим подключением на ядрах Xray, Mihomo и sing-box, -в том числе через интернет к серверу в России — стенд лежит в [tests/matrix](../tests/matrix/). +в том числе через интернет к серверу в России – стенд лежит в [tests/matrix](../tests/matrix/). > [!NOTE] > Обычный WireGuard по умолчанию не ставится: в России его рукопожатие режет DPI, -> а попытки подключения могут привести к блокировке IP сервера. AmneziaWG — тот же WireGuard -> с маскировкой — проходит. Если сервер и пользователи за границей, добавьте `wg` в `--protocols`. +> а попытки подключения могут привести к блокировке IP сервера. AmneziaWG – тот же WireGuard +> с маскировкой – проходит. Если сервер и пользователи за границей, добавьте `wg` в `--protocols`. ## Что понадобится - VPS с Ubuntu 22.04/24.04 или Debian 12/13, доступ root по SSH -- Свободные порты **443** и **80** — на свежем сервере они свободны +- Свободные порты **443** и **80** – на свежем сервере они свободны ## Установка @@ -66,12 +66,12 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main ![Результат установки 3X-UI](assets/script-3x-ui.svg) -1. **Адрес панели** — откройте его в браузере. +1. **Адрес панели** – откройте его в браузере. 2. **Логин и пароль** от панели. -3. **Подписка** — вставьте её в приложение или отсканируйте QR-код. +3. **Подписка** – вставьте её в приложение или отсканируйте QR-код. Всё это, вместе с отдельными ссылками на каждый протокол, сохраняется в `/root/3x-ui.txt`: -посмотреть снова — `cat /root/3x-ui.txt`. +посмотреть снова – `cat /root/3x-ui.txt`. ## Подключение @@ -84,8 +84,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main | Windows, macOS, Linux | Hiddify, v2rayN, Clash Verge | Подписка смотрит, какое приложение её запрашивает: Clash Verge и FlClash получают AmneziaWG, -а Happ, v2rayN, Karing и Hiddify — нет, потому что не умеют его. Для AmneziaVPN импортируйте -ссылки `vpn://` из `/root/3x-ui.txt`, для Telegram — ссылку `tg://` оттуда же. +а Happ, v2rayN, Karing и Hiddify – нет, потому что не умеют его. Для AmneziaVPN импортируйте +ссылки `vpn://` из `/root/3x-ui.txt`, для Telegram – ссылку `tg://` оттуда же. ## Дополнительные пользователи @@ -97,25 +97,35 @@ 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 link имя [--all]` | подписка и QR-код ещё раз; `--all` — ещё ссылки `vpn://` и `tg://` | -| `kit user limit имя --gb N --days N` | изменить лимиты (0 — без ограничений) | +| `kit user link имя [--all]` | подписка и QR-код ещё раз; `--all` – ещё ссылки `vpn://` и `tg://` | +| `kit user limit имя --gb N --days N` | изменить лимиты (0 – без ограничений) | | `kit user off имя` / `kit user on имя` | временно выключить и включить | | `kit user del имя` | удалить | -Трафик и лимиты общие на все протоколы: 50 ГБ — это 50 ГБ суммарно, через что бы пользователь ни подключался. +Трафик и лимиты общие на все протоколы: 50 ГБ – это 50 ГБ суммарно, через что бы пользователь ни подключался. ## Обновление +Обновления kit и подписки kit-sub приходят сами: раз в сутки ночью сервер проверяет, не вышла ли +новая версия 3X-UI KIT, и ставит её. Пользователи, ссылки и подписки не меняются, раздавать +ничего заново не нужно. + +Сервер ставит только **подписанные** релизы. Автор подписывает каждый релиз своим ключом, а +открытая часть ключа записана в `kit` на вашем сервере. Если файлы на GitHub подменят, подпись +не сойдётся и сервер откажется обновляться: одного взлома GitHub для этого мало. После +обновления сервер проверяет, что подписка отвечает, и при сбое возвращает прежнюю версию. +Панель 3X-UI и ядро Xray автообновление не трогает. + ```bash -kit update +kit update # обновить сейчас, не дожидаясь ночи +kit update --manual # выключить автообновление +kit update --auto # включить обратно +kit version # версии kit, панели и ядра, включено ли автообновление ``` -Обновляет команду `kit` и подписку `kit-sub` до новой версии 3X-UI KIT. Пользователи, ссылки -и подписки не меняются, раздавать ничего заново не нужно. Файлы берутся из тега релиза -на GitHub, а если новая подписка не запустится, `kit update` вернёт прежнюю. -`kit version` покажет версию kit, панели и ядра Xray. +Журнал ночных обновлений: `/var/log/kit-update.log`. Сервер ставили версией 1.0? В ней команды `kit update` ещё нет, поэтому один раз: @@ -123,19 +133,52 @@ kit update curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/scripts/kit.sh -o /usr/local/bin/kit && chmod 755 /usr/local/bin/kit && kit update ``` +Дальше обновления будут приходить сами. + Панель 3X-UI обновляется отдельно, через `x-ui`. Ядро Xray в панели не обновляйте до последней версии: с Xray 26.7 и новее Hiddify, FlClash и Clash Verge не подключаются к REALITY. +## Резервная копия и переезд + +```bash +kit backup +``` + +Сохранит в `/root/kit-backup-ДАТА.tar.gz` всё, что нужно, чтобы поднять тот же сервер в другом +месте: подключения, ключи, пользователей с их лимитами, настройки подписки и nginx. Файл виден +только root, и в нём ключи от сервера, поэтому храните его как пароль. Скачать к себе: + +```bash +scp root@IP:/root/kit-backup-ДАТА.tar.gz . +``` + +Переезд на новый VPS: скопируйте файл на новый сервер и запустите установщик с `--restore`: + +```bash +scp kit-backup-ДАТА.tar.gz root@НОВЫЙ_IP: +bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh) --restore kit-backup-ДАТА.tar.gz +``` + +Скрипт поставит ту же версию 3X-UI и вернёт всё из копии, логин и пароль панели остаются +прежними. Копию он берёт только из локального файла и сначала проверяет: лишние файлы или +странные пути внутри архива, и восстановление не начнётся. + +- **Сервер на домене** (`--cert`, `--key`, `--host`): переключите A-запись домена на новый IP, + и клиенты ничего не заметят. +- **Сервер на IP:** скрипт сам поменяет адрес в подключениях, но старые подписки указывают на + старый IP. Клиентам нужно один раз добавить подписку заново: `kit user link имя`. + ## Параметры | Параметр | Что делает | |---|---| -| `--protocols reality,hy2,ws` | только выбранные протоколы; `minimal` — только REALITY | +| `--protocols reality,hy2,ws` | только выбранные протоколы; `minimal` – только REALITY | | `--port 8443` | порт REALITY (TCP) и Hysteria2 (UDP) вместо 443 | | `--cert fullchain.pem --key privkey.pem --host домен` | свой сертификат, например для домена | | `--sni www.samsung.com` | свой сайт для маскировки REALITY | | `--no-ufw` | не трогать файрвол | | `--multi-port` | прежняя схема: у каждого протокола свой порт, без nginx | +| `--restore файл` | поднять сервер из резервной копии `kit backup` | ## Как устроена подписка @@ -147,11 +190,11 @@ curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v1.1/script > [!NOTE] > Если порт 80 занят, сертификат Let's Encrypt не получить. Тогда скрипт сделает свой > сертификат и впишет его отпечаток в ссылки, а панель будет доступна через SSH-туннель. -> Подписка в этом режиме работает только изнутри сервера — используйте отдельные ссылки. +> Подписка в этом режиме работает только изнутри сервера – используйте отдельные ссылки. ## Обслуживание -Управление панелью — официальная команда `x-ui`: перезапуск, логи, обновление, удаление. +Управление панелью – официальная команда `x-ui`: перезапуск, логи, обновление, удаление. ```bash x-ui diff --git a/manuals/assets/banner.svg b/manuals/assets/banner.svg index 31c87aa..9fed6d0 100644 --- a/manuals/assets/banner.svg +++ b/manuals/assets/banner.svg @@ -1 +1 @@ -3X-UI KIT — свой VPN-сервер: все протоколы, один порт, одна подпискаアコ1カ{{1ウ/コ}ケク{ウ><ケア1クウ1アウイアウ<0}}/イオイ0コ}}3X-UI KITСвой VPN-сервер: все протоколы, один порт 443, одна подпискаREALITYHysteria2AmneziaWGXHTTPTUIC443 \ No newline at end of file +3X-UI KIT – свой VPN-сервер: все протоколы, один порт, одна подпискаアコ1カ{{1ウ/コ}ケク{ウ><ケア1クウ1アウイアウ<0}}/イオイ0コ}}3X-UI KITСвой VPN-сервер: все протоколы, один порт 443, одна подпискаREALITYHysteria2AmneziaWGXHTTPTUIC443 \ No newline at end of file diff --git a/manuals/assets/entware-installer.svg b/manuals/assets/entware-installer.svg index 9c88b13..6d28c60 100644 --- a/manuals/assets/entware-installer.svg +++ b/manuals/assets/entware-installer.svg @@ -1 +1 @@ -Проводник · \\192.168.1.1\opkg\installПроводник · \\192.168.1.1\opkg\installФлешка (ext4), открытая по сети: \\192.168.1.1\📁 installсоздайте папку📦 mipsel-installer.tar.gzустановщик под ваш роутерКакой установщик выбрать — по процессору роутера (есть в характеристиках модели):mipselчипы MediaTek MT7621 / MT7628 — большинство моделейmipsчипы EcoNet EN75xxaarch64чипы MediaTek MT798x (Filogic) \ No newline at end of file +Проводник · \\192.168.x.x\opkg\installПроводник · \\192.168.x.x\opkg\installФлешка (ext4), открытая по сети: \\192.168.x.x\📁 installсоздайте папку📦 mipsel-installer.tar.gzустановщик под ваш роутерКакой установщик выбрать – по процессору роутера (есть в характеристиках модели):mipselчипы MediaTek MT7621 / MT7628 – большинство моделейmipsчипы EcoNet EN75xxaarch64чипы MediaTek MT798x (Filogic) \ No newline at end of file diff --git a/manuals/assets/keenetic-components.svg b/manuals/assets/keenetic-components.svg index 177e0c1..695ff61 100644 --- a/manuals/assets/keenetic-components.svg +++ b/manuals/assets/keenetic-components.svg @@ -1 +1 @@ -Keenetic · Управление → Общие настройки → Изменить набор компонентовKeenetic · Управление → Общие настройки → Изменить набор компонентовБазовые компонентыИнтерфейс USBСетевые функцииПротокол IPv6Утилиты и сервисыПрокси-сервер DNS-over-TLSПрокси-сервер DNS-over-HTTPSUSB-накопителиПоддержка USB-накопителейФайловая система ExtОбщий доступ к файлам (SMB) — удобно для копирования установщикаПакеты OPKGПоддержка открытых пакетовМодули ядра подсистемы NetfilterИзменение набора компонентов обновит KeeneticOS до последней версии. \ No newline at end of file +Keenetic · Управление → Общие настройки → Изменить набор компонентовKeenetic · Управление → Общие настройки → Изменить набор компонентовБазовые компонентыИнтерфейс USBСетевые функцииПротокол IPv6Утилиты и сервисыПрокси-сервер DNS-over-TLSПрокси-сервер DNS-over-HTTPSUSB-накопителиПоддержка USB-накопителейФайловая система ExtОбщий доступ к файлам (SMB) – удобно для копирования установщикаПакеты OPKGПоддержка открытых пакетовМодули ядра подсистемы NetfilterИзменение набора компонентов обновит KeeneticOS до последней версии. \ No newline at end of file diff --git a/manuals/assets/keenetic-opkg.svg b/manuals/assets/keenetic-opkg.svg index 34a6a24..42a0b70 100644 --- a/manuals/assets/keenetic-opkg.svg +++ b/manuals/assets/keenetic-opkg.svg @@ -1 +1 @@ -Keenetic · Управление → OPKGKeenetic · Управление → OPKGНакопительOPKG (ваша флешка)выберите флешку с папкой install1Сценарий initrc/opt/etc/init.d/rc.unslungНажмите «Сохранить» — установка Entware займёт несколько минут. \ No newline at end of file +Keenetic · Управление → OPKGKeenetic · Управление → OPKGНакопительOPKG (ваша флешка)выберите флешку с папкой install1Сценарий initrc/opt/etc/init.d/rc.unslungНажмите «Сохранить» – установка Entware займёт несколько минут. \ No newline at end of file diff --git a/manuals/assets/script-3x-ui.svg b/manuals/assets/script-3x-ui.svg index 887f62a..59a891e 100644 --- a/manuals/assets/script-3x-ui.svg +++ b/manuals/assets/script-3x-ui.svg @@ -1 +1 @@ -Конец установки 3X-UI KITroot@vps: ~# bash <(curl -fsSL https://raw.githubusercontent.com/…/scripts/3x-ui.sh)==> Ставлю пакеты: curl, jq, openssl, qrencode, ufw==> Маскировка: dl.google.com==> Ставлю 3X-UI v3.8.5 официальным установщиком (пара минут)==> Создаю подключения: REALITY, Hysteria2, XHTTP, WS, gRPC, AmneziaWG…==> Настраиваю nginx: всё TCP через порт 443 _ _ _ _ _ _ _ (_) |_ ___ _ __ ___ | |_| | ___ _| |__ _ __(_) ___| | __ | | __/ __| '_ \ / _ \| __| |/ / | | | '_ \| '__| |/ __| |/ / | | |_\__ \ | | | (_) | |_| <| |_| | |_) | | | | (__| < |_|\__|___/_| |_|\___/ \__|_|\_\\__,_|_.__/|_| |_|\___|_|\_\3X-UI KIT на основе панели 3X-UI (MHSanaei/3x-ui), ядра Xray и mihomo https://github.com/itsnotkubrick/3X-UI_KIT it's not Kubrick. it's just a VPN.Готово! 3X-UI работает: 11 протоколов.1Панель: https://203.0.113.10/k3v9qx2m7p1a/2Логин: aB3kL9mQ2x2Пароль: Zp7Wq2Lk9Vn4Tx8Rb3Hs3https://203.0.113.10/q7xm2k9vp4ta/zr8n1c5wy3eh6ubd[ здесь QR-код — отсканируйте его телефоном ] \ No newline at end of file +Конец установки 3X-UI KITroot@vps: ~# bash <(curl -fsSL https://raw.githubusercontent.com/…/scripts/3x-ui.sh)==> Ставлю пакеты: curl, jq, openssl, qrencode, ufw==> Маскировка: dl.google.com==> Ставлю 3X-UI v3.8.5 официальным установщиком (пара минут)==> Создаю подключения: REALITY, Hysteria2, XHTTP, WS, gRPC, AmneziaWG…==> Настраиваю nginx: всё TCP через порт 443 _ _ _ _ _ _ _ (_) |_ ___ _ __ ___ | |_| | ___ _| |__ _ __(_) ___| | __ | | __/ __| '_ \ / _ \| __| |/ / | | | '_ \| '__| |/ __| |/ / | | |_\__ \ | | | (_) | |_| <| |_| | |_) | | | | (__| < |_|\__|___/_| |_|\___/ \__|_|\_\\__,_|_.__/|_| |_|\___|_|\_\3X-UI KIT на основе панели 3X-UI (MHSanaei/3x-ui), ядра Xray и mihomo https://github.com/itsnotkubrick/3X-UI_KIT it's not Kubrick. it's just a VPN.Готово! 3X-UI работает: 11 протоколов.1Панель: https://203.0.113.10/k3v9qx2m7p1a/2Логин: aB3kL9mQ2x2Пароль: Zp7Wq2Lk9Vn4Tx8Rb3Hs3https://203.0.113.10/q7xm2k9vp4ta/zr8n1c5wy3eh6ubd[ здесь QR-код – отсканируйте его телефоном ] \ No newline at end of file diff --git a/manuals/assets/script-hysteria2.svg b/manuals/assets/script-hysteria2.svg index 56f0028..d0ac020 100644 --- a/manuals/assets/script-hysteria2.svg +++ b/manuals/assets/script-hysteria2.svg @@ -1 +1 @@ -root@vps: ~root@vps: ~# bash <(curl -fsSL https://raw.githubusercontent.com/…/scripts/hysteria2.sh)Установка Hysteria21Домен (Enter — без домена): hy.example.com1Почта для Let's Encrypt: you@example.com==> Скачиваю Hysteria 2.12.3 (amd64) с GitHub==> Контрольная сумма совпала: 8c7a68a906998b74…==> Настраиваю ufw: SSH 22/tcp, Hysteria 443/udp, сайт 80/tcp и 443/tcp==> Запускаю Hysteria и получаю сертификат для hy.example.comГотово! Hysteria2 работает.2hy2://admin:9c1e…@hy.example.com:443/?sni=hy.example.com#admin[ здесь QR-код — отсканируйте его телефоном ] \ No newline at end of file +root@vps: ~root@vps: ~# bash <(curl -fsSL https://raw.githubusercontent.com/…/scripts/hysteria2.sh)Установка Hysteria21Домен (Enter – без домена): hy.example.com1Почта для Let's Encrypt: you@example.com==> Скачиваю Hysteria 2.12.3 (amd64) с GitHub==> Контрольная сумма совпала: 8c7a68a906998b74…==> Настраиваю ufw: SSH 22/tcp, Hysteria 443/udp, сайт 80/tcp и 443/tcp==> Запускаю Hysteria и получаю сертификат для hy.example.comГотово! Hysteria2 работает.2hy2://admin:9c1e…@hy.example.com:443/?sni=hy.example.com#admin[ здесь QR-код – отсканируйте его телефоном ] \ No newline at end of file diff --git a/manuals/assets/xkeen-configs.svg b/manuals/assets/xkeen-configs.svg index 375d414..0a79eac 100644 --- a/manuals/assets/xkeen-configs.svg +++ b/manuals/assets/xkeen-configs.svg @@ -1 +1 @@ -root@Keenetic: /opt/etc/xray/configsroot@Keenetic: /opt/etc/xray/configs~ # ls /opt/etc/xray/configs/03_inbounds.json # режим работы — оставьте как есть104_outbounds.json # подключение к вашему серверу205_routing.json # что пускать через прокси~ # xkeen -restart \ No newline at end of file +root@Keenetic: /opt/etc/xray/configsroot@Keenetic: /opt/etc/xray/configs~ # ls /opt/etc/xray/configs/03_inbounds.json # режим работы – оставьте как есть104_outbounds.json # подключение к вашему серверу205_routing.json # что пускать через прокси~ # xkeen -restart \ No newline at end of file diff --git a/manuals/hysteria2.md b/manuals/hysteria2.md index c536080..34c95d0 100644 --- a/manuals/hysteria2.md +++ b/manuals/hysteria2.md @@ -3,13 +3,13 @@ [← На главную](../README.md) [Hysteria2](https://github.com/HyNetworks/hysteria) работает поверх UDP и -хорошо держит скорость на плохих каналах — отличная пара к REALITY. Одна +хорошо держит скорость на плохих каналах – отличная пара к REALITY. Одна команда ставит сервер, сертификат и сайт-заглушку и выдаёт готовую ссылку. **Что делает [скрипт](../scripts/hysteria2.sh):** - ставит официальный Hysteria2 проверенной версии и сверяет контрольную сумму; -- с доменом — получает сертификат Let's Encrypt, без домена — делает свой +- с доменом – получает сертификат Let's Encrypt, без домена – делает свой и закрепляет его отпечаток прямо в ссылке; - поднимает сайт-заглушку: при заходе на ваш домен откроется страница «RETRYING CONNECTION»; @@ -20,7 +20,7 @@ ## Что понадобится - VPS с Ubuntu 22.04/24.04 или Debian 12/13, доступ root по SSH -- По желанию — домен, A-запись которого указывает на IP сервера +- По желанию – домен, A-запись которого указывает на IP сервера ## Установка @@ -32,7 +32,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main ![Установка Hysteria2](assets/script-hysteria2.svg) -1. Скрипт спросит **домен и почту**. Нет домена — просто нажмите Enter. +1. Скрипт спросит **домен и почту**. Нет домена – просто нажмите Enter. 2. В конце он покажет **ссылку** `hy2://` и QR-код. > [!TIP] @@ -56,7 +56,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main | Команда | Что делает | |---|---| | `hy2 add имя` | добавить пользователя и показать его ссылку | -| `hy2 del имя` | удалить — его ссылка сразу перестанет работать | +| `hy2 del имя` | удалить – его ссылка сразу перестанет работать | | `hy2 list` | список пользователей | | `hy2 link имя` | ссылка и QR-код ещё раз | | `hy2 status` | версия, адрес и состояние | @@ -65,11 +65,11 @@ bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main ## Если что-то пошло не так -- **«Не удалось получить сертификат»** — A-запись домена ещё не обновилась +- **«Не удалось получить сертификат»** – A-запись домена ещё не обновилась или порт 80 закрыт у хостера. Исправьте и выполните `hy2 restart`. -- **Клиент не подключается** — проверьте, что у хостера открыт UDP-порт 443 +- **Клиент не подключается** – проверьте, что у хостера открыт UDP-порт 443 (у некоторых хостеров есть свой файрвол в личном кабинете). -- **Порт 443 занят** — поставьте на другой: добавьте к команде установки `--port 8443`. +- **Порт 443 занят** – поставьте на другой: добавьте к команде установки `--port 8443`. --- diff --git a/manuals/xkeen-keenetic.md b/manuals/xkeen-keenetic.md index 2503a70..971db09 100644 --- a/manuals/xkeen-keenetic.md +++ b/manuals/xkeen-keenetic.md @@ -4,14 +4,14 @@ [XKeen](https://github.com/jameszeroX/XKeen) направляет через прокси (Xray или Mihomo) трафик только выбранных устройств, а остальные ходят -в интернет напрямую. Всё работает на роутере — на телефоны и ПК ничего +в интернет напрямую. Всё работает на роутере – на телефоны и ПК ничего ставить не нужно. ## Что понадобится - Роутер **Keenetic** или **Netcraze** с USB-портом - USB-флешка, отформатированная в **ext4** -- Свой сервер с VLESS — например, из [этой инструкции](3x-ui.md) +- Свой сервер с VLESS – например, из [этой инструкции](3x-ui.md) ## 1. Установите компоненты @@ -24,7 +24,7 @@ ## 2. Установите Entware -1. Вставьте флешку в роутер и откройте её по сети: `\\192.168.1.1\`. +1. Вставьте флешку в роутер и откройте её по сети: `\\192.168.x.x\`. 2. Создайте папку `install` и положите туда установщик под процессор роутера: [mipsel](https://bin.entware.net/mipselsf-k3.4/installer/mipsel-installer.tar.gz), [mips](https://bin.entware.net/mipssf-k3.4/installer/mips-installer.tar.gz) или @@ -37,12 +37,12 @@ ![Выбор накопителя для OPKG](assets/keenetic-opkg.svg) Через несколько минут Entware установится. Подключиться к нему можно по SSH: -порт `222`, логин `root`, пароль `keenetic` — **сразу смените его** командой `passwd`. +порт `222`, логин `root`, пароль `keenetic` – **сразу смените его** командой `passwd`. ## 3. Настройте DNS Пропишите шифрованные DNS-серверы (DoT или DoH) по -[инструкции Keenetic](https://support.keenetic.ru/ultra/kn-1811/ru/31543-dot-and-doh-proxy-servers-for-dns-requests-encryption.html) — +[инструкции Keenetic](https://support.keenetic.ru/ultra/kn-1811/ru/31543-dot-and-doh-proxy-servers-for-dns-requests-encryption.html) – без этого XKeen работает неправильно. > [!IMPORTANT] @@ -50,7 +50,7 @@ > Создайте его в разделе **Пользователи и доступ**, вставьте в > [шаблон xkeen.json](https://github.com/jameszeroX/XKeen/releases/download/2.0.1_Beta/xkeen.json) > и положите файл на роутер по пути `/opt/etc/xkeen/xkeen.json`. -> Подробнее — в [вики XKeen](https://github.com/jameszeroX/XKeen/wiki/Порядок-установки). +> Подробнее – в [вики XKeen](https://github.com/jameszeroX/XKeen/wiki/Порядок-установки). ## 4. Установите XKeen @@ -73,16 +73,16 @@ sh -c "$(curl -sSL https://raw.githubusercontent.com/jameszeroX/XKeen/main/insta ![Конфигурационные файлы Xray](assets/xkeen-configs.svg) -1. `04_outbounds.json` — подключение к вашему серверу. -2. `05_routing.json` — какие сайты и сервисы пускать через прокси. +1. `04_outbounds.json` – подключение к вашему серверу. +2. `05_routing.json` – какие сайты и сервисы пускать через прокси. Оба файла собирает наш **[генератор Xray](https://itsnotkubrick.github.io/3X-UI_KIT/tools/xray/)**: -вставьте ссылку `vless://`, отметьте сервисы — и получите одну команду, +вставьте ссылку `vless://`, отметьте сервисы – и получите одну команду, которая сама запишет файлы на роутер и перезапустит XKeen. Для ядра Mihomo и ссылок Hysteria2 есть **[генератор Mihomo](https://itsnotkubrick.github.io/3X-UI_KIT/tools/mihomo/)**. > [!NOTE] -> Генераторы работают прямо в браузере — ссылки с паролями никуда не отправляются. +> Генераторы работают прямо в браузере – ссылки с паролями никуда не отправляются. Чтобы через прокси ходили только нужные устройства, в веб-интерфейсе откройте **Приоритеты подключений → Политики доступа в Интернет**, создайте политику @@ -92,7 +92,7 @@ sh -c "$(curl -sSL https://raw.githubusercontent.com/jameszeroX/XKeen/main/insta > Без политики `xkeen` через прокси пойдёт трафик **всех** устройств в сети. > [!TIP] -> Управлять настройками удобнее из браузера — через +> Управлять настройками удобнее из браузера – через > [XKeen UI](https://github.com/zxc-rv/XKeen-UI). --- diff --git a/scripts/3x-ui.sh b/scripts/3x-ui.sh index 889a5d6..477fbeb 100755 --- a/scripts/3x-ui.sh +++ b/scripts/3x-ui.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 3X-UI со всеми протоколами одной командой — https://github.com/itsnotkubrick/3X-UI_KIT +# 3X-UI со всеми протоколами одной командой – https://github.com/itsnotkubrick/3X-UI_KIT # # Установка: bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh) # @@ -8,7 +8,7 @@ # REALITY, XHTTP, VLESS/VMess WS, Trojan gRPC, Shadowsocks 2022, Hysteria2, TUIC, # AmneziaWG (классика и 3.1) и MTProto (и WireGuard по запросу), включает единую подписку # с форматом под каждый клиент и настраивает ufw. Домены не нужны. -# Каждый протокол проверен настоящими клиентами — см. tests/matrix. +# Каждый протокол проверен настоящими клиентами – см. tests/matrix. set -Eeuo pipefail @@ -19,19 +19,19 @@ KIT_VERSION="1.1" # kit и kit-sub берём из того же релиза, что и этот скрипт, а не из меняющейся ветки main. KIT_RAW="https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/v$KIT_VERSION" # Ядро Xray для панели. С 26.7.x клиенты на Mihomo и sing-box (Hiddify, FlClash, -# Clash Verge, Mihomo в XKeen) не проходят REALITY — проверено 2026-09-25. -# 26.6.27 — последняя версия, с которой работают все клиенты и которую принимает 3X-UI. +# Clash Verge, Mihomo в XKeen) не проходят REALITY – проверено 2026-09-25. +# 26.6.27 – последняя версия, с которой работают все клиенты и которую принимает 3X-UI. XRAY_CORE="v26.6.27" XUI_REPO="MHSanaei/3x-ui" RESULT=/root/3x-ui.txt XUI_ENV=/etc/x-ui/install-result.env # Сайты для маскировки REALITY: нужны TLS 1.3 и HTTP/2. Берём первый доступный. -# Apple, iCloud, Microsoft и домены .ru сам Xray не советует — их тут нет. +# Apple, iCloud, Microsoft и домены .ru сам Xray не советует – их тут нет. SNI_CANDIDATES=(dl.google.com www.amazon.com www.samsung.com www.yahoo.com) ALL_PROTOS=(reality hy2 xhttp ws trojan vmess ss tuic wg awg awg3 mtproto) # Обычный WireGuard в России режет DPI (проверено 2026-09-27: рукопожатие доходит до сервера, -# ответ — нет), а его попытки могут привлечь блокировку IP. По умолчанию не ставим. +# ответ – нет), а его попытки могут привлечь блокировку IP. По умолчанию не ставим. DEFAULT_PROTOS=(reality hy2 xhttp ws trojan vmess ss tuic awg awg3 mtproto) declare -A PORTS=([xhttp]=8443 [ws]=2053 [trojan]=2083 [vmess]=2087 [ss]=8388 [tuic]=8444 [wg]=51820 [awg]=51821 [awg3]=51822 [mtproto]=8445) PROTOS=(); CREATED=(); OPEN=() @@ -101,7 +101,7 @@ wait_panel() { # ---------- установка ---------- -# Пасхалка — только в конце установки. +# Пасхалка – только в конце установки. kit_banner() { echo printf '%s' "$G" @@ -119,7 +119,7 @@ ART echo " https://github.com/itsnotkubrick/3X-UI_KIT" echo " ${D}it's not Kubrick. it's just a VPN.${N}" echo - echo "Ниже — данные для входа в панель и подключения." + echo "Ниже – данные для входа в панель и подключения." } main() { @@ -128,14 +128,14 @@ main() { if [[ -f $RESULT && -x /usr/local/x-ui/x-ui ]]; then die "3X-UI уже установлена этим скриптом. Управление: команда x-ui, данные для входа: cat $RESULT" fi - # Панель удалили через меню x-ui, а наши файлы остались — убираем их и ставим заново. + # Панель удалили через меню x-ui, а наши файлы остались – убираем их и ставим заново. if [[ -f $RESULT ]]; then - warn "Панель 3X-UI удалена, но остались файлы прошлой установки — убираю их." - systemctl disable --now kit-sub >/dev/null 2>&1 || true - rm -rf /etc/systemd/system/kit-sub.service /usr/local/lib/kit-sub /etc/kit-sub /etc/kit /usr/local/bin/kit \ + warn "Панель 3X-UI удалена, но остались файлы прошлой установки – убираю их." + systemctl disable --now kit-sub kit-update.timer >/dev/null 2>&1 || true + rm -rf /etc/systemd/system/kit-sub.service /etc/systemd/system/kit-update.service /etc/systemd/system/kit-update.timer /usr/local/lib/kit-sub /etc/kit-sub /etc/kit /usr/local/bin/kit \ /etc/cron.d/kit-nginx-reload /etc/cron.d/kit-xui-menu /etc/cron.d/kit-sub-cert "$RESULT" systemctl daemon-reload - # Наш nginx держит 443 — без этого проверка порта ниже не пустит REALITY. + # Наш nginx держит 443 – без этого проверка порта ниже не пустит REALITY. if [[ -f /etc/nginx/kit-stream.conf ]]; then systemctl stop nginx >/dev/null 2>&1 || true rm -f /etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf @@ -143,10 +143,10 @@ main() { fi fi if [[ -d /usr/local/x-ui && ! -f $XUI_ENV ]]; then - die "3X-UI уже установлена другим способом — не трогаю её. Удалите её (x-ui uninstall) или добавьте REALITY в панели вручную." + die "3X-UI уже установлена другим способом – не трогаю её. Удалите её (x-ui uninstall) или добавьте REALITY в панели вручную." fi - local PORT=443 SNI="" PANEL_SSL=auto HOST="" UFW=yes NAME="admin" yes=no protos=all ucert="" ukey="" multi=no + local PORT=443 SNI="" PANEL_SSL=auto HOST="" UFW=yes NAME="admin" yes=no protos=all ucert="" ukey="" multi=no restore="" while [[ $# -gt 0 ]]; do case $1 in --port) PORT=$2; shift 2 ;; @@ -159,12 +159,18 @@ main() { --multi-port) multi=yes; shift ;; --key) ukey=$2; shift 2 ;; --no-ufw) UFW=no; shift ;; + --restore) restore=$2; shift 2 ;; -y|--yes) yes=yes; shift ;; -h|--help) usage; exit 0 ;; *) die "Неизвестный параметр: $1 (см. --help)" ;; esac done [[ $PORT =~ ^[0-9]+$ ]] && ((PORT > 0 && PORT < 65536)) || die "Неверный порт: $PORT" + if [[ -n $restore ]]; then + [[ -z $HOST || $HOST =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || die "--host при восстановлении: IP нового сервера" + restore_main "$restore" + return + fi local re_host='^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$' [[ -z $SNI || $SNI =~ $re_host ]] || die "--sni: нужно имя сайта, например dl.google.com" [[ -z $HOST || $HOST =~ $re_host || $HOST =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || die "--host: нужен IP или домен" @@ -172,7 +178,7 @@ main() { [[ $PANEL_SSL =~ ^(auto|ip|none)$ ]] || die "--panel-ssl: auto, ip или none" if [[ -n $ucert || -n $ukey ]]; then [[ -s $ucert && -s $ukey ]] || die "Нужны оба файла: --cert fullchain.pem --key privkey.pem" - openssl x509 -in "$ucert" -noout 2>/dev/null || die "$ucert — не сертификат в формате PEM" + openssl x509 -in "$ucert" -noout 2>/dev/null || die "$ucert – не сертификат в формате PEM" PANEL_SSL=custom fi case $protos in @@ -183,19 +189,19 @@ main() { for x in "${PROTOS[@]}"; do [[ " ${ALL_PROTOS[*]} " == *" $x "* ]] || die "Неизвестный протокол: $x. Доступны: ${ALL_PROTOS[*]}"; done ;; esac if port_busy "$PORT" tcp && ! { [[ -f $XUI_ENV ]] && ss -H -ltnp "sport = :$PORT" | grep -q -E 'xray|nginx'; }; then - die "Порт $PORT/tcp уже занят. REALITY нужен свободный порт — укажите другой: --port 8443" + die "Порт $PORT/tcp уже занят. REALITY нужен свободный порт – укажите другой: --port 8443" fi if [[ $PANEL_SSL == auto ]]; then if port_busy 80 tcp; then PANEL_SSL=none - warn "Порт 80 занят — сертификат для панели не получить. Панель будет доступна только через SSH-туннель." + warn "Порт 80 занят – сертификат для панели не получить. Панель будет доступна только через SSH-туннель." else PANEL_SSL=ip fi fi [[ $PANEL_SSL == ip ]] && port_busy 80 tcp && die "Для сертификата панели нужен свободный порт 80/tcp." - # Доверенный сертификат (Let's Encrypt или свой) — панель и подписка доступны снаружи по HTTPS. + # Доверенный сертификат (Let's Encrypt или свой) – панель и подписка доступны снаружи по HTTPS. TRUSTED=no [[ $PANEL_SSL == ip || $PANEL_SSL == custom ]] && TRUSTED=yes if [[ $PANEL_SSL == custom ]]; then @@ -219,7 +225,7 @@ main() { done [[ -n $SNI ]] || die "Ни один сайт из списка не ответил по TLS 1.3 + HTTP/2. Укажите свой: --sni example.com" elif ! sni_ok "$SNI"; then - die "$SNI не отвечает по TLS 1.3 + HTTP/2 — REALITY с ним работать не будет. Выберите другой сайт." + die "$SNI не отвечает по TLS 1.3 + HTTP/2 – REALITY с ним работать не будет. Выберите другой сайт." fi say "Маскировка: ${B}$SNI${N}" # Для режима «всё на 443» XHTTP и MTProto нужны свои сайты: nginx различает их по SNI. @@ -231,41 +237,20 @@ main() { SNI2=${SNI2:-$SNI}; SNI3=${SNI3:-www.cloudflare.com} # --- официальный установщик 3X-UI с закреплённой версией --- - local panel_port panel_path panel_user panel_pass tmp + local panel_port panel_path panel_user panel_pass panel_port=$(free_port) panel_path=$(rand_str 18) panel_user=$(rand_str 10) panel_pass=$(rand_str 20) if [[ -f $XUI_ENV ]]; then - say "3X-UI уже стоит после прошлого запуска — продолжаю с создания подключений" + say "3X-UI уже стоит после прошлого запуска – продолжаю с создания подключений" else - tmp=$(mktemp) - say "Ставлю 3X-UI $XUI_VERSION официальным установщиком (пара минут)" - curl -fsSL --retry 3 -o "$tmp" "https://raw.githubusercontent.com/$XUI_REPO/$XUI_VERSION/install.sh" - [[ $(sha256sum "$tmp" | awk '{print $1}') == "$XUI_INSTALL_SHA256" ]] \ - || die "Установщик 3X-UI $XUI_VERSION не совпал с проверенным (SHA256) – не запускаю. Сообщите нам: github.com/itsnotkubrick/3X-UI_KIT/issues" - if ! XUI_NONINTERACTIVE=1 XUI_SSL_MODE="${PANEL_SSL/custom/none}" XUI_SERVER_IP="$HOST" \ - XUI_PANEL_PORT="$panel_port" XUI_WEB_BASE_PATH="$panel_path" \ - XUI_USERNAME="$panel_user" XUI_PASSWORD="$panel_pass" \ - bash "$tmp" "$XUI_VERSION" /var/log/3x-ui-install.log 2>&1; then - tail -20 /var/log/3x-ui-install.log >&2 - die "Установщик 3X-UI завершился с ошибкой. Полный лог: /var/log/3x-ui-install.log" - fi - rm -f "$tmp" + install_xui "$panel_port" "$panel_path" "$panel_user" "$panel_pass" fi [[ -f $XUI_ENV ]] || die "Установщик не сохранил данные входа. Лог: /var/log/3x-ui-install.log" - # Данные для входа — из файла, который пишет сам установщик. - # shellcheck disable=SC1090 - . "$XUI_ENV" - TOKEN=$XUI_API_TOKEN - # Панель может уже работать по HTTPS (сертификат ставится после установщика) — пробуем оба. - local scheme - for scheme in https http; do - API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" - curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null && break - done - wait_panel + # Данные для входа – из файла, который пишет сам установщик. + connect_panel if [[ $PANEL_SSL == custom ]]; then say "Подключаю ваш сертификат к панели" @@ -277,7 +262,7 @@ main() { # Без сертификата панель и подписки не должны торчать наружу по HTTP. if [[ $PANEL_SSL == none ]]; then - say "Панель без сертификата — открываю её только для SSH-туннеля (127.0.0.1)" + say "Панель без сертификата – открываю её только для SSH-туннеля (127.0.0.1)" local all all=$(api POST setting/all '{}') api POST setting/update "$(jq -c '.webListen = "127.0.0.1" | .subListen = "127.0.0.1"' <<<"$all")" >/dev/null @@ -286,18 +271,7 @@ main() { fi # --- ядро Xray, совместимое со всеми клиентами --- - local cur_core - cur_core=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}') - if [[ $cur_core != "$XRAY_CORE" ]]; then - say "Ставлю ядро Xray $XRAY_CORE (совместимо с Hiddify, Mihomo и другими клиентами)" - api POST "server/installXray/$XRAY_CORE" '{}' >/dev/null - for _ in $(seq 1 30); do - cur_core=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}') - [[ $cur_core == "$XRAY_CORE" ]] && break - sleep 2 - done - [[ $cur_core == "$XRAY_CORE" ]] || warn "Не удалось сменить ядро Xray (сейчас $cur_core). Клиенты на Mihomo и sing-box могут не подключиться." - fi + set_xray_core # --- сертификат для протоколов с TLS --- setup_tls_cert @@ -305,18 +279,18 @@ main() { # --- подключения: все выбранные протоколы, один subId на пользователя --- EXISTING=$(api GET inbounds/list) SUBID="" - # «Всё на 443» — для новых установок с доверенным сертификатом. Старую многопортовую - # установку не переделываем: перенос работающих подключений — осознанное решение. + # «Всё на 443» – для новых установок с доверенным сертификатом. Старую многопортовую + # установку не переделываем: перенос работающих подключений – осознанное решение. if [[ $TRUSTED == yes && $multi == no ]]; then if jq -e 'any(.[]; .remark == "REALITY" and (.listen // "") != "127.0.0.1")' <<<"$EXISTING" >/dev/null; then - warn "Установка уже работает в режиме с отдельными портами — оставляю его." + warn "Установка уже работает в режиме с отдельными портами – оставляю его." else SINGLE=yes fi fi local p for p in "${PROTOS[@]}"; do "proto_$p"; done - # Первый пользователь — сразу на всех протоколах (как «kit user add»). + # Первый пользователь – сразу на всех протоколах (как «kit user add»). ensure_user # --- подписка: ссылки, Clash/Mihomo и JSON с автоопределением клиента --- @@ -324,19 +298,14 @@ main() { [[ $SINGLE == yes ]] && setup_nginx install_kit_cli brand_xui_menu + # Автообновление kit и kit-sub: только подписанные релизы, выключается kit update --manual. + /usr/local/bin/kit update --auto >/dev/null 2>&1 || warn "Автообновление не включилось – включите позже: kit update --auto" # --- файрвол --- if [[ $UFW == yes ]]; then - local ssh_port - ssh_port=$(ss -H -ltnp 2>/dev/null | awk '/sshd/ {sub(/.*:/,"",$4); print $4; exit}') - ssh_port=${ssh_port:-22} - OPEN+=("$ssh_port/tcp") [[ $TRUSTED == yes && $SINGLE == no ]] && OPEN+=("$XUI_PANEL_PORT/tcp" "$SUB_PORT/tcp") [[ $PANEL_SSL == ip ]] && OPEN+=("80/tcp") - say "Настраиваю ufw: ${OPEN[*]}" - local o - for o in "${OPEN[@]}"; do ufw allow "$o" >/dev/null; done - ufw --force enable >/dev/null || warn "ufw не включился (так бывает в контейнерах) — откройте порты у хостера вручную." + setup_ufw fi # --- итог --- @@ -359,13 +328,13 @@ main() { [[ $SINGLE == yes ]] && links=$(sed "s/^\(tg:\/\/proxy?\)\(.*\)port=${INNER[mtproto]}/\1\2port=443/" <<<"$links") umask 077 { - echo "3X-UI KIT (3X-UI $XUI_VERSION) — данные для входа (файл виден только root)" + echo "3X-UI KIT (3X-UI $XUI_VERSION) – данные для входа (файл виден только root)" echo echo "Панель: $panel_url" echo "Логин: $XUI_USERNAME" echo "Пароль: $XUI_PASSWORD" echo - [[ $TRUSTED == yes ]] && { echo "Подписка ($NAME) — все протоколы одной ссылкой:"; echo "$SUB_URL"; echo; } + [[ $TRUSTED == yes ]] && { echo "Подписка ($NAME) – все протоколы одной ссылкой:"; echo "$SUB_URL"; echo; } echo "Отдельные подключения ($NAME):" echo "$links" } >"$RESULT" @@ -380,7 +349,7 @@ main() { echo "Пароль: ${B}$XUI_PASSWORD${N}" echo if [[ $TRUSTED == yes ]]; then - echo "Подписка для ${B}$NAME${N} — все протоколы одной ссылкой. Вставьте её в Hiddify, v2rayN, Happ," + echo "Подписка для ${B}$NAME${N} – все протоколы одной ссылкой. Вставьте её в Hiddify, v2rayN, Happ," echo "Clash Verge или FlClash: приложение само получит подходящий формат." echo echo "$SUB_URL" @@ -388,22 +357,77 @@ main() { qrencode -t ANSIUTF8 -m 1 "$SUB_URL" || true if [[ -n $AWG_LINKS ]]; then echo - echo "AmneziaWG приходит по подписке в Clash Verge и FlClash; для AmneziaVPN — ссылки vpn:// в $RESULT." + echo "AmneziaWG приходит по подписке в Clash Verge и FlClash; для AmneziaVPN – ссылки vpn:// в $RESULT." fi else - echo "Без сертификата подписка недоступна снаружи — вот ссылки по одной:" + echo "Без сертификата подписка недоступна снаружи – вот ссылки по одной:" echo echo "$links" fi echo if [[ -n $PIN && " ${CREATED[*]} " == *" TUIC "* ]]; then - warn "TUIC со своим сертификатом: в клиенте включите «Разрешить небезопасный» (allow insecure) — отпечаток TUIC-ссылки не передают." + warn "TUIC со своим сертификатом: в клиенте включите «Разрешить небезопасный» (allow insecure) – отпечаток TUIC-ссылки не передают." fi echo "Всё это сохранено в ${B}$RESULT${N}." echo - echo "Дополнительные пользователи — одной командой, сразу во все протоколы, со своей подпиской:" + echo "Дополнительные пользователи – одной командой, сразу во все протоколы, со своей подпиской:" echo " ${B}kit user add sasha --gb 50 --days 30${N}" - echo " ${B}kit user list${N} — кто сколько израсходовал и до какого числа" + echo " ${B}kit user list${N} – кто сколько израсходовал и до какого числа" +} + +# Официальный установщик 3X-UI закреплённой версии, сверенный по SHA256. +install_xui() { # порт путь логин пароль + local tmp + tmp=$(mktemp) + say "Ставлю 3X-UI $XUI_VERSION официальным установщиком (пара минут)" + curl -fsSL --retry 3 -o "$tmp" "https://raw.githubusercontent.com/$XUI_REPO/$XUI_VERSION/install.sh" + [[ $(sha256sum "$tmp" | awk '{print $1}') == "$XUI_INSTALL_SHA256" ]] \ + || die "Установщик 3X-UI $XUI_VERSION не совпал с проверенным (SHA256) – не запускаю. Сообщите нам: github.com/itsnotkubrick/3X-UI_KIT/issues" + if ! XUI_NONINTERACTIVE=1 XUI_SSL_MODE="${PANEL_SSL/custom/none}" XUI_SERVER_IP="$HOST" \ + XUI_PANEL_PORT="$1" XUI_WEB_BASE_PATH="$2" \ + XUI_USERNAME="$3" XUI_PASSWORD="$4" \ + bash "$tmp" "$XUI_VERSION" /var/log/3x-ui-install.log 2>&1; then + tail -20 /var/log/3x-ui-install.log >&2 + die "Установщик 3X-UI завершился с ошибкой. Полный лог: /var/log/3x-ui-install.log" + fi + rm -f "$tmp" +} + +# Панель: API по https или http – сертификат мог появиться после установщика. +connect_panel() { + # shellcheck disable=SC1090 + . "$XUI_ENV" + TOKEN=$XUI_API_TOKEN + local scheme + for scheme in https http; do + API="$scheme://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api" + curl -fsk -m 5 -o /dev/null -H "Authorization: Bearer $XUI_API_TOKEN" "$API/server/getNewUUID" 2>/dev/null && break + done + wait_panel +} + +set_xray_core() { + local cur_core + cur_core=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}') + if [[ $cur_core != "$XRAY_CORE" ]]; then + say "Ставлю ядро Xray $XRAY_CORE (совместимо с Hiddify, Mihomo и другими клиентами)" + api POST "server/installXray/$XRAY_CORE" '{}' >/dev/null + for _ in $(seq 1 30); do + cur_core=$(/usr/local/x-ui/bin/xray-linux-* version 2>/dev/null | awk 'NR==1 {print "v" $2}') + [[ $cur_core == "$XRAY_CORE" ]] && break + sleep 2 + done + [[ $cur_core == "$XRAY_CORE" ]] || warn "Не удалось сменить ядро Xray (сейчас $cur_core). Клиенты на Mihomo и sing-box могут не подключиться." + fi +} + +setup_ufw() { + local ssh_port o + ssh_port=$(ss -H -ltnp 2>/dev/null | awk '/sshd/ {sub(/.*:/,"",$4); print $4; exit}') + OPEN+=("${ssh_port:-22}/tcp") + say "Настраиваю ufw: ${OPEN[*]}" + for o in "${OPEN[@]}"; do ufw allow "$o" >/dev/null; done + ufw --force enable >/dev/null || warn "ufw не включился (так бывает в контейнерах) – откройте порты у хостера вручную." } # ---------- сертификат ---------- @@ -415,7 +439,7 @@ setup_tls_cert() { elif [[ $PANEL_SSL == ip && -s /root/cert/ip/fullchain.pem ]]; then CERT=/root/cert/ip/fullchain.pem; KEY=/root/cert/ip/privkey.pem else - # Без Let's Encrypt — свой сертификат, а его отпечаток уходит в ссылки (pcs), + # Без Let's Encrypt – свой сертификат, а его отпечаток уходит в ссылки (pcs), # чтобы клиенты доверяли именно ему. CERT=/root/cert/self/fullchain.pem; KEY=/root/cert/self/privkey.pem if [[ ! -s $CERT ]]; then @@ -438,7 +462,7 @@ tls_json() { # alpn(JSON-массив) # ---------- протоколы ---------- -# AmneziaWG — в отдельной подписке: приложения на Xray и sing-box (Happ, v2rayN, Karing, +# AmneziaWG – в отдельной подписке: приложения на Xray и sing-box (Happ, v2rayN, Karing, # Hiddify) его не умеют и видят как обычный WireGuard, который не подключается. client_base() { # суффикс local sid=$SUBID @@ -450,7 +474,7 @@ uuid() { cat /proc/sys/kernel/random/uuid; } rnd() { shuf -i "$1-$2" -n 1; } # add_inbound remark port proto(tcp|udp|both|inner) protocol settings stream -# inner — подключение за nginx: слушает 127.0.0.1, наружу порт не открываем. +# inner – подключение за nginx: слушает 127.0.0.1, наружу порт не открываем. add_inbound() { local remark=$1 port=$2 net=$3 protocol=$4 settings=$5 stream=$6 body listen="" [[ $net == inner ]] && listen=127.0.0.1 @@ -473,7 +497,7 @@ add_inbound() { local out out=$(curl -sSk -m 20 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -X POST -d "$body" "$API/inbounds/add") if [[ $(jq -r '.success' <<<"$out") != true ]] && grep -q 'Duplicate email' <<<"$out"; then - # Клиент с таким именем остался от удалённого подключения — берём уникальное имя. + # Клиент с таким именем остался от удалённого подключения – берём уникальное имя. body=$(jq -c --arg sfx "-$(openssl rand -hex 2)" '.settings |= (fromjson | .clients[0].email += $sfx | tojson)' <<<"$body") out=$(curl -sSk -m 20 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -X POST -d "$body" "$API/inbounds/add") fi @@ -490,7 +514,7 @@ open_port() { # port net } # External Proxy 3X-UI: ссылки ведут на HOST:443, хотя подключение слушает localhost. -# SNI — только для TLS через nginx: у REALITY своё имя сайта маскировки, его не трогаем. +# SNI – только для TLS через nginx: у REALITY своё имя сайта маскировки, его не трогаем. ext_proxy() { # forceTls(same|tls) alpn(JSON) local sni="" [[ $HOST =~ ^[0-9.]+$ ]] || sni=$HOST @@ -606,7 +630,7 @@ proto_wg() { } # AmneziaWG: классические параметры понимают Mihomo и роутеры Keenetic, -# полный набор 3.1 — только приложения AmneziaVPN/AmneziaWG. Поэтому два подключения. +# полный набор 3.1 – только приложения AmneziaVPN/AmneziaWG. Поэтому два подключения. awg_obfuscation() { # classic|full local jmin s1 s2 jmin=$(rnd 40 89); s1=$(rnd 15 150); s2=$(rnd 15 150) @@ -655,12 +679,12 @@ telegram_reachable() { proto_mtproto() { # MTProto бесполезен, если сам сервер не достаёт до Telegram (некоторые хостеры его блокируют). if ! telegram_reachable; then - warn "MTProto пропущен: с этого сервера недоступны серверы Telegram — прокси для Telegram здесь работать не будет." + warn "MTProto пропущен: с этого сервера недоступны серверы Telegram – прокси для Telegram здесь работать не будет." return fi local settings if [[ $SINGLE == yes ]]; then - # FakeTLS-домен — свой сайт: nginx узнаёт MTProto по нему и передаёт mtg с реальным IP клиента. + # FakeTLS-домен – свой сайт: nginx узнаёт MTProto по нему и передаёт mtg с реальным IP клиента. settings=$(jq -nc --arg d "$SNI3" --argjson c "$(client_base mtproto)" '{fakeTlsDomain: $d, proxyProtocolListener: true, clients: [$c + {secret: ""}]}') add_inbound "MTProto" "${INNER[mtproto]}" inner mtproto "$settings" '{}' else @@ -684,7 +708,7 @@ ensure_user() { [[ $missing == "[]" ]] || api POST "clients/$NAME/attach" "$(jq -nc --argjson i "$missing" '{inboundIds: $i}')" >/dev/null awg_attach "$NAME" "$SUBID" elif [[ -n $legacy ]]; then - # Установка до kit 1.1: у каждого протокола свой клиент — оставляем как есть. + # Установка до kit 1.1: у каждого протокола свой клиент – оставляем как есть. SUBID=$legacy else SUBID=$(rand_str 16 | tr 'A-Z' 'a-z') @@ -697,7 +721,7 @@ ensure_user() { # В 3X-UI 3.x у клиента одна запись и в ней одна пара ключей WireGuard и один адрес. Если # клиент подключён к двум AmneziaWG, в подписку для обоих уходят ключ и адрес одного из них, # и второй сервер клиента не узнаёт (проверено 2026-09-27). Поэтому к первому AmneziaWG -# подключаем основную запись, а ко второму — запись-«двойник» «имя-awg» с подпиской «-awg» +# подключаем основную запись, а ко второму – запись-«двойник» «имя-awg» с подпиской «-awg» # (subId в 3X-UI обязан быть уникальным) и теми же лимитами; kit-sub подмешивает её в Clash. awg_ids() { api GET inbounds/list | jq -r '[.[] | select(.protocol == "amneziawg") | .id] | sort | .[]'; } non_awg_ids() { api GET inbounds/list | jq -c '[.[] | select(.protocol != "amneziawg") | .id]'; } @@ -735,6 +759,10 @@ install_kit_cli() { printf 'MTPROTO_INNER=%q\n' "${INNER[mtproto]}" } >/etc/kit/kit.env chmod 600 /etc/kit/kit.env + install_kit_file +} + +install_kit_file() { local d src="" d=$(dirname "${BASH_SOURCE[0]}") [[ -f $d/kit.sh && ${BASH_SOURCE[0]} != /dev/fd/* ]] && src=$d/kit.sh @@ -745,7 +773,7 @@ install_kit_cli() { KIT_INSTALL_CMD="bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh)" -# После «x-ui → Uninstall» меню подсказывает команду официального установщика — +# После «x-ui → Uninstall» меню подсказывает команду официального установщика – # меняем её на нашу. Только в echo: вызов установщика в «Update» не трогаем. # Правим один раз при установке, без cron: чужие файлы по расписанию не трогаем. brand_xui_menu() { @@ -760,13 +788,48 @@ brand_xui_menu() { # ---------- всё на 443: nginx ---------- +# Сайт-заглушка: на случайный заход по 443 сервер показывает обычный сайт. Шаблон выбирается +# случайно из набора, чтобы тысячи серверов 3X-UI KIT не выглядели одинаково. Всё внутри +# скрипта, со сторонних сайтов ничего не скачивается. +stub_site() { + local n year + n=$(rnd 1 6); year=$(date +%Y) + case $n in + 1) cat <<'HTML' +Welcome

Site is under construction

Please check back soon.

+HTML + ;; + 2) cat <<'HTML' +Welcome to nginx!

Welcome to nginx!

If you see this page, the nginx web server is successfully installed and working. Further configuration is required.

For online documentation and support please refer to nginx.org.
Commercial support is available at nginx.com.

Thank you for using nginx.

+HTML + ;; + 3) cat <Lena Hart Photography

Lena Hart

Landscape & travel photography

© $year Lena Hart. New portfolio coming soon.
+HTML + ;; + 4) cat <Notes

~/notes

Small notes about Linux, networks and home servers.

Comments are closed.

+HTML + ;; + 5) cat <Green Bean Coffee

Green Bean Coffee

Specialty coffee · fresh pastries · open daily 8–18

Online orders coming soon© $year Green Bean Coffee
+HTML + ;; + 6) cat <Status

System status

WebsiteOperational
APIOperational
StorageOperational

Updated automatically · $year

+HTML + ;; + esac +} + + setup_nginx() { say "Настраиваю nginx: всё TCP через порт 443" apt-get install -y -qq nginx libnginx-mod-stream >/dev/null - # Порт 80 нужен Let's Encrypt для продления сертификата — сайт nginx по умолчанию убираем. + # Порт 80 нужен Let's Encrypt для продления сертификата – сайт nginx по умолчанию убираем. rm -f /etc/nginx/sites-enabled/default - # Панель — только через nginx. + # Панель – только через nginx. local all all=$(api POST setting/all '{}') if [[ $(jq -r '.webListen' <<<"$all") != 127.0.0.1 ]]; then @@ -776,11 +839,9 @@ setup_nginx() { fi install -d -m 755 /var/www/kit - [[ -f /var/www/kit/index.html ]] || cat >/var/www/kit/index.html <<'HTML' -Welcome

Site is under construction

Please check back soon.

-HTML + [[ -f /var/www/kit/index.html ]] || stub_site >/var/www/kit/index.html - # Маршруты — из текущих подключений панели: сайты REALITY, пути WebSocket, сервисы gRPC. + # Маршруты – из текущих подключений панели: сайты REALITY, пути WebSocket, сервисы gRPC. local list reality_sni xhttp_sni mt_sni locs="" kind path port list=$(api GET inbounds/list) reality_sni=$(jq -r '.[] | select(.remark == "REALITY" and .listen == "127.0.0.1") | (.streamSettings | if type == "string" then fromjson else . end).realitySettings.serverNames[0]' <<<"$list") @@ -817,7 +878,7 @@ HTML local panel_path=/${XUI_WEB_BASE_PATH#/} panel_path=${panel_path%/}/ { - echo "# Сгенерировано 3x-ui.sh (3X-UI KIT) — перезаписывается при повторном запуске." + echo "# Сгенерировано 3x-ui.sh (3X-UI KIT) – перезаписывается при повторном запуске." echo "stream {" echo " map \$ssl_preread_server_name \$kit_upstream {" [[ -n $reality_sni ]] && echo " $reality_sni 127.0.0.1:${INNER[reality]};" @@ -837,7 +898,7 @@ HTML echo "}" } >/etc/nginx/kit-stream.conf cat >/etc/nginx/conf.d/kit.conf <>/etc/nginx/nginx.conf - nginx -t >/tmp/nginx-test.log 2>&1 || { cat /tmp/nginx-test.log >&2; die "nginx не принял конфиг — лог выше."; } + nginx -t >/tmp/nginx-test.log 2>&1 || { cat /tmp/nginx-test.log >&2; die "nginx не принял конфиг – лог выше."; } systemctl enable nginx >/dev/null 2>&1 systemctl restart nginx - # Let's Encrypt на IP продлевается каждые несколько дней — nginx раз в сутки перечитывает сертификат. + # Let's Encrypt на IP продлевается каждые несколько дней – nginx раз в сутки перечитывает сертификат. echo '17 4 * * * root systemctl reload nginx >/dev/null 2>&1' >/etc/cron.d/kit-nginx-reload OPEN+=("443/tcp") local i @@ -892,7 +953,7 @@ setup_subscription() { SUB_PATH=$(jq -r '.subPath // "/sub/"' <<<"$all") if [[ $SUB_PATH == /sub/ || -z $SUB_PATH ]]; then SUB_PATH="/$(rand_str 12 | tr 'A-Z' 'a-z')/"; fi if [[ $TRUSTED == yes ]]; then - # Наружу смотрит kit-sub (подписка с учётом приложения), 3X-UI — только на 127.0.0.1. + # Наружу смотрит kit-sub (подписка с учётом приложения), 3X-UI – только на 127.0.0.1. SUB_PORT=2096; SUB_INTERNAL=2097 # Без subURI панель показывает ссылку на внутренний порт 2097, до которого снаружи не достучаться. local uri="https://$HOST:$SUB_PORT$SUB_PATH" @@ -958,9 +1019,8 @@ UNIT KIT_SUB_URL="$KIT_RAW/scripts/kit-sub.py" -install_kit_sub() { - say "Ставлю подписку с учётом приложения (kit-sub)" - apt-get install -y -qq python3 python3-yaml >/dev/null +# Сам kit_sub.py: рядом со скриптом (запуск из папки scripts/) или из того же релиза. +install_kit_sub_file() { install -d -m 755 /usr/local/lib/kit-sub /etc/kit-sub local src=${KIT_SUB_SRC:-} if [[ -z $src ]]; then @@ -970,6 +1030,12 @@ install_kit_sub() { if [[ -n $src ]]; then install -m 644 "$src" /usr/local/lib/kit-sub/kit_sub.py else curl -fsSL --retry 3 -o /usr/local/lib/kit-sub/kit_sub.py "$KIT_SUB_URL"; fi python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" /usr/local/lib/kit-sub/kit_sub.py || die "kit-sub скачался повреждённым" +} + +install_kit_sub() { + say "Ставлю подписку с учётом приложения (kit-sub)" + apt-get install -y -qq python3 python3-yaml >/dev/null + install_kit_sub_file if [[ $SINGLE == yes ]]; then # За nginx: слушаем только localhost, TLS снимает nginx на 443. jq -n --arg path "$SUB_PATH" --argjson port "${INNER[sub]}" --arg up "http://127.0.0.1:$SUB_INTERNAL" --arg host "$HOST" \ @@ -994,40 +1060,231 @@ install_kit_sub() { [[ $SINGLE == yes ]] && kp=${INNER[sub]} for i in $(seq 1 20); do port_busy "$kp" tcp && return 0; sleep 1; done journalctl -u kit-sub -n 20 --no-pager >&2 || true - die "kit-sub не запустился — лог выше." + die "kit-sub не запустился – лог выше." } -# Ссылки пользователя — из его же подписки (её собирает сама 3X-UI). -# Ссылки пользователя — прямо из подписки 3X-UI внутри сервера (мимо kit-sub, который +# Ссылки пользователя – из его же подписки (её собирает сама 3X-UI). +# Ссылки пользователя – прямо из подписки 3X-UI внутри сервера (мимо kit-sub, который # прячет от VPN-приложений vpn:// и tg://). sub_links subId [попыток] sub_links() { local id=$1 tries=${2:-20} raw="" i port=${SUB_INTERNAL:-$SUB_PORT} scheme=http [[ -z ${SUB_INTERNAL:-} && $TRUSTED == yes ]] && scheme=https for i in $(seq 1 "$tries"); do - # Настоящий адрес в Host — 3X-UI подставит его в ссылки. + # Настоящий адрес в Host – 3X-UI подставит его в ссылки. raw=$(curl -fsSk -m 10 -A "v2rayN/7.0" -H "Host: $HOST:$SUB_PORT" "$scheme://127.0.0.1:$port$SUB_PATH$id" 2>/dev/null) && [[ -n $raw ]] && break raw=""; sleep 2 done if grep -q '://' <<<"$raw"; then echo "$raw"; else base64 -d <<<"$raw" 2>/dev/null || true; fi } +# ---------- восстановление из резервной копии (kit backup) ---------- + +# Файл настроек из копии можно подключать, только если в нём нет ничего, кроме +# ИМЯ=значение без подстановок и команд. +safe_env() { + [[ -r $1 ]] || die "В копии нет файла ${1##*/}." + grep -qvE "^([A-Z][A-Z0-9_]*=([A-Za-z0-9._:/@%+,=-]*|'[^']*'))?$" "$1" && die "В копии подозрительный файл ${1##*/} – не восстанавливаю." + return 0 +} + +# Меняет старый IP на новый в базе панели и файлах kit (только целиком, 1.2.3.4 не заденет 11.2.3.45). +replace_host() { # старый новый файлы... + python3 - "$@" <<'PY' +import re, sqlite3, sys +old, new, files = sys.argv[1], sys.argv[2], sys.argv[3:] +rx = re.compile(r"(?/dev/null + + # Сначала проверяем архив целиком: только наши пути, только файлы и папки, без ссылок + # и «../». Чужой архив не должен ничего записать мимо. + tmp=$(mktemp -d) + # shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет + trap "rm -rf -- '$tmp'" EXIT + python3 - "$file" "$tmp" <<'PY' || die "Это не резервная копия 3X-UI KIT или она повреждена. Ничего не менял." +import os, sys, tarfile +allowed = ("etc/x-ui/x-ui.db", "etc/x-ui/install-result.env", "etc/kit/kit.env", "etc/kit-sub/config.json", + "etc/nginx/kit-stream.conf", "etc/nginx/conf.d/kit.conf", "var/www/kit", "root/cert/self", + "root/cert/custom", "root/3x-ui.txt", "kit-backup.env") +parents = {"etc", "etc/x-ui", "etc/kit", "etc/kit-sub", "etc/nginx", "etc/nginx/conf.d", "var", "var/www", "root", "root/cert"} +with tarfile.open(sys.argv[1], "r:gz") as t: + ms = [] + for m in t.getmembers(): + n = os.path.normpath(m.name) + if n == ".": + continue + if n.startswith("/") or ".." in n.split("/"): + sys.exit("путь " + m.name) + if not (m.isfile() or m.isdir()): + sys.exit("не файл " + m.name) + if not (n in parents and m.isdir()) and not any(n == a or n.startswith(a + "/") for a in allowed): + sys.exit("лишний " + m.name) + m.name = n + m.uid = m.gid = 0 + m.uname = m.gname = "root" + m.mode = 0o700 if m.isdir() else 0o600 + ms.append(m) + names = {m.name for m in ms} + for need in ("etc/x-ui/x-ui.db", "etc/x-ui/install-result.env", "kit-backup.env"): + if need not in names: + sys.exit("нет " + need) + if hasattr(tarfile, "data_filter"): + t.extractall(sys.argv[2], members=ms, filter="data") + else: + t.extractall(sys.argv[2], members=ms) +PY + local f + for f in "$tmp/kit-backup.env" "$tmp/etc/x-ui/install-result.env" "$tmp/etc/kit/kit.env"; do if [[ -f $f ]]; then safe_env "$f"; fi; done + python3 -c "import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); assert c.execute('PRAGMA integrity_check').fetchone()[0]=='ok'; c.execute('SELECT count(*) FROM inbounds')" \ + "$tmp/etc/x-ui/x-ui.db" 2>/dev/null || die "База панели в копии повреждена. Ничего не менял." + + local BACKUP_HOST="" BACKUP_SSL="" BACKUP_DATE="" BACKUP_KIT_VERSION="" + # shellcheck disable=SC1090 + . "$tmp/kit-backup.env" + [[ $BACKUP_SSL =~ ^(ip|custom|none)$ ]] || die "В копии нет данных о сертификате." + PANEL_SSL=$BACKUP_SSL + # Домен переезжает вместе с сервером (поменяйте A-запись), IP – нет. + if [[ $BACKUP_HOST =~ ^[0-9.]+$ ]]; then + HOST=${HOST:-$(public_ip)} + [[ -n $HOST ]] || die "Не удалось узнать внешний IP. Укажите его: --host 1.2.3.4" + [[ $HOST != "$BACKUP_HOST" ]] && old_ip=$BACKUP_HOST + else + HOST=$BACKUP_HOST + fi + [[ $PANEL_SSL == ip ]] && port_busy 80 tcp && die "Для сертификата панели нужен свободный порт 80/tcp." + # Конфиги nginx и kit-sub из архива не берём, а собираем заново: из копии – только + # проверенные значения (путь подписки, порты, режим). + SINGLE=no; SUB_PATH=""; SUB_INTERNAL=""; SUB_PORT="" + if [[ -f $tmp/etc/kit/kit.env ]]; then + SINGLE=$(. "$tmp/etc/kit/kit.env"; echo "${SINGLE:-no}") + SUB_PATH=$(. "$tmp/etc/kit/kit.env"; echo "${SUB_PATH:-}") + SUB_INTERNAL=$(. "$tmp/etc/kit/kit.env"; echo "${SUB_INTERNAL:-}") + [[ $SINGLE =~ ^(yes|no)$ && $SUB_PATH =~ ^/[A-Za-z0-9_-]+/$ && $SUB_INTERNAL =~ ^[0-9]{1,5}$ ]] \ + || die "В копии странные настройки kit (kit.env) – не восстанавливаю." + fi + if [[ -f $tmp/etc/kit-sub/config.json ]]; then + SUB_PORT=$(jq -r '.port' "$tmp/etc/kit-sub/config.json" 2>/dev/null || true) + [[ $SUB_PORT =~ ^[0-9]{1,5}$ && -n $SUB_PATH ]] || die "В копии странные настройки подписки – не восстанавливаю." + fi + say "Копия от ${BACKUP_DATE:-?} (kit ${BACKUP_KIT_VERSION:-?}), сервер ${B}$HOST${N}" + + # 3X-UI той же закреплённой версии, потом подменяем её базу на базу из копии: + # подключения, ключи REALITY, пользователи и их подписки остаются прежними. + install_xui "$(free_port)" "$(rand_str 18)" "$(rand_str 10)" "$(rand_str 20)" + systemctl stop x-ui + install -m 600 "$tmp/etc/x-ui/x-ui.db" /etc/x-ui/x-ui.db + install -m 600 "$tmp/etc/x-ui/install-result.env" "$XUI_ENV" + local c + for c in self custom; do + [[ -d $tmp/root/cert/$c ]] || continue + install -d -m 700 "/root/cert/$c" + install -m 644 "$tmp/root/cert/$c/fullchain.pem" "/root/cert/$c/fullchain.pem" + install -m 600 "$tmp/root/cert/$c/privkey.pem" "/root/cert/$c/privkey.pem" + done + install -d -m 700 /etc/kit /etc/kit-sub + [[ -f $tmp/etc/kit/kit.env ]] && install -m 600 "$tmp/etc/kit/kit.env" /etc/kit/kit.env + [[ -f $tmp/root/3x-ui.txt ]] && install -m 600 "$tmp/root/3x-ui.txt" "$RESULT" + if [[ -n $old_ip ]]; then + say "Меняю адрес сервера в подключениях: $old_ip → $HOST" + local files=() + for f in "$XUI_ENV" /etc/kit/kit.env "$RESULT"; do if [[ -f $f ]]; then files+=("$f"); fi; done + replace_host "$old_ip" "$HOST" "${files[@]}" + fi + systemctl start x-ui + connect_panel + set_xray_core + + # Сертификат: Let's Encrypt на новый IP выпустил установщик, свой и самоподписанный – из копии. + setup_tls_cert + TRUSTED=no + [[ $PANEL_SSL == ip || $PANEL_SSL == custom ]] && TRUSTED=yes + if [[ -n $SUB_PORT ]]; then + install_kit_sub + [[ $SINGLE == yes ]] || OPEN+=("$SUB_PORT/tcp") + fi + # Всё на 443: nginx строит маршруты по подключениям из базы, заглушка – из копии. + if [[ $SINGLE == yes ]]; then + install -d -m 755 /var/www/kit + [[ -f $tmp/var/www/kit/index.html ]] && install -m 644 "$tmp/var/www/kit/index.html" /var/www/kit/index.html + setup_nginx + fi + + if [[ -f /etc/kit/kit.env ]]; then + install_kit_file + brand_xui_menu + /usr/local/bin/kit update --auto >/dev/null 2>&1 || warn "Автообновление не включилось – включите позже: kit update --auto" + fi + + # Порты – по подключениям из копии: что слушает не только localhost, то и открываем. + if [[ $UFW == yes ]]; then + local p + while read -r p; do OPEN+=("$p"); done < <(api GET inbounds/list | jq -r '.[] | select(.listen != "127.0.0.1") + | if (.protocol | test("^(hysteria|hysteria2|tuic|wireguard|amneziawg)$")) then "\(.port)/udp" + elif .protocol == "shadowsocks" then "\(.port)/tcp", "\(.port)/udp" else "\(.port)/tcp" end') + [[ $TRUSTED == yes && $SINGLE == no ]] && OPEN+=("$XUI_PANEL_PORT/tcp") + [[ $PANEL_SSL == ip ]] && OPEN+=("80/tcp") + setup_ufw + fi + + local n_in n_cl + n_in=$(api GET inbounds/list | jq length) + n_cl=$(api GET inbounds/list | jq '[.[] | (.settings | if type == "string" then fromjson else . end).clients // [] | .[].email] | unique | length') + kit_banner + echo + echo "${G}${B}Готово! Сервер восстановлен из копии: $n_in подключений, $n_cl клиентских записей.${N}" + echo "Логин и пароль панели прежние, адрес панели и подписки: ${B}cat $RESULT${N}" + echo + if [[ -n $old_ip ]]; then + warn "IP сервера сменился: $old_ip → $HOST." + echo "Старые подписки указывают на старый IP, поэтому клиентам нужно один раз добавить" + echo "подписку заново: ${B}kit user list${N}, затем ${B}kit user link имя${N}." + echo "${D}Чтобы в следующий раз переезд прошёл незаметно для клиентов, ставьте сервер на домен (--cert, --key, --host).${N}" + else + echo "Клиентам ничего менять не нужно: ключи, ссылки и подписки те же." + fi + [[ $PANEL_SSL == custom ]] && echo "Направьте A-запись домена ${B}$HOST${N} на IP этого сервера, если ещё не сделали." + return 0 +} + usage() { cat <${N} $*"; } warn() { printf '%s\n' "${Y}!${N} $*" >&2; } die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; } -trap 'die "Ошибка в строке $LINENO. Если это установка — исправьте причину и запустите скрипт ещё раз."' ERR +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 @@ -105,7 +105,7 @@ install_binary() { 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 "Контрольная сумма не совпала — файл повреждён или подменён."; } + [[ -n $expected && $expected == "$actual" ]] || { rm -rf "$tmp"; die "Контрольная сумма не совпала – файл повреждён или подменён."; } install -m 755 "$tmp/hysteria" "$BIN" rm -rf "$tmp" say "Контрольная сумма совпала: ${D}${actual:0:16}…${N}" @@ -146,7 +146,7 @@ render_config() { local u p . "$STATE" { - echo "# Сгенерировано hy2 — правьте через команду hy2, ручные изменения перезапишутся." + echo "# Сгенерировано hy2 – правьте через команду hy2, ручные изменения перезапишутся." echo "listen: :$PORT" echo if [[ -n ${DOMAIN:-} ]]; then @@ -212,9 +212,9 @@ EOF } tune_sysctl() { - # Буферы UDP побольше — рекомендация разработчиков Hysteria для скорости. + # Буферы UDP побольше – рекомендация разработчиков Hysteria для скорости. printf 'net.core.rmem_max=16777216\nnet.core.wmem_max=16777216\n' >"$SYSCTL" - sysctl -q -p "$SYSCTL" 2>/dev/null || warn "Не удалось применить sysctl (так бывает в контейнерах) — на работу не влияет." + sysctl -q -p "$SYSCTL" 2>/dev/null || warn "Не удалось применить sysctl (так бывает в контейнерах) – на работу не влияет." } setup_ufw() { @@ -230,7 +230,7 @@ setup_ufw() { ufw allow 80/tcp >/dev/null ufw allow "$PORT/tcp" >/dev/null fi - ufw --force enable >/dev/null || warn "ufw не включился (так бывает в контейнерах) — откройте порты у хостера вручную." + ufw --force enable >/dev/null || warn "ufw не включился (так бывает в контейнерах) – откройте порты у хостера вручную." } # Ждём строку «server up and running» в логе с момента запуска: с доменом @@ -250,7 +250,7 @@ start_and_wait() { if grep -qi 'acme\|certificate' <<<"$log"; then die "Не удалось получить сертификат. Проверьте, что A-запись домена указывает на этот сервер, а порт 80/tcp открыт у хостера. Потом: hy2 restart" fi - die "Hysteria не запустилась — причина выше. После исправления: hy2 restart" + die "Hysteria не запустилась – причина выше. После исправления: hy2 restart" fi sleep 1 done @@ -281,11 +281,11 @@ cmd_install() { echo echo "${B}Установка Hysteria2${N}" echo "С доменом сертификат выпустит Let's Encrypt, а по адресу домена откроется сайт-заглушка." - echo "Без домена будет самоподписанный сертификат — это тоже работает." - read -rp "Домен (Enter — без домена): " DOMAIN + echo "Без домена будет самоподписанный сертификат – это тоже работает." + read -rp "Домен (Enter – без домена): " DOMAIN if [[ -n $DOMAIN ]]; then read -rp "Почта для Let's Encrypt: " EMAIL; fi fi - # Значения попадают в install.env и конфиг Hysteria — пропускаем только допустимые символы. + # Значения попадают в 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" @@ -298,8 +298,8 @@ cmd_install() { 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 занят — на нём будет сайт-заглушка." + port_busy 80 tcp && die "TCP-порт 80 занят – он нужен Let's Encrypt для проверки домена." + port_busy "$PORT" tcp && die "TCP-порт $PORT занят – на нём будет сайт-заглушка." fi install_packages @@ -310,7 +310,7 @@ cmd_install() { 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-запись." + [[ $resolved == "$my_ip" ]] || warn "Домен $DOMAIN указывает на $resolved, а IP сервера $my_ip. Если сертификат не выпустится – проверьте A-запись." fi SNI=${SNI:-${DOMAIN:-www.bing.com}} @@ -371,7 +371,7 @@ show_link() { local link link=$(user_link "$1") echo - echo "Ссылка для ${B}$1${N} — вставьте в Hiddify, v2rayN, Streisand или NekoBox:" + echo "Ссылка для ${B}$1${N} – вставьте в Hiddify, v2rayN, Streisand или NekoBox:" echo echo "$link" echo @@ -406,7 +406,7 @@ cmd_del() { 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 "Это последний пользователь — сначала добавьте другого." + [[ $(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 удалён, его ссылка больше не работает." @@ -434,19 +434,41 @@ cmd_status() { systemctl --no-pager --lines=5 status hysteria-server || true } +# Открытый ключ, которым автор подписывает релизы (тот же, что в kit). Обновление ставится, +# только если SHA256SUMS релиза подписан этим ключом и скрипт совпал с подписанным хешем. +KIT_SIGNERS=( + # KIT_SIGNER_KEY +) +KIT_SIG_NS="3x-ui-kit-release" +KIT_SIG_ID="releases@3x-ui-kit" + cmd_update() { require_installed - local tmp - local latest + 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 недоступен с сервера." - tmp=$(mktemp) - say "Скачиваю скрипт версии $latest" - curl -fsSL --retry 3 -o "$tmp" "$KIT_REPO_RAW/v$latest/scripts/hysteria2.sh" - bash -n "$tmp" || { rm -f "$tmp"; die "Скачанный скрипт повреждён – ничего не менял."; } - bash "$tmp" __update_binary - install -m 755 "$tmp" "$CLI" - rm -f "$tmp" + [[ $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() { @@ -485,7 +507,7 @@ cmd_uninstall() { cmd_help() { cat <-awg»: Mihomo умеет AmneziaWG, а остальные приложения нет; - * остальные приложения и браузер — ответ 3X-UI как есть (ссылки или страница); - * заголовок Subscription-Userinfo: expire=0 («бессрочно») убирается — иначе + * остальные приложения и браузер – ответ 3X-UI как есть (ссылки или страница); + * заголовок Subscription-Userinfo: expire=0 («бессрочно») убирается – иначе приложения показывают срок «01.01.1970». -Настройки — /etc/kit-sub/config.json. Сертификат перечитывается сам после продления. +Настройки – /etc/kit-sub/config.json. Сертификат перечитывается сам после продления. """ import base64 @@ -64,14 +64,14 @@ def upstream(sub_id, ua, host, accept): def fix_userinfo(value): - # «expire=0» значит «бессрочно», но приложения рисуют 01.01.1970 — убираем. + # «expire=0» значит «бессрочно», но приложения рисуют 01.01.1970 – убираем. parts = [p.strip() for p in value.split(";") if p.strip() and p.strip() != "expire=0"] return "; ".join(parts) def strip_links(body): - """Список ссылок (base64 или текст) без vpn:// и tg:// — их не умеет ни одно VPN-приложение - со ссылками: vpn:// — конфиг для AmneziaVPN, tg:// — прокси для Telegram.""" + """Список ссылок (base64 или текст) без vpn:// и tg:// – их не умеет ни одно VPN-приложение + со ссылками: vpn:// – конфиг для AmneziaVPN, tg:// – прокси для Telegram.""" text = body.decode("utf-8", "replace").strip() encoded = "://" not in text if encoded: @@ -85,7 +85,7 @@ def strip_links(body): def strip_awg(clash_yaml): - """Clash-конфиг без AmneziaWG — для приложений, которые его не умеют.""" + """Clash-конфиг без AmneziaWG – для приложений, которые его не умеют.""" cfg = yaml.safe_load(clash_yaml) if not isinstance(cfg, dict): return clash_yaml @@ -110,7 +110,7 @@ def merge_awg(main_yaml, awg_yaml): return main_yaml names = {p.get("name") for p in main.get("proxies") or []} for p in extra: - # 3X-UI дописывает к имени запись-«двойника» («AmneziaWG-3.1-sasha-awg») — убираем хвост. + # 3X-UI дописывает к имени запись-«двойника» («AmneziaWG-3.1-sasha-awg») – убираем хвост. p["name"] = re.sub(r"-[^-\s]+-awg\d*$", "", p["name"]) or p["name"] base, n = p["name"], 2 while p["name"] in names: @@ -133,7 +133,7 @@ class Handler(http.server.BaseHTTPRequestHandler): timeout = 20 # зависшие соединения не держим def setup(self): - # TLS-рукопожатие — в потоке запроса, а не в общем цикле приёма соединений. + # TLS-рукопожатие – в потоке запроса, а не в общем цикле приёма соединений. # Без сертификата (за nginx, на 127.0.0.1) работаем по обычному HTTP. self.request.settimeout(self.timeout) if self.server.ssl_ctx is not None: @@ -177,13 +177,13 @@ class Handler(http.server.BaseHTTPRequestHandler): clash = bool(CLASH_UA.search(ua)) and "yaml" in headers.get("content-type", "") 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]}") try: if code == 200 and clash and not awg: body = strip_awg(body) elif code == 200 and awg and not sub_id.endswith(("-awg", "-tg")): - # Установки до kit 1.1 держали AmneziaWG в подписке «-awg» — подмешиваем её. + # Установки до kit 1.1 держали AmneziaWG в подписке «-awg» – подмешиваем её. acode, _, abody = upstream(sub_id + "-awg", ua, host, accept) if acode == 200 and abody: body = merge_awg(body, abody) @@ -208,7 +208,7 @@ class Server(http.server.ThreadingHTTPServer): daemon_threads = True ssl_ctx = None - def handle_error(self, request, client_address): # обрывы TLS от сканеров — не ошибка + def handle_error(self, request, client_address): # обрывы TLS от сканеров – не ошибка pass address_family = socket.AF_INET6 if ":" in CONF.get("listen", "") else socket.AF_INET @@ -228,7 +228,7 @@ def main(): stamp = [os.path.getmtime(cert)] def reload_cert(): - # Let's Encrypt на IP живёт 6 дней — после продления берём новый сертификат без перезапуска. + # Let's Encrypt на IP живёт 6 дней – после продления берём новый сертификат без перезапуска. while True: time.sleep(600) try: diff --git a/scripts/kit.sh b/scripts/kit.sh index b2b5347..126e3b3 100644 --- a/scripts/kit.sh +++ b/scripts/kit.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# kit — управление сервером 3X-UI KIT: пользователи и обновление. +# kit – управление сервером 3X-UI KIT: пользователи, обновление, резервная копия. # https://github.com/itsnotkubrick/3X-UI_KIT # # kit user add имя [--gb 50] [--days 30] [--devices 3] # kit user list | link имя | limit имя [--gb N] [--days N] | off имя | on имя | del имя -# kit update | kit version +# kit update [--auto | --manual] | kit backup | kit version set -Eeuo pipefail export LC_ALL=C.UTF-8 # ширина колонок по символам, а не байтам @@ -28,7 +28,7 @@ warn() { printf '%s\n' "${Y}!${N} $*" >&2; } die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Запустите от root: sudo -i, затем команду ещё раз." -[[ -f $XUI_ENV && -f $KIT_ENV ]] || die "Не найдена установка — сначала поставьте сервер скриптом 3x-ui.sh." +[[ -f $XUI_ENV && -f $KIT_ENV ]] || die "Не найдена установка – сначала поставьте сервер скриптом 3x-ui.sh." # shellcheck disable=SC1090 . "$XUI_ENV"; . "$KIT_ENV" @@ -52,7 +52,7 @@ api() { # METHOD path [json] # В 3X-UI 3.x у клиента одна запись и в ней одна пара ключей WireGuard и один адрес. Если # клиент подключён к двум AmneziaWG, в подписку для обоих уходят ключ и адрес одного из них, # и второй сервер клиента не узнаёт (проверено 2026-09-27). Поэтому к первому AmneziaWG -# подключаем основную запись, а ко второму — запись-«двойник» «имя-awg» с подпиской «-awg» +# подключаем основную запись, а ко второму – запись-«двойник» «имя-awg» с подпиской «-awg» # (subId в 3X-UI обязан быть уникальным) и теми же лимитами; kit-sub подмешивает её в Clash. awg_ids() { api GET inbounds/list | jq -r '[.[] | select(.protocol == "amneziawg") | .id] | sort | .[]'; } non_awg_ids() { api GET inbounds/list | jq -c '[.[] | select(.protocol != "amneziawg") | .id]'; } @@ -98,13 +98,13 @@ show_link() { # имя subId local url url=$(sub_url "$2") echo - echo "Подписка ${B}$1${N} — все протоколы одной ссылкой. Вставьте в Happ, Hiddify, Karing," + echo "Подписка ${B}$1${N} – все протоколы одной ссылкой. Вставьте в Happ, Hiddify, Karing," echo "v2rayN, Clash Verge или FlClash:" echo echo "$url" echo command -v qrencode >/dev/null && qrencode -t ANSIUTF8 -m 1 "$url" - echo "${D}AmneziaVPN и Telegram: kit user link $1 --all — отдельные ссылки vpn:// и tg://${N}" + echo "${D}AmneziaVPN и Telegram: kit user link $1 --all – отдельные ссылки vpn:// и tg://${N}" } cmd_add() { @@ -167,13 +167,13 @@ cmd_list() { elif ((exp > 0 && exp < $(date +%s) * 1000)); then st="${Y}истёк${N}" elif ((total > 0 && used >= total)); then st="${Y}лимит${N}" else st="${G}активен${N}"; fi - if ((last > 0)); then seen=$(date -d "@$((last / 1000))" '+%d.%m %H:%M'); else seen="—"; fi + if ((last > 0)); then seen=$(date -d "@$((last / 1000))" '+%d.%m %H:%M'); else seen="–"; fi printf "%-18s %-22s %-14s %-19s %s\n" "$email" "$tr" "$till" "$st" "$seen" done } } -# Меняет все записи пользователя (основную и «двойников» AmneziaWG), каждую — от её собственных данных. +# Меняет все записи пользователя (основную и «двойников» AmneziaWG), каждую – от её собственных данных. update_user() { # имя jq-фильтр [аргументы jq...] local name=$1 filter=$2 e rec body shift 2 @@ -197,14 +197,14 @@ cmd_limit() { esac done update_user "$name" "$f" --argjson gb "${gb:-0}" --argjson days "${days:-0}" --argjson dev "${dev:-0}" - say "Лимиты $name обновлены (0 — без ограничений)." + say "Лимиты $name обновлены (0 – без ограничений)." } cmd_toggle() { # имя true|false valid_name "$1" [[ -n $(client "$1") ]] || die "Нет пользователя $1" update_user "$1" ".enable = \$v" --argjson v "$2" - if [[ $2 == true ]]; then say "Пользователь $1 включён."; else say "Пользователь $1 выключен — подписка и подключения не работают."; fi + if [[ $2 == true ]]; then say "Пользователь $1 включён."; else say "Пользователь $1 выключен – подписка и подключения не работают."; fi } cmd_del() { @@ -222,12 +222,24 @@ cmd_del() { # ---------- версия и обновление ---------- +# Открытый ключ, которым автор подписывает релизы (ssh-keygen -Y sign). Закрытая часть +# есть только у автора, поэтому подменить обновление, взломав один GitHub, не выйдет. +# Новый ключ приходит только в релизе, подписанном старым. +KIT_SIGNERS=( + # KIT_SIGNER_KEY +) +KIT_SIG_NS="3x-ui-kit-release" +KIT_SIG_ID="releases@3x-ui-kit" +KIT_MANUAL=/etc/kit/manual-update +KIT_UPDATE_LOG=/var/log/kit-update.log + xray_version() { local b for b in /usr/local/x-ui/bin/xray-linux-*; do [[ -x $b ]] && "$b" version 2>/dev/null | awk 'NR==1 {print "v" $2}'; return; done } remote_version() { curl -fsS -m "${1:-5}" "$KIT_RAW/VERSION" 2>/dev/null | tr -d '[:space:]'; } newer() { [[ $1 != "$2" && $(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1) == "$1" ]]; } # $1 новее $2? +auto_enabled() { systemctl is-enabled -q kit-update.timer 2>/dev/null; } # Раз в сутки узнаём последнюю версию (не дольше 3 секунд) и подсказываем обновиться. update_hint() { @@ -239,13 +251,19 @@ update_hint() { latest=$(cat "$KIT_LATEST" 2>/dev/null || true) if [[ -n $latest ]] && newer "$latest" "$KIT_VERSION"; then echo - echo "${Y}↑ Доступна версия $latest${N} (у вас $KIT_VERSION). Обновить: ${B}kit update${N}" + if auto_enabled; then + echo "${Y}↑ Вышла версия $latest${N} (у вас $KIT_VERSION). Встанет сама этой ночью или сейчас: ${B}kit update${N}" + else + echo "${Y}↑ Доступна версия $latest${N} (у вас $KIT_VERSION). Обновить: ${B}kit update${N}" + fi fi } cmd_version() { echo "3X-UI KIT $KIT_VERSION" echo "${D}панель 3X-UI $(/usr/local/x-ui/x-ui -v 2>/dev/null | head -1 || echo '?'), ядро Xray $(xray_version)${N}" + if auto_enabled; then echo "${D}автообновление: включено (только подписанные релизы), журнал $KIT_UPDATE_LOG${N}" + else echo "${D}автообновление: выключено, включить: kit update --auto${N}"; fi update_hint } @@ -255,6 +273,31 @@ changelog_of() { index($0, v) == 1 { on = 1; t = substr($0, length(v) + 1); sub(/^[: ]+/, "", t); if (t != "") print t; next } on && /^## / { exit } on' | sed 's/\*\*//g; s/`//g' | grep -v '^[[:space:]]*$' | head -40 || true } +# Скачивает релиз $1 в каталог $2 и проверяет: подпись SHA256SUMS нашим ключом, версию +# внутри подписанного файла и SHA256 каждого файла. Любое несовпадение – отказ. +fetch_release() { # версия каталог + local v=$1 d=$2 raw f sum k + ((${#KIT_SIGNERS[@]})) || { warn "В этой сборке kit нет ключа подписи – проверить обновление нечем."; return 1; } + command -v ssh-keygen >/dev/null || { warn "Нет ssh-keygen (пакет openssh-client) – подпись не проверить."; return 1; } + raw=$(kit_ref_raw "$v") + curl -fsSL --retry 3 -o "$d/SHA256SUMS" "$raw/SHA256SUMS" && curl -fsSL --retry 3 -o "$d/SHA256SUMS.sig" "$raw/SHA256SUMS.sig" \ + || { warn "Не удалось скачать подпись релиза $v."; return 1; } + : >"$d/allowed_signers" + for k in "${KIT_SIGNERS[@]}"; do printf '%s namespaces="%s" %s\n' "$KIT_SIG_ID" "$KIT_SIG_NS" "$k" >>"$d/allowed_signers"; done + if ! ssh-keygen -Y verify -f "$d/allowed_signers" -I "$KIT_SIG_ID" -n "$KIT_SIG_NS" -s "$d/SHA256SUMS.sig" <"$d/SHA256SUMS" >/dev/null 2>&1; then + warn "Подпись релиза $v не сошлась с ключом автора – это не наш релиз. Ничего не ставлю." + return 1 + fi + # Версия записана внутри подписанного файла: старый подписанный релиз под видом нового не пройдёт. + grep -qx "# 3X-UI KIT $v" "$d/SHA256SUMS" || { warn "Подписанный релиз не той версии – ничего не ставлю."; return 1; } + for f in scripts/kit.sh scripts/kit-sub.py; do + sum=$(awk -v f="$f" '$2 == f {print $1}' "$d/SHA256SUMS") + [[ $sum =~ ^[0-9a-f]{64}$ ]] || { warn "В подписанном списке нет $f."; return 1; } + curl -fsSL --retry 3 -o "$d/${f##*/}" "$raw/$f" || { warn "Не удалось скачать $f."; return 1; } + [[ $(sha256sum "$d/${f##*/}" | awk '{print $1}') == "$sum" ]] || { warn "$f не совпал с подписанным SHA256 – ничего не ставлю."; return 1; } + done +} + # Юнит kit-sub: без root (DynamicUser), конфиг и сертификат – через LoadCredential. # Сертификат Let's Encrypt на IP продлевается раз в несколько дней, поэтому при отдельном # порте kit-sub перезапускается раз в сутки и берёт свежий. @@ -292,37 +335,110 @@ WantedBy=multi-user.target UNIT } +# Подписка отвечает? Берём настоящего пользователя и спрашиваем kit-sub изнутри сервера. +sub_ok() { + local cfg=/etc/kit-sub/config.json port path scheme=http sid i + port=$(jq -r '.port' "$cfg"); path=$(jq -r '.path' "$cfg") + [[ -n $(jq -r '.cert // empty' "$cfg") ]] && scheme=https + sid=$(clients 2>/dev/null | jq -r '[.[] | .subId // empty | select(. != "")][0] // empty' 2>/dev/null || true) + for i in $(seq 1 10); do + if [[ -n $sid ]]; then + curl -fsSk -m 5 -o /dev/null -A "Happ/1.0" "$scheme://127.0.0.1:$port$path$sid" 2>/dev/null && return 0 + else + # Пользователей нет – достаточно, что служба жива и слушает порт. + systemctl is-active -q kit-sub && ss -Hltn "sport = :$port" | grep -q . && return 0 + fi + sleep 1 + done + return 1 +} + # Сервер ещё не получил исправления 1.1 (например, kit обновили вручную из 1.0)? needs_migration() { [[ -f /etc/cron.d/kit-xui-menu ]] && return 0 [[ -f /etc/systemd/system/kit-sub.service ]] && ! grep -q '^DynamicUser=yes' /etc/systemd/system/kit-sub.service && return 0 + [[ ! -f $KIT_MANUAL ]] && ! auto_enabled && return 0 return 1 } +# Автообновление: раз в сутки ночью со случайной задержкой, чтобы тысячи серверов не шли +# на GitHub в одну минуту. Ставит только подписанные релизы и только kit и kit-sub. +auto_on() { + cat >/etc/systemd/system/kit-update.service </etc/systemd/system/kit-update.timer <<'UNIT' +[Unit] +Description=3X-UI KIT: проверка обновлений раз в сутки + +[Timer] +OnCalendar=*-*-* 03:00:00 +RandomizedDelaySec=3h +Persistent=true + +[Install] +WantedBy=timers.target +UNIT + rm -f "$KIT_MANUAL" + systemctl daemon-reload + systemctl enable --now kit-update.timer >/dev/null 2>&1 +} + +auto_off() { + systemctl disable --now kit-update.timer >/dev/null 2>&1 || true + install -d -m 700 /etc/kit + touch "$KIT_MANUAL" +} + cmd_update() { - local force=${1:-} latest tmp + local force="" unattended=no latest tmp + while [[ $# -gt 0 ]]; do + case $1 in + --force) force=yes ;; + --auto) auto_on; say "Автообновление включено: раз в сутки ночью, только подписанные релизы. Журнал: $KIT_UPDATE_LOG"; return ;; + --manual) auto_off; say "Автообновление выключено. Обновляться вручную: kit update, включить снова: kit update --auto"; return ;; + --unattended) unattended=yes ;; + *) die "Неизвестный параметр: $1 (kit update [--force | --auto | --manual])" ;; + esac + shift + done + # Ночной запуск и ручной не должны встретиться. + exec 9>/run/kit-update.lock + flock -n 9 || die "Обновление уже идёт." + [[ $unattended == yes ]] && echo "--- $(date '+%F %T') kit $KIT_VERSION: проверяю обновления" + latest=$(remote_version) || true [[ $latest =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "Не удалось узнать последнюю версию: GitHub недоступен с сервера. Попробуйте позже." echo "$latest" >"$KIT_LATEST" - if ! newer "$latest" "$KIT_VERSION" && [[ $force != --force ]] && ! needs_migration; then + # Откатить на старую версию нельзя даже с подписью: только вперёд или та же. + newer "$KIT_VERSION" "$latest" && die "На GitHub версия $latest старше вашей $KIT_VERSION – ничего не делаю." + if ! newer "$latest" "$KIT_VERSION" && [[ $force != yes ]] && ! needs_migration; then say "У вас последняя версия: $KIT_VERSION." - echo "${D}Переустановить файлы kit той же версии: kit update --force${N}" + [[ $unattended == yes ]] || echo "${D}Переустановить файлы kit той же версии: kit update --force${N}" return fi if [[ $latest == "$KIT_VERSION" ]]; then say "Применяю исправления версии $latest"; else say "3X-UI KIT $KIT_VERSION → $latest"; fi tmp=$(mktemp -d) - trap 'rm -rf "$tmp"' RETURN - curl -fsSL --retry 3 -o "$tmp/kit.sh" "$(kit_ref_raw "$latest")/scripts/kit.sh" && bash -n "$tmp/kit.sh" \ - || die "Не удалось скачать новую версию kit – сервер не тронут." + # shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет + trap "rm -rf -- '$tmp'" EXIT + fetch_release "$latest" "$tmp" || die "Сервер не тронут." + bash -n "$tmp/kit.sh" && python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$tmp/kit-sub.py" \ + || die "Файлы релиза не прошли проверку синтаксиса – сервер не тронут." - # Подписка kit-sub: ставим новую, при сбое возвращаем старую. + # Подписка kit-sub: ставим новую, проверяем, что отвечает, иначе возвращаем старую. if [[ -f /usr/local/lib/kit-sub/kit_sub.py ]]; then - curl -fsSL --retry 3 -o "$tmp/kit_sub.py" "$(kit_ref_raw "$latest")/scripts/kit-sub.py" \ - && python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$tmp/kit_sub.py" \ - || die "Не удалось скачать kit-sub – сервер не тронут." cp /usr/local/lib/kit-sub/kit_sub.py "$tmp/kit_sub.old" cp /etc/systemd/system/kit-sub.service "$tmp/kit-sub.service.old" - install -m 644 "$tmp/kit_sub.py" /usr/local/lib/kit-sub/kit_sub.py + install -m 644 "$tmp/kit-sub.py" /usr/local/lib/kit-sub/kit_sub.py # С 1.1 kit-sub работает без root: переписываем юнит под DynamicUser и LoadCredential. local c k c=$(jq -r '.cert // empty' /etc/kit-sub/config.json); k=$(jq -r '.key // empty' /etc/kit-sub/config.json) @@ -330,16 +446,16 @@ cmd_update() { [[ -n $c ]] && echo '19 4 * * * root systemctl restart kit-sub >/dev/null 2>&1' >/etc/cron.d/kit-sub-cert systemctl daemon-reload systemctl restart kit-sub - sleep 3 - if systemctl is-active -q kit-sub; then - say "Подписка kit-sub обновлена" + sleep 2 + if sub_ok; then + say "Подписка kit-sub обновлена и отвечает" else install -m 644 "$tmp/kit_sub.old" /usr/local/lib/kit-sub/kit_sub.py install -m 644 "$tmp/kit-sub.service.old" /etc/systemd/system/kit-sub.service - rm -f /etc/cron.d/kit-sub-cert + grep -q '^LoadCredential=cert.pem' "$tmp/kit-sub.service.old" || rm -f /etc/cron.d/kit-sub-cert systemctl daemon-reload systemctl restart kit-sub - warn "Новая kit-sub не запустилась – вернул прежнюю. Лог: journalctl -u kit-sub -n 30" + die "Новая подписка не ответила – вернул прежнюю, kit остался версии $KIT_VERSION. Лог: journalctl -u kit-sub -n 30" fi fi @@ -353,16 +469,67 @@ cmd_update() { fi # 1.0 ставил cron, который каждый день правил файлы x-ui, – убираем. rm -f /etc/cron.d/kit-xui-menu + # Автообновление включено по умолчанию, пока его не выключили командой kit update --manual. + [[ -f $KIT_MANUAL ]] || auto_enabled || { auto_on; say "Включил автообновление: kit update --manual, чтобы выключить"; } # Через rename: bash дочитывает текущий kit по ходу работы, его файл трогать нельзя. install -m 755 "$tmp/kit.sh" /usr/local/bin/kit.new && mv -f /usr/local/bin/kit.new /usr/local/bin/kit echo echo "${G}✓ Готово: 3X-UI KIT $latest.${N} Пользователи, ссылки и подписки не менялись." + [[ $unattended == yes ]] && return local news news=$(changelog_of "$latest") if [[ -n $news ]]; then echo; echo "${B}Что нового в $latest${N}"; echo "$news"; fi } +# ---------- резервная копия ---------- + +# Всё, что нужно, чтобы поднять тот же сервер в другом месте: база панели (пользователи, +# ключи, подключения), настройки kit и kit-sub, nginx, сайт-заглушка, свои сертификаты. +# Сертификат Let's Encrypt на IP не берём: на новом сервере он выпускается заново. +BACKUP_PATHS=(/etc/x-ui/install-result.env /etc/kit/kit.env /etc/kit-sub/config.json + /etc/nginx/kit-stream.conf /etc/nginx/conf.d/kit.conf /var/www/kit /root/cert/self /root/cert/custom /root/3x-ui.txt) + +cmd_backup() { + local out tmp p ssl=none c + out=/root/kit-backup-$(date +%Y%m%d-%H%M).tar.gz + tmp=$(mktemp -d) + # shellcheck disable=SC2064 # путь подставляем сразу: при выходе локальной переменной уже нет + trap "rm -rf -- '$tmp'" EXIT + install -d -m 700 "$tmp/etc/x-ui" + # Снимок базы средствами SQLite: панель продолжает работать, копия целая. + python3 - /etc/x-ui/x-ui.db "$tmp/etc/x-ui/x-ui.db" <<'PY' || die "Не удалось скопировать базу панели." +import sqlite3, sys +src = sqlite3.connect("file:%s?mode=ro" % sys.argv[1], uri=True) +dst = sqlite3.connect(sys.argv[2]) +src.backup(dst) +dst.close(); src.close() +PY + for p in "${BACKUP_PATHS[@]}"; do if [[ -e $p ]]; then cp -a --parents "$p" "$tmp"; fi; done + # Какой сертификат был у панели и подписки – новый сервер должен получить такой же. + c=$(jq -r '.cert // empty' /etc/kit-sub/config.json 2>/dev/null || true) + [[ -z $c && -f /etc/nginx/conf.d/kit.conf ]] && c=$(awk '$1 == "ssl_certificate" {sub(/;$/, "", $2); print $2; exit}' /etc/nginx/conf.d/kit.conf) + case $c in + /root/cert/ip/*) ssl=ip ;; + /root/cert/custom/*) ssl=custom ;; + esac + { + printf 'BACKUP_KIT_VERSION=%q\n' "$KIT_VERSION" + printf 'BACKUP_HOST=%q\n' "$HOST" + printf 'BACKUP_SSL=%q\n' "$ssl" + printf 'BACKUP_DATE=%q\n' "$(date +%F)" + } >"$tmp/kit-backup.env" + (umask 077; tar -czf "$out" -C "$tmp" .) + chmod 600 "$out" + say "Резервная копия: ${B}$out${N} ($(du -h "$out" | cut -f1))" + echo + echo "В ней ключи и пароли от сервера, храните её как пароль. Скачать к себе (на компьютере):" + echo " ${B}scp root@$HOST:$out .${N}" + echo + echo "Поднять сервер из копии на новом VPS:" + echo " ${B}bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/3x-ui.sh) --restore ${out##*/}${N}" +} + usage() { cat < p.type !== 'mtproto').forEach((p, i) => { - // Тестовый сервер с самоподписанным сертификатом — разрешаем клиентам его принять. + // Тестовый сервер с самоподписанным сертификатом – разрешаем клиентам его принять. if (PIN && p.tls && p.tls.security === "tls") { p.tls.insecure = true; p.tls.pin = PIN; } if (PIN && (p.type === "hysteria2" || p.type === "tuic")) p.insecure = true; if (PIN && p.type === "hysteria2") p.pinSHA256 = PIN; diff --git a/tests/matrix/matrix-run.sh b/tests/matrix/matrix-run.sh index aaa2b63..63346c8 100644 --- a/tests/matrix/matrix-run.sh +++ b/tests/matrix/matrix-run.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Прогон матрицы: каждый конфиг — отдельный клиент, через него открываем сайт. +# Прогон матрицы: каждый конфиг – отдельный клиент, через него открываем сайт. cd /lab/cases t() { # client port cmd... local port=$1; shift @@ -16,7 +16,7 @@ t() { # client port cmd... printf '%-32s %-40s %-40s %s\n' "протокол" "Xray 26.6.27" "Mihomo" "sing-box" for d in */; do d=${d%/} - x='—'; m='—'; s='—' + x='–'; m='–'; s='–' [[ -f $d/xray.json ]] && x=$(t 1080 /cl/xray run -c $d/xray.json) [[ -f $d/mihomo.yaml ]] && m=$(t 1081 /cl/mihomo -d /tmp/mh -f $d/mihomo.yaml) [[ -f $d/singbox.json ]] && s=$(t 1082 /cl/sing-box run -c $d/singbox.json) diff --git a/tools/lib/links.js b/tools/lib/links.js index f8d70b0..dfd37ab 100644 --- a/tools/lib/links.js +++ b/tools/lib/links.js @@ -1,5 +1,5 @@ // Разбор ссылок vless:// vmess:// trojan:// ss:// hy2:// в общий формат. -// Работает в браузере (window.PM) и в Node (module.exports). Всё локально — +// Работает в браузере (window.PM) и в Node (module.exports). Всё локально – // ссылки никуда не отправляются. (function (root) { 'use strict'; @@ -78,7 +78,7 @@ s.fp = q.get('fp') || (security === 'reality' ? 'chrome' : ''); s.alpn = splitList(q.get('alpn')); s.insecure = truthy(q.get('allowInsecure')) || truthy(q.get('insecure')); - // Отпечаток сертификата: pcs — имя параметра в ссылках Xray 26, pinSHA256 — в ссылках Hysteria. + // Отпечаток сертификата: pcs – имя параметра в ссылках Xray 26, pinSHA256 – в ссылках Hysteria. s.pin = (q.get('pcs') || q.get('pinSHA256') || '').replace(/:/g, '').toLowerCase(); } if (security === 'reality') { @@ -228,7 +228,7 @@ }, hp); } - // Конфиг wg-quick / AmneziaWG: [Interface] + [Peer]. Параметры Jc…H4 — обфускация AmneziaWG. + // Конфиг wg-quick / AmneziaWG: [Interface] + [Peer]. Параметры Jc…H4 – обфускация AmneziaWG. function parseWgConf(text, name) { const sec = {}; let cur = null; @@ -261,7 +261,7 @@ function parseVpn(link) { let text; try { text = b64decode(link.slice('vpn://'.length).split('#')[0]); } catch (e) { throw new Error('ссылка vpn:// повреждена'); } - if (!/\[Interface\]/.test(text)) throw new Error('формат vpn:// не поддерживается — нужен конфиг AmneziaWG'); + if (!/\[Interface\]/.test(text)) throw new Error('формат vpn:// не поддерживается – нужен конфиг AmneziaWG'); return parseWgConf(text, dec(link.split('#')[1] || '')); } @@ -292,7 +292,7 @@ function parseText(text) { let t = (text || '').trim(); if (t && !/:\/\//.test(t) && looksBase64(t)) { - try { t = b64decode(t); } catch (e) { /* не base64 — разберём как есть */ } + try { t = b64decode(t); } catch (e) { /* не base64 – разберём как есть */ } } const ok = []; const errors = []; @@ -303,7 +303,7 @@ errors.push({ line: i + 1, text: line.length > 60 ? line.slice(0, 57) + '…' : line, error: e.message }); } }); - // Уникальные имена — клиенты требуют. + // Уникальные имена – клиенты требуют. const seen = {}; ok.forEach((p) => { const base = p.name; diff --git a/tools/lib/mihomo.js b/tools/lib/mihomo.js index 016f9cd..15ce73a 100644 --- a/tools/lib/mihomo.js +++ b/tools/lib/mihomo.js @@ -7,7 +7,7 @@ // Сервисы: какие наборы правил отправлять через прокси. const SERVICES = [ - { id: 'blocked', group: 'Заблокированное', label: 'Заблокированное в России', hint: 'список re:filter — основные заблокированные сайты', on: true, + { id: 'blocked', group: 'Заблокированное', label: 'Заблокированное в России', hint: 'список re:filter – основные заблокированные сайты', on: true, sets: [['refilter@domain', REFILTER + 'domain-rule.mrs', 'domain'], ['refilter@ipcidr', REFILTER + 'ip-rule.mrs', 'ipcidr']] }, { id: 'youtube', label: 'YouTube', on: true, sets: [['youtube@domain', META + 'geosite/youtube.mrs', 'domain']] }, { id: 'telegram', label: 'Telegram', on: true, @@ -142,7 +142,7 @@ o['pre-shared-key'] = p.preSharedKey || undefined; o.mtu = p.mtu; o.reserved = p.reserved || undefined; // Параметры AmneziaWG в именах Mihomo. Для 3.1 (есть ключ защиты заголовков) - // обязателен version: 3 — без него Mihomo не подключается к такому серверу. + // обязателен version: 3 – без него Mihomo не подключается к такому серверу. if (p.awg) { const names = { headerprotectionkey: 'header-protection-key', contentpaddingaddition: 'content-padding-addition', rekeyaftertime: 'rekey-after-time', rejectaftertime: 'reject-after-time', rekeytimeout: 'rekey-timeout', @@ -194,7 +194,8 @@ 'external-controller': '0.0.0.0:9090', secret: opts.secret || randomSecret(), 'external-ui': 'zashboard', - 'external-ui-url': 'https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip', + // Версия панели закреплена: «latest» поставил бы на роутер то, что мы не проверяли. + 'external-ui-url': 'https://github.com/Zephyruso/zashboard/releases/download/v3.29.1/dist.zip', sniffer: { enable: true, sniff: { HTTP: { __flow: 1, ports: [80, 8080] }, TLS: { __flow: 1, ports: [443, 8443] }, QUIC: { __flow: 1, ports: [443, 8443] } }, @@ -221,7 +222,7 @@ const target = {}; services.forEach((s) => { if (opts.perService) { - // В правилах запятая — разделитель, поэтому имя группы без запятых. + // В правилах запятая – разделитель, поэтому имя группы без запятых. const g = (s.group || s.label).replace(/,/g, ''); groups.push({ name: g, type: 'select', proxies: [PROXY, 'DIRECT'], 'include-all': true }); target[s.id] = g; @@ -253,7 +254,7 @@ const head = '# Сгенерировано: https://itsnotkubrick.github.io/3X-UI_KIT/tools/mihomo/\n' + '# Файл для XKeen: /opt/etc/mihomo/config.yaml, затем xkeen -restart\n' + - '# Панель управления: http://IP-роутера:9090/ui (секрет — поле secret ниже)\n'; + '# Панель управления: http://IP-роутера:9090/ui (секрет – поле secret ниже)\n'; return { yaml: head + yaml(cfg).replace(/^\n/, ''), config: cfg, count: proxies.length }; } diff --git a/tools/lib/singbox.js b/tools/lib/singbox.js index c15f910..d6a87be 100644 --- a/tools/lib/singbox.js +++ b/tools/lib/singbox.js @@ -32,7 +32,7 @@ throw new Error('sing-box не поддерживает транспорт ' + t.network); } - // Возвращает { outbound } или { endpoint } (WireGuard в sing-box — endpoint). + // Возвращает { outbound } или { endpoint } (WireGuard в sing-box – endpoint). function convert(p) { const base = { tag: p.name, server: p.server, server_port: p.port }; switch (p.type) { @@ -45,7 +45,7 @@ case 'ss': return { outbound: Object.assign(base, { type: 'shadowsocks', method: p.method, password: p.password }) }; case 'hysteria2': { - // sing-box не умеет закреплять отпечаток сертификата — при pinSHA256 проверку отключаем. + // sing-box не умеет закреплять отпечаток сертификата – при pinSHA256 проверку отключаем. const t = clean({ enabled: true, server_name: p.sni, alpn: p.alpn, insecure: (p.insecure || !!p.pinSHA256) || undefined }); const o = clean(Object.assign(base, { type: 'hysteria2', password: p.password, tls: t })); if (p.obfs) o.obfs = { type: p.obfs, password: p.obfsPassword }; @@ -65,7 +65,7 @@ } } - // opts: { inbound: {type:'mixed', port} } — для тестов и десктопных клиентов. + // opts: { inbound: {type:'mixed', port} } – для тестов и десктопных клиентов. function build(proxies, opts) { opts = opts || {}; const outbounds = [], endpoints = [], skipped = []; diff --git a/tools/lib/xray.js b/tools/lib/xray.js index 26312bf..3507960 100644 --- a/tools/lib/xray.js +++ b/tools/lib/xray.js @@ -71,9 +71,9 @@ peers: [clean({ publicKey: p.publicKey, preSharedKey: p.preSharedKey, endpoint: p.server + ':' + p.port, allowedIPs: ['0.0.0.0/0', '::/0'] })] }), }; default: - throw new Error({ tuic: 'TUIC не поддерживается Xray — возьмите генератор Mihomo', - amneziawg: 'AmneziaWG не поддерживается Xray — возьмите генератор Mihomo', - mtproto: 'MTProto — это прокси для Telegram, в Xray он не нужен' }[p.type] || 'протокол ' + p.type + ' не поддерживается Xray'); + throw new Error({ tuic: 'TUIC не поддерживается Xray – возьмите генератор Mihomo', + amneziawg: 'AmneziaWG не поддерживается Xray – возьмите генератор Mihomo', + mtproto: 'MTProto – это прокси для Telegram, в Xray он не нужен' }[p.type] || 'протокол ' + p.type + ' не поддерживается Xray'); } } @@ -96,7 +96,7 @@ { id: 'discord', label: 'Discord', on: true, zkeen: { i: ['discord'] }, v2fly: { d: ['discord'] } }, { id: 'twitter', label: 'X (Twitter)', on: true, v2fly: { d: ['twitter'] } }, { id: 'ai', label: 'ChatGPT, Claude и другие ИИ', on: true, v2fly: { d: ['category-ai-!cn'] } }, - { id: 'cdn', label: 'Зарубежные CDN и хостинги', hint: 'Cloudflare, Hetzner, DigitalOcean… — их часто замедляют', on: true, + { id: 'cdn', label: 'Зарубежные CDN и хостинги', hint: 'Cloudflare, Hetzner, DigitalOcean… – их часто замедляют', on: true, zkeen: { i: ['akamai', 'amazon', 'arelion', 'azure', 'bunnycdn', 'cdn77', 'cloudflare', 'cogent', 'colocrossing', 'contabo', 'datacamp', 'digitalocean', 'fastly', 'frantech', 'gcore', 'hetzner', 'leaseweb', 'linode', 'liquidweb', 'mega', 'melbicom', 'oracle', 'ovh', 'scaleway', 'vodafone', 'vultr'] } }, @@ -128,7 +128,7 @@ })); const uniq = (a) => Array.from(new Set(a)); - // Политика xkeen_full: всё с этих входов — через прокси (как в стандартном конфиге XKeen). + // Политика xkeen_full: всё с этих входов – через прокси (как в стандартном конфиге XKeen). const rules = [{ inboundTag: ['force-proxy-redirect', 'force-proxy-tproxy'], outboundTag: proxyTag }]; const dd = domains(opts.directDomains), pd = domains(opts.proxyDomains); if (dd.length) rules.push({ domain: dd.map((d) => 'domain:' + d), outboundTag: 'direct' }); @@ -157,7 +157,7 @@ const warnings = []; proxies.forEach((p) => { const insecure = (p.tls && p.tls.insecure && !p.tls.pin) || (p.type === 'hysteria2' && p.insecure && !p.pinSHA256); - if (insecure) warnings.push(p.name + ': в ссылке отключена проверка сертификата, а Xray 26 так не умеет — нужен отпечаток (pcs) или настоящий сертификат'); + if (insecure) warnings.push(p.name + ': в ссылке отключена проверка сертификата, а Xray 26 так не умеет – нужен отпечаток (pcs) или настоящий сертификат'); try { const tag = outs.length === 0 ? mainTag : mainTag + '-' + (outs.length + 1); outs.push(outbound(p, tag)); @@ -167,7 +167,7 @@ }); if (!outs.length) throw new Error(skipped.length ? skipped[0].error : 'Добавьте хотя бы одну ссылку.'); - // Несколько серверов — балансировщик по пингу выбирает лучший. + // Несколько серверов – балансировщик по пингу выбирает лучший. const multi = outs.length > 1; let obs = null; const proxyTarget = mainTag; diff --git a/tools/mihomo/index.html b/tools/mihomo/index.html index 08c2a29..a5ebd3c 100644 --- a/tools/mihomo/index.html +++ b/tools/mihomo/index.html @@ -4,7 +4,7 @@ Генератор Mihomo для XKeen - + @@ -16,9 +16,9 @@

Конфиг Mihomo для XKeen

-

Ссылки на серверы или подписка — и готовый config.yaml с правилами для нужных сервисов, +

Ссылки на серверы или подписка – и готовый config.yaml с правилами для нужных сервисов, автовыбором быстрого сервера и веб-панелью. Плюс команда, которая сама положит его на роутер.

-
🔒 Всё считается в вашем браузере — ссылки никуда не отправляются
+
🔒 Всё считается в вашем браузере – ссылки никуда не отправляются

1Серверы

@@ -36,10 +36,10 @@
Дополнительно
- + - +
@@ -51,7 +51,7 @@

3Готовый конфиг

-
Добавьте ссылку или подписку в шаг 1 — здесь появится конфиг.
+
Добавьте ссылку или подписку в шаг 1 – здесь появится конфиг.
@@ -101,7 +101,7 @@ if ($('#sub').value.trim() && !o.subscription) err = 'Подписка должна начинаться с http:// или https://'; if (!r) { yaml = ''; - $('#result').innerHTML = err ? '
' + esc(err) + '
' : '
Добавьте ссылку или подписку в шаг 1 — здесь появится конфиг.
'; + $('#result').innerHTML = err ? '
' + esc(err) + '
' : '
Добавьте ссылку или подписку в шаг 1 – здесь появится конфиг.
'; return; } yaml = r.yaml; @@ -110,10 +110,10 @@ '
' + '
' + '
' + - '
  1. Подключитесь к роутеру: ssh root@192.168.1.1 -p 222
  2. ' + + '
    1. Подключитесь к роутеру: ssh root@192.168.x.x -p 222
    2. ' + '
    3. Если XKeen сейчас работает на Xray, сначала выполните xkeen -mihomo
    4. ' + - '
    5. Вставьте команду целиком и нажмите Enter — конфиг запишется, XKeen перезапустится.
    6. ' + - '
    7. Панель управления: http://192.168.1.1:9090/ui, секрет:
    '; + '
  3. Вставьте команду целиком и нажмите Enter – конфиг запишется, XKeen перезапустится.
  4. ' + + '
  5. Панель управления: http://192.168.x.x:9090/ui, секрет:
'; UI.tabs($('#tabs'), (t) => { tab = t; show(); }); $('#cp').onclick = (e) => UI.copy($('#out').textContent, e.target); $('#dl').onclick = () => UI.download('config.yaml', yaml); diff --git a/tools/release.sh b/tools/release.sh new file mode 100644 index 0000000..9f9528e --- /dev/null +++ b/tools/release.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Подпись релиза 3X-UI KIT. Запускает только автор, на своём компьютере, перед тегом: +# +# tools/release.sh [~/.ssh/3xui_kit_release] +# +# Проверяет, что версии сходятся и установщик 3X-UI не подменили, пишет SHA256SUMS, +# подписывает его закрытым ключом (ssh-keygen спросит пароль ключа) и сразу проверяет +# подпись открытым ключом, записанным в kit. Сервер 3X-UI KIT ставит обновление, только +# если эта проверка у него тоже сошлась. Закрытый ключ никуда не отправляется. + +set -euo pipefail +cd "$(dirname "$0")/.." + +die() { printf '✗ %s\n' "$*" >&2; exit 1; } +ok() { printf '✓ %s\n' "$*"; } +sha() { if command -v sha256sum >/dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi; } + +key=${1:-$HOME/.ssh/3xui_kit_release} +[[ -f $key ]] || die "Нет закрытого ключа $key. Создать: ssh-keygen -t ed25519 -C \"3X-UI KIT releases\" -f $key" +NS="3x-ui-kit-release" +ID="releases@3x-ui-kit" +FILES=(scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh scripts/kit-sub.py VERSION) + +v=$(tr -d '[:space:]' SHA256SUMS +rm -f SHA256SUMS.sig +ssh-keygen -Y sign -f "$key" -n "$NS" SHA256SUMS >/dev/null +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +signers scripts/kit.sh | while read -r k; do printf '%s namespaces="%s" %s\n' "$ID" "$NS" "$k"; done >"$tmp" +ssh-keygen -Y verify -f "$tmp" -I "$ID" -n "$NS" -s SHA256SUMS.sig /dev/null || die "Подпись не прошла проверку." +ok "SHA256SUMS подписан и проверен так же, как это сделает сервер" +echo +cat SHA256SUMS +echo +echo "Дальше:" +echo " git add SHA256SUMS SHA256SUMS.sig && git commit -m \"Релиз v$v: подпись\"" +echo " git tag v$v && git push origin main v$v" diff --git a/tools/test/links.test.js b/tools/test/links.test.js index 04d6a9c..51eb519 100644 --- a/tools/test/links.test.js +++ b/tools/test/links.test.js @@ -109,7 +109,7 @@ test('TUIC, WireGuard, AmneziaWG (vpn://) и MTProto', () => { assert.equal(parseLink('https://t.me/proxy?server=m.example.com&port=8445&secret=ee00').port, 8445); }); -test('Xray 26: вместо allowInsecure — отпечаток или предупреждение', () => { +test('Xray 26: вместо allowInsecure – отпечаток или предупреждение', () => { const insecure = WS_TLS.replace('#', '&allowInsecure=1#'); const r = buildXray(parseText(insecure).proxies, {}); assert.ok(!JSON.stringify(r.files).includes('allowInsecure')); @@ -119,7 +119,7 @@ test('Xray 26: вместо allowInsecure — отпечаток или пред assert.equal(pinned.warnings.length, 0); }); -test('Mihomo: AmneziaWG 3.1 — имена параметров Mihomo и version: 3', () => { +test('Mihomo: AmneziaWG 3.1 – имена параметров Mihomo и version: 3', () => { const conf = '[Interface]\nPrivateKey = cHJpdg==\nAddress = 10.8.2.2/32\nJc = 3\nJmin = 49\nJmax = 139\nS1 = 141\nS2 = 92\nS3 = 22\nS4 = 18\nH1 = 5\nH2 = 6\nH3 = 7\nH4 = 8\nI1 = \nHeaderProtectionKey = a2V5\nRekeyTimeout = 5-6\n[Peer]\nPublicKey = cHVi\nEndpoint = a.example.com:51822\n'; const r = buildMihomo(parseText('vpn://' + Buffer.from(conf).toString('base64')).proxies, { secret: 'x' }); const o = r.config.proxies[0]['amnezia-wg-option']; diff --git a/tools/xray/index.html b/tools/xray/index.html index cd02177..89899a9 100644 --- a/tools/xray/index.html +++ b/tools/xray/index.html @@ -4,7 +4,7 @@ Генератор Xray для XKeen - + @@ -16,30 +16,30 @@

Конфиг Xray для XKeen

-

Вставьте ссылку на свой сервер — получите готовые 04_outbounds.json и 05_routing.json +

Вставьте ссылку на свой сервер – получите готовые 04_outbounds.json и 05_routing.json и одну команду, которая сама положит их на роутер.

-
🔒 Всё считается в вашем браузере — ссылки никуда не отправляются
+
🔒 Всё считается в вашем браузере – ссылки никуда не отправляются

1Ссылки на серверы

Поддерживаются vless (REALITY, TLS, ws, gRPC, xhttp, httpupgrade), vmess, trojan, shadowsocks, Hysteria2 и WireGuard. - Несколько серверов — Xray сам выберет самый быстрый. TUIC и AmneziaWG работают через Mihomo.

+ Несколько серверов – Xray сам выберет самый быстрый. TUIC и AmneziaWG работают через Mihomo.

2Что пускать через прокси

Геобазы на роутере
-

Какие базы вы выбрали при установке XKeen. Не уверены — оставьте ZKeen: её XKeen ставит по умолчанию.

+

Какие базы вы выбрали при установке XKeen. Не уверены – оставьте ZKeen: её XKeen ставит по умолчанию.

Сервисы
Дополнительно
- +
@@ -49,12 +49,12 @@
-

XKeen по умолчанию ожидает vless-reality — менять стоит, только если вы правили правила вручную.

+

XKeen по умолчанию ожидает vless-reality – менять стоит, только если вы правили правила вручную.

3Готовый конфиг

-
Вставьте ссылку в шаг 1 — здесь появятся файлы.
+
Вставьте ссылку в шаг 1 – здесь появятся файлы.
@@ -93,7 +93,7 @@ const el = document.querySelector('[data-svc="' + s.id + '"]'); const ok = PM.xrayServiceAvailable(s, o.bases); const box = el.querySelector('input'); - // Без нужной базы галочку снимаем, но помним выбор — вернём, когда база появится. + // Без нужной базы галочку снимаем, но помним выбор – вернём, когда база появится. if (!ok && !box.disabled) { box.dataset.want = box.checked ? '1' : ''; box.checked = false; } if (ok && box.disabled) box.checked = box.dataset.want === '1'; el.classList.toggle('off', !ok); @@ -132,7 +132,7 @@ UI.renderParsed($('#parsed'), parsed, r ? r.skipped : []); if (!r) { files = null; - $('#result').innerHTML = err ? '
' + esc(err) + '
' : '
Вставьте ссылку в шаг 1 — здесь появятся файлы.
'; + $('#result').innerHTML = err ? '
' + esc(err) + '
' : '
Вставьте ссылку в шаг 1 – здесь появятся файлы.
'; return; } files = {}; @@ -143,8 +143,8 @@ '
' + '
' + '
' + - '
  1. Подключитесь к роутеру: ssh root@192.168.1.1 -p 222
  2. ' + - '
  3. Вставьте команду целиком и нажмите Enter — файлы запишутся, XKeen перезапустится.
  4. ' + + '
    1. Подключитесь к роутеру: ssh root@192.168.x.x -p 222
    2. ' + + '
    3. Вставьте команду целиком и нажмите Enter – файлы запишутся, XKeen перезапустится.
    4. ' + '
    5. Готово. Проверить: xkeen -status
    '; UI.tabs($('#tabs'), (t) => { tab = t; show(); }); $('#cp').onclick = (e) => UI.copy($('#out').textContent, e.target); @@ -153,7 +153,7 @@ show(); const notes = []; if (r.count > 1) notes.push('Серверов: ' + r.count + '. Xray будет каждую минуту проверять их и выбирать самый быстрый.'); - if (r.skipped.length) notes.push('Пропущено: ' + r.skipped.map((s) => s.name + ' — ' + s.error).join('; ')); + if (r.skipped.length) notes.push('Пропущено: ' + r.skipped.map((s) => s.name + ' – ' + s.error).join('; ')); (r.warnings || []).forEach((w) => notes.push(w)); $('#note').innerHTML = notes.map((n) => '
    ' + esc(n) + '
    ').join(''); }