1.1: подписанное автообновление, kit backup и --restore, случайная заглушка

- kit update ставит только релизы, у которых SHA256SUMS подписан ключом автора
  (ssh-keygen -Y verify), версия внутри подписи совпадает, файлы сошлись с хешами;
  откат на старую версию запрещён; после обновления проверяется подписка, иначе возврат
- автообновление: systemd-таймер раз в сутки ночью со случайной задержкой,
  kit update --manual / --auto, журнал /var/log/kit-update.log
- hy2 update тоже проверяет подпись
- tools/release.sh: проверка версий и хеша установщика 3X-UI, SHA256SUMS и подпись
- kit backup и 3x-ui.sh --restore: архив 600, строгая проверка архива, конфиги nginx
  и kit-sub собираются заново, смена IP в подключениях
- сайт-заглушка: случайный из шести шаблонов внутри скрипта
- zashboard закреплён на v3.29.1, короткие тире и 192.168.x.x в текстах и картинках
- CI: ключ подписи одинаковый в kit и hy2, проверка подписи релиза
- CHANGELOG, README, инструкция, SECURITY.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7GrBSRPVXhrz3RAprWDnt
This commit is contained in:
Claude
2026-09-30 19:15:19 +00:00
parent d33a8bd9a6
commit 0a19858efa
30 changed files with 944 additions and 323 deletions
+12 -1
View File
@@ -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 <SHA256SUMS
+30 -7
View File
@@ -24,6 +24,8 @@
- **Меню x-ui оставили в покое.** Больше не переписываем чужие файлы по расписанию, только
один раз при установке.
- **Строгие имена.** Параметры `--sni` и `--host` у установщика 3X-UI тоже проверяются.
- **Веб-панель Mihomo на роутере закреплена.** Генератор больше не ставит на роутер «последнюю»
версию zashboard, а только ту, которую мы видели в лицо (v3.29.1).
**Для тех, кто хочет проверить сам**
@@ -31,20 +33,41 @@
- `SECURITY.md`: как сообщить об уязвимости, не публикуя её на весь интернет.
- Проверка кода на каждый коммит: ShellCheck, Python, тесты генераторов.
**Новая команда: `kit update`**
**Обновления приходят сами, но только с нашей печатью**
- Обновляет kit и подписку до новой версии, а пользователи, ссылки и подписки остаются прежними.
Раньше, чтобы получить исправление, приходилось сносить всё и раздавать ссылки заново.
- Раньше, чтобы получить исправление, приходилось сносить всё и раздавать ссылки заново.
«Мам, удали старый VPN, вот новый» больше не повторится.
- Если новая подписка вдруг не запустится, `kit update` молча вернёт старую. Ну, не молча,
предупредит.
- Раз в сутки ночью сервер сам проверяет, не вышла ли новая версия, и обновляет kit и подписку.
Пользователи, ссылки и подписки остаются прежними. Панель 3X-UI и Xray не трогаем.
- Каждый релиз подписан ключом автора, а открытая часть ключа уже лежит на вашем сервере.
Подпись не сошлась? Сервер вежливо отказывается. Даже если взломают GitHub, чужое обновление
не встанет: для этого нужен ещё и ключ, а он не в облаке, а у автора.
- Откатить сервер на старую версию под видом новой тоже не выйдет: номер версии внутри подписи.
- После обновления сервер проверяет, что подписка отвечает. Не отвечает – возвращает прежнюю
и пишет об этом в журнал `/var/log/kit-update.log`.
- Не любите сюрпризы? `kit update --manual`, и обновляться будете сами, командой `kit update`.
**Переезд без боли: `kit backup`**
- `kit backup` складывает в один файл всё, что нужно серверу: подключения, ключи, пользователей,
настройки подписки. `--restore` поднимает из него тот же сервер на новом VPS.
- Хостер поднял цены или IP попал в немилость? Переезжаете за 5 минут, а на домене клиенты
вообще ничего не заметят.
- Копия восстанавливается только из вашего файла и сначала проходит досмотр: лишние файлы
и хитрые пути внутри архива, и восстановление не начнётся.
**Заглушка с характером**
- На случайный заход сервер теперь показывает один из шести сайтов: кофейню, фотографа, блог
про Linux и других. Выбирается при установке, так что тысячи серверов 3X-UI KIT больше
не выглядят как близнецы. Всё лежит внутри скрипта, ничего не качаем со сторонних сайтов.
**Как обновиться**
- Сервер 3X-UI KIT из версии 1.0 (там ещё нет `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`
- Дальше хватит `kit update`, он сам подскажет, когда выйдет новая версия.
- Hysteria2: `hy2 update`.
- Дальше обновления будут приходить сами.
- Hysteria2: `hy2 update` (тоже проверяет подпись).
- Новые установки получают всё сразу.
## v1.0
+44 -18
View File
@@ -14,10 +14,18 @@
---
> [!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), настраивает все популярные
протоколы, сертификат и файрвол и выдаёт одну ссылку-подписку. Её можно вставить в любое
приложение — оно само получит подходящие ему протоколы. Домен не нужен.
приложение – оно само получит подходящие ему протоколы. Домен не нужен.
<div align="center">
<img alt="Конец установки 3X-UI KIT" src="manuals/assets/script-3x-ui.svg" width="760">
@@ -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
<details>
<summary>🔍 Хотите сначала посмотреть скрипт? Установка с проверкой</summary>
Скачайте скрипт конкретной версии, прочитайте его и сверьте 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
```
<details>
<summary>Проверка подписи</summary>
Открытый ключ автора записан в [`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» – файл подписан автором. Так же каждую ночь проверяет обновления ваш сервер.
</details>
Скрипт версии 1.1 скачивает свои части (`kit`, `kit-sub`) из того же релиза, а установщик
3X-UI сверяет по SHA256 и не запускает, если он изменился. Первый раз удобнее ставить на
свежий VPS без ваших данных.
</details>
Подробно — подключение приложений, дополнительные пользователи и параметры —
в **[инструкции](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
## Поддержать проект
Скрипты и инструкции бесплатные. Донат добровольный — он помогает оплачивать
Скрипты и инструкции бесплатные. Донат добровольный – он помогает оплачивать
тестовые серверы и держать скрипты в актуальном состоянии. Спасибо! 💜
| Способ | |
+7 -1
View File
@@ -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.
+66 -23
View File
@@ -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
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="330" viewBox="0 0 720 330" role="img" aria-label="Проводник · \\192.168.1.1\opkg\install"><title>Проводник · \\192.168.1.1\opkg\install</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="719" height="329" rx="14" fill="#0d1117" stroke="#30363d" stroke-width="1" /><path d="M1 44 H719" stroke="#30363d"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="360.0" y="27" class="s" font-size="13" fill="#8b949e" font-weight="500" text-anchor="middle">Проводник · \\192.168.1.1\opkg\install</text><text x="24" y="78" class="s" font-size="14" fill="#8b949e" font-weight="400" text-anchor="start">Флешка (ext4), открытая по сети: \\192.168.1.1\</text><rect x="30" y="92" width="330" height="32" rx="7" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><text x="40" y="113" class="m" font-size="14.5" fill="#e6edf3" font-weight="400" text-anchor="start">📁 install</text><text x="380" y="113" class="s" font-size="12.5" fill="#3fb950" font-weight="400" text-anchor="start">создайте папку</text><rect x="64" y="136" width="330" height="32" rx="7" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><text x="74" y="157" class="m" font-size="14.5" fill="#e6edf3" font-weight="400" text-anchor="start">📦 mipsel-installer.tar.gz</text><text x="414" y="157" class="s" font-size="12.5" fill="#3fb950" font-weight="400" text-anchor="start">установщик под ваш роутер</text><text x="24" y="206" class="s" font-size="13" fill="#e6edf3" font-weight="700" text-anchor="start">Какой установщик выбрать — по процессору роутера (есть в характеристиках модели):</text><rect x="24" y="218" width="90" height="24" rx="6" fill="#161b22" stroke="#30363d" stroke-width="1" /><text x="69" y="235" class="m" font-size="13" fill="#3fb950" font-weight="400" text-anchor="middle">mipsel</text><text x="128" y="235" class="s" font-size="13" fill="#8b949e" font-weight="400" text-anchor="start">чипы MediaTek MT7621 / MT7628 — большинство моделей</text><rect x="24" y="246" width="90" height="24" rx="6" fill="#161b22" stroke="#30363d" stroke-width="1" /><text x="69" y="263" class="m" font-size="13" fill="#3fb950" font-weight="400" text-anchor="middle">mips</text><text x="128" y="263" class="s" font-size="13" fill="#8b949e" font-weight="400" text-anchor="start">чипы EcoNet EN75xx</text><rect x="24" y="274" width="90" height="24" rx="6" fill="#161b22" stroke="#30363d" stroke-width="1" /><text x="69" y="291" class="m" font-size="13" fill="#3fb950" font-weight="400" text-anchor="middle">aarch64</text><text x="128" y="291" class="s" font-size="13" fill="#8b949e" font-weight="400" text-anchor="start">чипы MediaTek MT798x (Filogic)</text></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="330" viewBox="0 0 720 330" role="img" aria-label="Проводник · \\192.168.x.x\opkg\install"><title>Проводник · \\192.168.x.x\opkg\install</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="719" height="329" rx="14" fill="#0d1117" stroke="#30363d" stroke-width="1" /><path d="M1 44 H719" stroke="#30363d"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="360.0" y="27" class="s" font-size="13" fill="#8b949e" font-weight="500" text-anchor="middle">Проводник · \\192.168.x.x\opkg\install</text><text x="24" y="78" class="s" font-size="14" fill="#8b949e" font-weight="400" text-anchor="start">Флешка (ext4), открытая по сети: \\192.168.x.x\</text><rect x="30" y="92" width="330" height="32" rx="7" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><text x="40" y="113" class="m" font-size="14.5" fill="#e6edf3" font-weight="400" text-anchor="start">📁 install</text><text x="380" y="113" class="s" font-size="12.5" fill="#3fb950" font-weight="400" text-anchor="start">создайте папку</text><rect x="64" y="136" width="330" height="32" rx="7" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><text x="74" y="157" class="m" font-size="14.5" fill="#e6edf3" font-weight="400" text-anchor="start">📦 mipsel-installer.tar.gz</text><text x="414" y="157" class="s" font-size="12.5" fill="#3fb950" font-weight="400" text-anchor="start">установщик под ваш роутер</text><text x="24" y="206" class="s" font-size="13" fill="#e6edf3" font-weight="700" text-anchor="start">Какой установщик выбрать – по процессору роутера (есть в характеристиках модели):</text><rect x="24" y="218" width="90" height="24" rx="6" fill="#161b22" stroke="#30363d" stroke-width="1" /><text x="69" y="235" class="m" font-size="13" fill="#3fb950" font-weight="400" text-anchor="middle">mipsel</text><text x="128" y="235" class="s" font-size="13" fill="#8b949e" font-weight="400" text-anchor="start">чипы MediaTek MT7621 / MT7628 – большинство моделей</text><rect x="24" y="246" width="90" height="24" rx="6" fill="#161b22" stroke="#30363d" stroke-width="1" /><text x="69" y="263" class="m" font-size="13" fill="#3fb950" font-weight="400" text-anchor="middle">mips</text><text x="128" y="263" class="s" font-size="13" fill="#8b949e" font-weight="400" text-anchor="start">чипы EcoNet EN75xx</text><rect x="24" y="274" width="90" height="24" rx="6" fill="#161b22" stroke="#30363d" stroke-width="1" /><text x="69" y="291" class="m" font-size="13" fill="#3fb950" font-weight="400" text-anchor="middle">aarch64</text><text x="128" y="291" class="s" font-size="13" fill="#8b949e" font-weight="400" text-anchor="start">чипы MediaTek MT798x (Filogic)</text></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="230" viewBox="0 0 720 230" role="img" aria-label="Keenetic · Управление → OPKG"><title>Keenetic · Управление → OPKG</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="719" height="229" rx="14" fill="#0d1117" stroke="#30363d" stroke-width="1" /><path d="M1 44 H719" stroke="#30363d"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="360.0" y="27" class="s" font-size="13" fill="#8b949e" font-weight="500" text-anchor="middle">Keenetic · Управление → OPKG</text><rect x="12" y="62" width="696" height="60" rx="10" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><text x="178" y="89" class="s" font-size="14" fill="#e6edf3" font-weight="400" text-anchor="end">Накопитель</text><rect x="194" y="70" width="466" height="30" rx="7" fill="#161b22" stroke="#3fb950" stroke-width="1" /><text x="206" y="90" class="s" font-size="13.5" fill="#e6edf3" font-weight="400" text-anchor="start">OPKG (ваша флешка)</text><path d="M638 82 l5 5 l5 -5" fill="none" stroke="#8b949e" stroke-width="1.6"/><text x="194" y="114" class="s" font-size="12" fill="#3fb950" font-weight="400" text-anchor="start">выберите флешку с папкой install</text><circle cx="688" cy="85" r="11" fill="#3fb950"/><text x="688" y="89.5" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">1</text><text x="178" y="159" class="s" font-size="14" fill="#8b949e" font-weight="400" text-anchor="end">Сценарий initrc</text><rect x="194" y="140" width="466" height="30" rx="7" fill="#161b22" stroke="#30363d" stroke-width="1" /><text x="206" y="160" class="m" font-size="13.5" fill="#e6edf3" font-weight="400" text-anchor="start">/opt/etc/init.d/rc.unslung</text><text x="24" y="200" class="s" font-size="12" fill="#8b949e" font-weight="400" text-anchor="start">Нажмите «Сохранить» — установка Entware займёт несколько минут.</text></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="230" viewBox="0 0 720 230" role="img" aria-label="Keenetic · Управление → OPKG"><title>Keenetic · Управление → OPKG</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="719" height="229" rx="14" fill="#0d1117" stroke="#30363d" stroke-width="1" /><path d="M1 44 H719" stroke="#30363d"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="360.0" y="27" class="s" font-size="13" fill="#8b949e" font-weight="500" text-anchor="middle">Keenetic · Управление → OPKG</text><rect x="12" y="62" width="696" height="60" rx="10" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><text x="178" y="89" class="s" font-size="14" fill="#e6edf3" font-weight="400" text-anchor="end">Накопитель</text><rect x="194" y="70" width="466" height="30" rx="7" fill="#161b22" stroke="#3fb950" stroke-width="1" /><text x="206" y="90" class="s" font-size="13.5" fill="#e6edf3" font-weight="400" text-anchor="start">OPKG (ваша флешка)</text><path d="M638 82 l5 5 l5 -5" fill="none" stroke="#8b949e" stroke-width="1.6"/><text x="194" y="114" class="s" font-size="12" fill="#3fb950" font-weight="400" text-anchor="start">выберите флешку с папкой install</text><circle cx="688" cy="85" r="11" fill="#3fb950"/><text x="688" y="89.5" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">1</text><text x="178" y="159" class="s" font-size="14" fill="#8b949e" font-weight="400" text-anchor="end">Сценарий initrc</text><rect x="194" y="140" width="466" height="30" rx="7" fill="#161b22" stroke="#30363d" stroke-width="1" /><text x="206" y="160" class="m" font-size="13.5" fill="#e6edf3" font-weight="400" text-anchor="start">/opt/etc/init.d/rc.unslung</text><text x="24" y="200" class="s" font-size="12" fill="#8b949e" font-weight="400" text-anchor="start">Нажмите «Сохранить» – установка Entware займёт несколько минут.</text></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="368" viewBox="0 0 860 368" role="img" aria-label="root@vps: ~"><title>root@vps: ~</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="859" height="367" rx="14" fill="#0d1117" stroke="#30363d" stroke-width="1" /><path d="M1 44 H859" stroke="#30363d"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="430.0" y="27" class="m" font-size="13" fill="#8b949e" font-weight="500" text-anchor="middle">root@vps: ~</text><text x="22" y="70" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve"># </text><text x="38.260000000000005" y="70" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">bash &lt;(curl -fsSL https://raw.githubusercontent.com/…/scripts/hysteria2.sh)</text><text x="22" y="91" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve"></text><text x="22" y="112" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Установка Hysteria2</text><rect x="10" y="118" width="840" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="832" cy="128.5" r="11" fill="#3fb950"/><text x="832" y="133.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">1</text><text x="22" y="133" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Домен (Enter — без домена): </text><text x="249.64000000000001" y="133" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">hy.example.com</text><rect x="10" y="139" width="840" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="832" cy="149.5" r="11" fill="#3fb950"/><text x="832" y="154.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">1</text><text x="22" y="154" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Почта для Let's Encrypt: </text><text x="225.25000000000003" y="154" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">you@example.com</text><text x="22" y="175" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">==&gt; </text><text x="54.52" y="175" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Скачиваю Hysteria 2.12.3 (amd64) с GitHub</text><text x="22" y="196" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">==&gt; </text><text x="54.52" y="196" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Контрольная сумма совпала: 8c7a68a906998b74…</text><text x="22" y="217" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">==&gt; </text><text x="54.52" y="217" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Настраиваю ufw: SSH 22/tcp, Hysteria 443/udp, сайт 80/tcp и 443/tcp</text><text x="22" y="238" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">==&gt; </text><text x="54.52" y="238" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Запускаю Hysteria и получаю сертификат для hy.example.com</text><text x="22" y="259" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve"></text><text x="22" y="280" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">Готово! Hysteria2 работает.</text><text x="22" y="301" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve"></text><rect x="10" y="307" width="840" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="832" cy="317.5" r="11" fill="#3fb950"/><text x="832" y="322.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">2</text><text x="22" y="322" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">hy2://admin:9c1e…@hy.example.com:443/?sni=hy.example.com#admin</text><text x="22" y="343" class="m" font-size="13.5" fill="#8b949e" xml:space="preserve">[ здесь QR-код — отсканируйте его телефоном ]</text></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="860" height="368" viewBox="0 0 860 368" role="img" aria-label="root@vps: ~"><title>root@vps: ~</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="859" height="367" rx="14" fill="#0d1117" stroke="#30363d" stroke-width="1" /><path d="M1 44 H859" stroke="#30363d"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="430.0" y="27" class="m" font-size="13" fill="#8b949e" font-weight="500" text-anchor="middle">root@vps: ~</text><text x="22" y="70" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve"># </text><text x="38.260000000000005" y="70" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">bash &lt;(curl -fsSL https://raw.githubusercontent.com/…/scripts/hysteria2.sh)</text><text x="22" y="91" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve"></text><text x="22" y="112" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Установка Hysteria2</text><rect x="10" y="118" width="840" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="832" cy="128.5" r="11" fill="#3fb950"/><text x="832" y="133.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">1</text><text x="22" y="133" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Домен (Enter – без домена): </text><text x="249.64000000000001" y="133" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">hy.example.com</text><rect x="10" y="139" width="840" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="832" cy="149.5" r="11" fill="#3fb950"/><text x="832" y="154.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">1</text><text x="22" y="154" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Почта для Let's Encrypt: </text><text x="225.25000000000003" y="154" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">you@example.com</text><text x="22" y="175" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">==&gt; </text><text x="54.52" y="175" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Скачиваю Hysteria 2.12.3 (amd64) с GitHub</text><text x="22" y="196" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">==&gt; </text><text x="54.52" y="196" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Контрольная сумма совпала: 8c7a68a906998b74…</text><text x="22" y="217" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">==&gt; </text><text x="54.52" y="217" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Настраиваю ufw: SSH 22/tcp, Hysteria 443/udp, сайт 80/tcp и 443/tcp</text><text x="22" y="238" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">==&gt; </text><text x="54.52" y="238" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">Запускаю Hysteria и получаю сертификат для hy.example.com</text><text x="22" y="259" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve"></text><text x="22" y="280" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">Готово! Hysteria2 работает.</text><text x="22" y="301" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve"></text><rect x="10" y="307" width="840" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="832" cy="317.5" r="11" fill="#3fb950"/><text x="832" y="322.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">2</text><text x="22" y="322" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">hy2://admin:9c1e…@hy.example.com:443/?sni=hy.example.com#admin</text><text x="22" y="343" class="m" font-size="13.5" fill="#8b949e" xml:space="preserve">[ здесь QR-код – отсканируйте его телефоном ]</text></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="200" viewBox="0 0 720 200" role="img" aria-label="root@Keenetic: /opt/etc/xray/configs"><title>root@Keenetic: /opt/etc/xray/configs</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="719" height="199" rx="14" fill="#0d1117" stroke="#30363d" stroke-width="1" /><path d="M1 44 H719" stroke="#30363d"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="360.0" y="27" class="m" font-size="13" fill="#8b949e" font-weight="500" text-anchor="middle">root@Keenetic: /opt/etc/xray/configs</text><text x="22" y="70" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">~ # </text><text x="54.52" y="70" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">ls /opt/etc/xray/configs/</text><text x="22" y="91" class="m" font-size="13.5" fill="#8b949e" xml:space="preserve">03_inbounds.json # режим работы — оставьте как есть</text><rect x="10" y="97" width="700" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="692" cy="107.5" r="11" fill="#3fb950"/><text x="692" y="112.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">1</text><text x="22" y="112" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">04_outbounds.json </text><text x="184.60000000000002" y="112" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve"># подключение к вашему серверу</text><rect x="10" y="118" width="700" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="692" cy="128.5" r="11" fill="#3fb950"/><text x="692" y="133.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">2</text><text x="22" y="133" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">05_routing.json </text><text x="184.60000000000002" y="133" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve"># что пускать через прокси</text><text x="22" y="154" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve"></text><text x="22" y="175" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">~ # </text><text x="54.52" y="175" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">xkeen -restart</text></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="200" viewBox="0 0 720 200" role="img" aria-label="root@Keenetic: /opt/etc/xray/configs"><title>root@Keenetic: /opt/etc/xray/configs</title><style>.s{font-family:Inter, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif}.m{font-family:'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace}</style><rect x="0.5" y="0.5" width="719" height="199" rx="14" fill="#0d1117" stroke="#30363d" stroke-width="1" /><path d="M1 44 H719" stroke="#30363d"/><circle cx="22" cy="22" r="6" fill="#ff5f57"/><circle cx="40" cy="22" r="6" fill="#febc2e"/><circle cx="58" cy="22" r="6" fill="#28c840"/><text x="360.0" y="27" class="m" font-size="13" fill="#8b949e" font-weight="500" text-anchor="middle">root@Keenetic: /opt/etc/xray/configs</text><text x="22" y="70" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">~ # </text><text x="54.52" y="70" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">ls /opt/etc/xray/configs/</text><text x="22" y="91" class="m" font-size="13.5" fill="#8b949e" xml:space="preserve">03_inbounds.json # режим работы – оставьте как есть</text><rect x="10" y="97" width="700" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="692" cy="107.5" r="11" fill="#3fb950"/><text x="692" y="112.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">1</text><text x="22" y="112" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">04_outbounds.json </text><text x="184.60000000000002" y="112" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve"># подключение к вашему серверу</text><rect x="10" y="118" width="700" height="22" rx="5" fill="#3fb95022" stroke="#3fb950" stroke-width="1" /><circle cx="692" cy="128.5" r="11" fill="#3fb950"/><text x="692" y="133.0" class="s" font-size="12" fill="#0d1117" font-weight="700" text-anchor="middle">2</text><text x="22" y="133" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">05_routing.json </text><text x="184.60000000000002" y="133" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve"># что пускать через прокси</text><text x="22" y="154" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve"></text><text x="22" y="175" class="m" font-size="13.5" fill="#3fb950" xml:space="preserve">~ # </text><text x="54.52" y="175" class="m" font-size="13.5" fill="#e6edf3" xml:space="preserve">xkeen -restart</text></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+8 -8
View File
@@ -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`.
---
+11 -11
View File
@@ -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).
---
+370 -113
View File
@@ -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" </dev/null >/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" </dev/null >/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» с подпиской «<id>-awg»
# подключаем основную запись, а ко второму – запись-«двойник» «имя-awg» с подпиской «<id>-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'
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome</title><style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f7f9;color:#1f2328}main{text-align:center;padding:24px}h1{font-weight:600;font-size:28px}p{color:#57606a}</style></head><body><main><h1>Site is under construction</h1><p>Please check back soon.</p></main></body></html>
HTML
;;
2) cat <<'HTML'
<!DOCTYPE html><html><head><title>Welcome to nginx!</title><style>html{color-scheme:light dark}body{width:35em;margin:0 auto;font-family:Tahoma,Verdana,Arial,sans-serif}</style></head><body><h1>Welcome to nginx!</h1><p>If you see this page, the nginx web server is successfully installed and working. Further configuration is required.</p><p>For online documentation and support please refer to <a href="http://nginx.org/">nginx.org</a>.<br/>Commercial support is available at <a href="http://nginx.com/">nginx.com</a>.</p><p><em>Thank you for using nginx.</em></p></body></html>
HTML
;;
3) cat <<HTML
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lena Hart Photography</title><style>body{margin:0;font-family:Georgia,serif;background:#111;color:#eee}header{padding:48px 24px 16px;text-align:center}h1{font-weight:400;letter-spacing:.2em;text-transform:uppercase;font-size:22px}p{color:#aaa}.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;padding:24px}.g div{aspect-ratio:4/3;background:linear-gradient(135deg,#2b2b2b,#444)}footer{text-align:center;color:#666;padding:24px;font-size:13px}</style></head><body><header><h1>Lena Hart</h1><p>Landscape &amp; travel photography</p></header><section class="g"><div></div><div></div><div></div><div></div><div></div><div></div></section><footer>&copy; $year Lena Hart. New portfolio coming soon.</footer></body></html>
HTML
;;
4) cat <<HTML
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Notes</title><style>body{max-width:640px;margin:0 auto;padding:40px 20px;font:16px/1.6 ui-monospace,Menlo,Consolas,monospace;color:#222;background:#fdfdf8}h1{font-size:20px}a{color:#0645ad}li{margin:6px 0}small{color:#888}</style></head><body><h1>~/notes</h1><p>Small notes about Linux, networks and home servers.</p><ul><li><a href="#">Setting up a home NAS with ZFS</a> <small>$year-03-14</small></li><li><a href="#">Notes on systemd timers</a> <small>$year-02-02</small></li><li><a href="#">Why I moved my blog to a static site</a> <small>$year-01-09</small></li></ul><p><small>Comments are closed.</small></p></body></html>
HTML
;;
5) cat <<HTML
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Green Bean Coffee</title><style>body{margin:0;font-family:Helvetica,Arial,sans-serif;background:#f4efe6;color:#3b2f24;text-align:center}main{padding:72px 20px}h1{font-size:40px;margin:0 0 8px}p{font-size:18px}.b{display:inline-block;margin-top:16px;padding:10px 22px;border:2px solid #3b2f24;border-radius:24px}small{display:block;margin-top:48px;color:#8a7a68}</style></head><body><main><h1>Green Bean Coffee</h1><p>Specialty coffee &middot; fresh pastries &middot; open daily 8&ndash;18</p><span class="b">Online orders coming soon</span><small>&copy; $year Green Bean Coffee</small></main></body></html>
HTML
;;
6) cat <<HTML
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Status</title><style>body{margin:0;font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#fff;color:#24292f}main{max-width:560px;margin:64px auto;padding:0 20px}h1{font-size:22px}.r{display:flex;justify-content:space-between;padding:12px 0;border-bottom:1px solid #eaeef2}.ok{color:#1a7f37}small{color:#8c959f}</style></head><body><main><h1>System status</h1><div class="r"><span>Website</span><span class="ok">Operational</span></div><div class="r"><span>API</span><span class="ok">Operational</span></div><div class="r"><span>Storage</span><span class="ok">Operational</span></div><p><small>Updated automatically &middot; $year</small></p></main></body></html>
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'
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Welcome</title><style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f7f9;color:#1f2328}main{text-align:center;padding:24px}h1{font-weight:600;font-size:28px}p{color:#57606a}</style></head><body><main><h1>Site is under construction</h1><p>Please check back soon.</p></main></body></html>
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 <<NGX
# Сгенерировано 3x-ui.sh (3X-UI KIT) — перезаписывается при повторном запуске.
# Сгенерировано 3x-ui.sh (3X-UI KIT) – перезаписывается при повторном запуске.
server {
listen 127.0.0.1:${INNER[web]} ssl http2 proxy_protocol;
server_name _;
@@ -873,10 +934,10 @@ $locs
}
NGX
grep -q 'kit-stream.conf' /etc/nginx/nginx.conf || echo 'include /etc/nginx/kit-stream.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"(?<![0-9.])" + re.escape(old) + r"(?![0-9])")
sub = lambda v: rx.sub(new, v) if isinstance(v, str) else v
db = sqlite3.connect("/etc/x-ui/x-ui.db")
db.create_function("kit_sub", 1, sub)
for table, cols in (("inbounds", ("settings", "stream_settings")), ("settings", ("value",))):
for c in cols:
db.execute("UPDATE %s SET %s = kit_sub(%s)" % (table, c, c))
db.commit(); db.close()
for f in files:
s = open(f).read()
open(f, "w").write(rx.sub(new, s))
PY
}
restore_main() { # файл
local file=$1 tmp old_ip=""
[[ -f $file ]] || die "Нет файла $file. Сначала скопируйте копию на этот сервер: scp kit-backup-….tar.gz root@IP:"
[[ -d /usr/local/x-ui ]] && die "Восстанавливать можно только на чистый сервер, а здесь уже стоит 3X-UI."
port_busy 443 tcp && die "Порт 443/tcp занят. Восстанавливать нужно на чистый VPS."
say "Ставлю пакеты: curl, jq, openssl, qrencode, ufw, python3"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl jq openssl qrencode ca-certificates iproute2 ufw socat cron python3 python3-yaml >/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 <<EOF
3X-UI со всеми протоколами одной командой
--protocols all all (по умолчанию — всё, кроме WireGuard), minimal (только REALITY)
--protocols all all (по умолчанию – всё, кроме WireGuard), minimal (только REALITY)
или список через запятую: reality,hy2,xhttp,ws,trojan,vmess,ss,tuic,wg,awg,awg3,mtproto
(обычный WireGuard в России блокируется — включайте его, только если сервер и
(обычный WireGuard в России блокируется – включайте его, только если сервер и
пользователи за границей)
--port 443 порт REALITY (TCP) и Hysteria2 (UDP), по умолчанию 443
--sni сайт сайт для маскировки (по умолчанию подбирается сам)
--panel-ssl ip|none сертификат панели: ip — Let's Encrypt на IP (нужен порт 80),
none — панель только через SSH-туннель (по умолчанию выбирается сам)
--panel-ssl ip|none сертификат панели: ip – Let's Encrypt на IP (нужен порт 80),
none – панель только через SSH-туннель (по умолчанию выбирается сам)
--cert файл --key файл свой сертификат (например, для домена) вместо Let's Encrypt на IP;
тогда --host — это домен из сертификата
тогда --host – это домен из сертификата
--user admin имя первого клиента
--host 1.2.3.4 адрес в ссылке, если IP определился неверно
--no-ufw не трогать файрвол
--restore файл поднять сервер из резервной копии kit backup (на чистом VPS)
-y не задавать вопросов
EOF
}
+49 -27
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Hysteria2 одной командой — https://github.com/itsnotkubrick/3X-UI_KIT
# Hysteria2 одной командой – https://github.com/itsnotkubrick/3X-UI_KIT
#
# Установка: bash <(curl -fsSL https://raw.githubusercontent.com/itsnotkubrick/3X-UI_KIT/main/scripts/hysteria2.sh)
# Управление: hy2 help
@@ -43,11 +43,11 @@ say() { printf '%s\n' "${G}==>${N} $*"; }
warn() { printf '%s\n' "${Y}!${N} $*" >&2; }
die() { printf '%s\n' "${R}✗${N} $*" >&2; exit 1; }
trap 'die "Ошибка в строке $LINENO. Если это установка — исправьте причину и запустите скрипт ещё раз."' ERR
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 <<EOF
${B}hy2${N} — управление Hysteria2
${B}hy2${N} – управление Hysteria2
hy2 add имя добавить пользователя и показать его ссылку
hy2 del имя удалить пользователя
+15 -15
View File
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
"""Подписка с учётом приложения — посредник перед подпиской 3X-UI.
"""Подписка с учётом приложения – посредник перед подпиской 3X-UI.
https://github.com/itsnotkubrick/3X-UI_KIT
Слушает публичный адрес подписки (HTTPS) и ходит в подписку 3X-UI на 127.0.0.1:
* Clash / Mihomo (Clash Verge, FlClash, Mihomo Party…) — конфиг 3X-UI плюс AmneziaWG
* Clash / Mihomo (Clash Verge, FlClash, Mihomo Party…) – конфиг 3X-UI плюс AmneziaWG
из подписки «<id>-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 в подписке «<id>-awg» — подмешиваем её.
# Установки до kit 1.1 держали AmneziaWG в подписке «<id>-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:
+199 -29
View File
@@ -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» с подпиской «<id>-awg»
# подключаем основную запись, а ко второму – запись-«двойник» «имя-awg» с подпиской «<id>-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 <<UNIT
[Unit]
Description=3X-UI KIT: автообновление kit и kit-sub (только подписанные релизы)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/kit update --unattended
StandardOutput=append:$KIT_UPDATE_LOG
StandardError=append:$KIT_UPDATE_LOG
UNIT
cat >/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 <<EOF
${B}kit${N} $KIT_VERSION – управление сервером 3X-UI KIT
@@ -376,7 +543,9 @@ ${B}kit${N} $KIT_VERSION – управление сервером 3X-UI KIT
kit user del имя удалить
Сервер:
kit update обновить kit и подписку kit-sub (пользователи и ссылки не меняются)
kit update обновить kit и подписку kit-sub сейчас (пользователи и ссылки не меняются)
kit update --manual выключить автообновление (--auto – включить обратно)
kit backup резервная копия для переезда на другой сервер
kit version версия kit, панели и ядра
EOF
}
@@ -389,7 +558,8 @@ case "${1:-} ${2:-}" in
"user off") cmd_toggle "${3:-}" false ;;
"user on") cmd_toggle "${3:-}" true ;;
"user del") shift 2; cmd_del "$@" ;;
"update "*) cmd_update "${2:-}" ;;
"update "*) shift; cmd_update "$@" ;;
"backup "*) cmd_backup ;;
"version "*|"--version "*|"-v "*) cmd_version ;;
*) usage; update_hint ;;
esac
+13 -13
View File
@@ -3,7 +3,7 @@
Проверяет настоящим подключением, что каждый протокол 3X-UI работает с каждым клиентом:
Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. Прогонять на каждой новой версии 3X-UI и Xray.
1. Сервер: чистый Ubuntu в Docker с systemd, на нём `scripts/3x-ui.sh`, затем `mk-inbounds.sh` —
1. Сервер: чистый Ubuntu в Docker с systemd, на нём `scripts/3x-ui.sh`, затем `mk-inbounds.sh` –
создаёт через API подключения VLESS XHTTP, WS+TLS, Trojan gRPC, VMess, Shadowsocks 2022,
Hysteria2, TUIC, WireGuard, AmneziaWG и MTProto.
2. Ссылки: `GET /panel/api/inbounds/allLinks` → `links.txt`.
@@ -22,29 +22,29 @@ Xray, Mihomo, sing-box и официальным клиентом AmneziaWG. П
|---|---|---|---|
| VLESS REALITY | ✓ | ✓ | ✓ |
| Hysteria2 | ✓ | ✓ | ✓ |
| VLESS XHTTP + REALITY | ✓ | ✓ | — |
| VLESS XHTTP + REALITY | ✓ | ✓ | – |
| VLESS WS + TLS | ✓ | ✓ | ✓ |
| Trojan gRPC + TLS | ✓ | ✓ | ✓ |
| VMess WS + TLS | ✓ | ✓ | ✓ |
| Shadowsocks 2022 | ✓ | ✓ | ✓ |
| TUIC v5 | — | ✓ | ✓ |
| WireGuard | ✓ | ✓ | ✓ | (локально; через DPI в России — ✗ у всех клиентов) |
| AmneziaWG (классика) | — | ✓ | — |
| AmneziaWG 3.1 | — | ✓ | — |
| TUIC v5 | – | ✓ | ✓ |
| WireGuard | ✓ | ✓ | ✓ | (локально; через DPI в России – ✗ у всех клиентов) |
| AmneziaWG (классика) | – | ✓ | – |
| AmneziaWG 3.1 | – | ✓ | – |
Подписка в формате Clash (User-Agent Clash Verge) — все 11 прокси ✓ в Mihomo.
Подписка в формате Clash (User-Agent Clash Verge) – все 11 прокси ✓ в Mihomo.
Официальный клиент AmneziaWG 3.1 подключается к обоим вариантам AmneziaWG.
MTProto: `mtg doctor` — все дата-центры Telegram доступны.
Со своим (самоподписанным) сертификатом подписка Clash от 3X-UI не передаёт Mihomo отпечаток —
MTProto: `mtg doctor` – все дата-центры Telegram доступны.
Со своим (самоподписанным) сертификатом подписка Clash от 3X-UI не передаёт Mihomo отпечаток –
TLS-протоколы в ней не работают; ссылки с `pcs` работают.
## Через интернет к серверу в России, 2026-09-27
Сервер Ubuntu 24.04 в России, `scripts/3x-ui.sh` с настоящим сертификатом Let's Encrypt на IP,
клиенты — на Mac через интернет. Подписка-ссылки и подписка Clash: все протоколы ✓, кроме:
клиенты – на Mac через интернет. Подписка-ссылки и подписка Clash: все протоколы ✓, кроме:
- **WireGuard** — ✗ у Xray, Mihomo и sing-box: рукопожатие доходит до сервера, ответ режет DPI.
- **WireGuard** – ✗ у Xray, Mihomo и sing-box: рукопожатие доходит до сервера, ответ режет DPI.
Поэтому из набора по умолчанию убран.
- **MTProto** — сервер хостера не достаёт до Telegram, прокси бесполезен; установщик теперь
- **MTProto** – сервер хостера не достаёт до Telegram, прокси бесполезен; установщик теперь
проверяет это и пропускает MTProto.
- AmneziaWG (оба варианта) — ✓ в Mihomo и в официальном клиенте AmneziaWG.
- AmneziaWG (оба варианта) – ✓ в Mihomo и в официальном клиенте AmneziaWG.
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# awg-set.sh classic|full — выставляет обфускацию AmneziaWG на подключении и печатает ссылку.
# awg-set.sh classic|full – выставляет обфускацию AmneziaWG на подключении и печатает ссылку.
. /etc/x-ui/install-result.env
API="http://127.0.0.1:$XUI_PANEL_PORT/$XUI_WEB_BASE_PATH/panel/api"; H=(-H "Authorization: Bearer $XUI_API_TOKEN" -H 'Content-Type: application/json')
ID=$(curl -fsS "${H[@]}" "$API/inbounds/list" | jq -r '.obj[] | select(.protocol=="amneziawg") | .id')
+1 -1
View File
@@ -7,7 +7,7 @@ const { proxies } = L.parseText(fs.readFileSync(linksFile, 'utf8'));
fs.rmSync(out, { recursive: true, force: true });
const rows = [];
proxies.filter((p) => 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;
+2 -2
View File
@@ -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)
+6 -6
View File
@@ -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;
+6 -5
View File
@@ -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 };
}
+3 -3
View File
@@ -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 = [];
+7 -7
View File
@@ -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;
+10 -10
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Генератор Mihomo для XKeen</title>
<meta name="description" content="Готовый config.yaml Mihomo для XKeen из ссылок vless, hy2, trojan, ss или подписки — прямо в браузере">
<meta name="description" content="Готовый config.yaml Mihomo для XKeen из ссылок vless, hy2, trojan, ss или подписки – прямо в браузере">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🕶️</text></svg>">
<link rel="stylesheet" href="../lib/ui.css">
</head>
@@ -16,9 +16,9 @@
<main>
<h1>Конфиг Mihomo для XKeen</h1>
<p class="lead">Ссылки на серверы или подписка — и готовый <code>config.yaml</code> с правилами для нужных сервисов,
<p class="lead">Ссылки на серверы или подписка – и готовый <code>config.yaml</code> с правилами для нужных сервисов,
автовыбором быстрого сервера и веб-панелью. Плюс команда, которая сама положит его на роутер.</p>
<div class="privacy">🔒 <span>Всё считается <b>в вашем браузере</b> — ссылки никуда не отправляются</span></div>
<div class="privacy">🔒 <span>Всё считается <b>в вашем браузере</b> – ссылки никуда не отправляются</span></div>
<section class="step">
<h2><span class="num">1</span>Серверы</h2>
@@ -36,10 +36,10 @@
<div class="grid" id="services"></div>
<div class="sub">Дополнительно</div>
<div class="grid">
<label class="opt"><input type="checkbox" id="perService"><span>Свой выбор сервера для каждого сервиса<small>в панели можно пустить YouTube через один сервер, а Telegram — через другой</small></span></label>
<label class="opt"><input type="checkbox" id="perService"><span>Свой выбор сервера для каждого сервиса<small>в панели можно пустить YouTube через один сервер, а Telegram – через другой</small></span></label>
<label class="opt"><input type="checkbox" id="blockQuic" checked><span>Блокировать QUIC<small>видео через прокси грузится стабильнее</small></span></label>
<label class="opt"><input type="checkbox" id="blockAds"><span>Блокировать рекламу</span></label>
<label class="opt"><input type="checkbox" id="finalProxy"><span>Всё остальное — тоже через прокси<small>иначе остальное идёт напрямую</small></span></label>
<label class="opt"><input type="checkbox" id="finalProxy"><span>Всё остальное – тоже через прокси<small>иначе остальное идёт напрямую</small></span></label>
</div>
<div class="two">
<div><label class="field" for="proxyDomains">Свои сайты через прокси</label>
@@ -51,7 +51,7 @@
<section class="step">
<h2><span class="num">3</span>Готовый конфиг</h2>
<div id="result"><div class="empty">Добавьте ссылку или подписку в шаг 1 — здесь появится конфиг.</div></div>
<div id="result"><div class="empty">Добавьте ссылку или подписку в шаг 1 – здесь появится конфиг.</div></div>
</section>
</main>
<footer><a href="https://github.com/itsnotkubrick/3X-UI_KIT/tree/main/tools">Исходный код на GitHub</a> · Проверено на Mihomo 1.19 и XKeen 2.0</footer>
@@ -101,7 +101,7 @@
if ($('#sub').value.trim() && !o.subscription) err = 'Подписка должна начинаться с http:// или https://';
if (!r) {
yaml = '';
$('#result').innerHTML = err ? '<div class="msg err">' + esc(err) + '</div>' : '<div class="empty">Добавьте ссылку или подписку в шаг 1 — здесь появится конфиг.</div>';
$('#result').innerHTML = err ? '<div class="msg err">' + esc(err) + '</div>' : '<div class="empty">Добавьте ссылку или подписку в шаг 1 – здесь появится конфиг.</div>';
return;
}
yaml = r.yaml;
@@ -110,10 +110,10 @@
'<div class="tabs" id="tabs"><button data-tab="cmd" class="on">Команда для роутера</button><button data-tab="yaml">config.yaml</button></div>' +
'<div class="out"><pre class="code" id="out"></pre></div>' +
'<div class="actions"><button class="btn" id="cp">Скопировать</button><button class="btn ghost" id="dl">Скачать config.yaml</button></div>' +
'<ol class="how"><li>Подключитесь к роутеру: <code>ssh root@192.168.1.1 -p 222</code></li>' +
'<ol class="how"><li>Подключитесь к роутеру: <code>ssh root@192.168.x.x -p 222</code></li>' +
'<li>Если XKeen сейчас работает на Xray, сначала выполните <code>xkeen -mihomo</code></li>' +
'<li>Вставьте команду целиком и нажмите Enter — конфиг запишется, XKeen перезапустится.</li>' +
'<li>Панель управления: <code>http://192.168.1.1:9090/ui</code>, секрет: <code id="sec"></code></li></ol>';
'<li>Вставьте команду целиком и нажмите Enter – конфиг запишется, XKeen перезапустится.</li>' +
'<li>Панель управления: <code>http://192.168.x.x:9090/ui</code>, секрет: <code id="sec"></code></li></ol>';
UI.tabs($('#tabs'), (t) => { tab = t; show(); });
$('#cp').onclick = (e) => UI.copy($('#out').textContent, e.target);
$('#dl').onclick = () => UI.download('config.yaml', yaml);
+62
View File
@@ -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:]' <VERSION)
[[ $v =~ ^[0-9]+(\.[0-9]+)+$ ]] || die "VERSION: $v"
for f in scripts/3x-ui.sh scripts/hysteria2.sh scripts/kit.sh; do
grep -qx "KIT_VERSION=\"$v\"" "$f" || die "$f: KIT_VERSION не $v"
done
grep -q "^## v$v" CHANGELOG.md || die "В CHANGELOG.md нет раздела ## v$v"
ok "Версия $v во всех скриптах и в CHANGELOG"
[[ -z $(git status --porcelain -- "${FILES[@]}" CHANGELOG.md) ]] || die "Есть незакоммиченные изменения в файлах релиза – сначала коммит."
# Установщик 3X-UI: хеш в скрипте должен совпадать с тем, что сейчас лежит на GitHub.
xv=$(sed -n 's/^XUI_VERSION="\(.*\)"/\1/p' scripts/3x-ui.sh)
xs=$(sed -n 's/^XUI_INSTALL_SHA256="\(.*\)"/\1/p' scripts/3x-ui.sh)
got=$(curl -fsSL "https://raw.githubusercontent.com/MHSanaei/3x-ui/$xv/install.sh" | sha | awk '{print $1}')
[[ $got == "$xs" ]] || die "Установщик 3X-UI $xv изменился: $got вместо $xs. Проверьте, что поменялось, прежде чем выпускать."
ok "Установщик 3X-UI $xv совпадает с закреплённым SHA256"
# Открытые ключи из kit и hy2 – одинаковые, и среди них есть ключ, которым подписываем.
signers() { awk '/^KIT_SIGNERS=\(/ {on = 1; next} on && /^\)/ {exit} on && /^ *"/ {gsub(/^ *"|"$/, ""); print}' "$1"; }
[[ -n $(signers scripts/kit.sh) ]] || die "В scripts/kit.sh пустой KIT_SIGNERS: впишите туда открытую часть ключа ($key.pub)."
[[ $(signers scripts/kit.sh) == "$(signers scripts/hysteria2.sh)" ]] || die "KIT_SIGNERS в kit.sh и hysteria2.sh различаются."
pub=$(ssh-keygen -y -f "$key" | awk '{print $1, $2}')
signers scripts/kit.sh | awk '{print $1, $2}' | grep -qxF "$pub" || die "Ключа $key нет в KIT_SIGNERS – серверы не примут такую подпись."
ok "Ключ подписи записан в kit и hy2"
{ echo "# 3X-UI KIT $v"; sha "${FILES[@]}"; } >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 <SHA256SUMS >/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"
+2 -2
View File
@@ -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 = <r 89>\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'];
+13 -13
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Генератор Xray для XKeen</title>
<meta name="description" content="Готовые 04_outbounds.json и 05_routing.json для XKeen из ссылки vless://, vmess://, trojan://, ss:// — прямо в браузере">
<meta name="description" content="Готовые 04_outbounds.json и 05_routing.json для XKeen из ссылки vless://, vmess://, trojan://, ss:// – прямо в браузере">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🕶️</text></svg>">
<link rel="stylesheet" href="../lib/ui.css">
</head>
@@ -16,30 +16,30 @@
<main>
<h1>Конфиг Xray для XKeen</h1>
<p class="lead">Вставьте ссылку на свой сервер — получите готовые <code>04_outbounds.json</code> и <code>05_routing.json</code>
<p class="lead">Вставьте ссылку на свой сервер – получите готовые <code>04_outbounds.json</code> и <code>05_routing.json</code>
и одну команду, которая сама положит их на роутер.</p>
<div class="privacy">🔒 <span>Всё считается <b>в вашем браузере</b> — ссылки никуда не отправляются</span></div>
<div class="privacy">🔒 <span>Всё считается <b>в вашем браузере</b> – ссылки никуда не отправляются</span></div>
<section class="step">
<h2><span class="num">1</span>Ссылки на серверы</h2>
<textarea id="links" class="mono" spellcheck="false" placeholder="vless://…&#10;По одной на строку. Можно вставить подписку в base64 целиком."></textarea>
<div class="parsed" id="parsed"></div>
<p class="hint">Поддерживаются vless (REALITY, TLS, ws, gRPC, xhttp, httpupgrade), vmess, trojan, shadowsocks, Hysteria2 и WireGuard.
Несколько серверов — Xray сам выберет самый быстрый. TUIC и AmneziaWG работают через <a href="../mihomo/">Mihomo</a>.</p>
Несколько серверов – Xray сам выберет самый быстрый. TUIC и AmneziaWG работают через <a href="../mihomo/">Mihomo</a>.</p>
</section>
<section class="step">
<h2><span class="num">2</span>Что пускать через прокси</h2>
<div class="sub">Геобазы на роутере</div>
<div class="grid" id="bases"></div>
<p class="hint">Какие базы вы выбрали при установке XKeen. Не уверены — оставьте ZKeen: её XKeen ставит по умолчанию.</p>
<p class="hint">Какие базы вы выбрали при установке XKeen. Не уверены – оставьте ZKeen: её XKeen ставит по умолчанию.</p>
<div class="sub">Сервисы</div>
<div class="grid" id="services"></div>
<div class="sub">Дополнительно</div>
<div class="grid">
<label class="opt"><input type="checkbox" id="blockQuic" checked><span>Блокировать QUIC<small>видео через прокси грузится стабильнее</small></span></label>
<label class="opt" id="adsOpt"><input type="checkbox" id="blockAds"><span>Блокировать рекламу<small>нужна база V2Fly</small></span></label>
<label class="opt"><input type="checkbox" id="finalProxy"><span>Всё остальное — тоже через прокси<small>иначе остальное идёт напрямую</small></span></label>
<label class="opt"><input type="checkbox" id="finalProxy"><span>Всё остальное – тоже через прокси<small>иначе остальное идёт напрямую</small></span></label>
</div>
<div class="two">
<div><label class="field" for="proxyDomains">Свои сайты через прокси</label>
@@ -49,12 +49,12 @@
</div>
<label class="field" for="tag">Тег основного сервера</label>
<input type="text" id="tag" value="vless-reality" spellcheck="false">
<p class="hint">XKeen по умолчанию ожидает <code>vless-reality</code> — менять стоит, только если вы правили правила вручную.</p>
<p class="hint">XKeen по умолчанию ожидает <code>vless-reality</code> – менять стоит, только если вы правили правила вручную.</p>
</section>
<section class="step">
<h2><span class="num">3</span>Готовый конфиг</h2>
<div id="result"><div class="empty">Вставьте ссылку в шаг 1 — здесь появятся файлы.</div></div>
<div id="result"><div class="empty">Вставьте ссылку в шаг 1 – здесь появятся файлы.</div></div>
</section>
</main>
<footer><a href="https://github.com/itsnotkubrick/3X-UI_KIT/tree/main/tools">Исходный код на GitHub</a> · Проверено на Xray 26 и XKeen 2.0</footer>
@@ -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 ? '<div class="msg err">' + esc(err) + '</div>' : '<div class="empty">Вставьте ссылку в шаг 1 — здесь появятся файлы.</div>';
$('#result').innerHTML = err ? '<div class="msg err">' + esc(err) + '</div>' : '<div class="empty">Вставьте ссылку в шаг 1 – здесь появятся файлы.</div>';
return;
}
files = {};
@@ -143,8 +143,8 @@
'<button data-tab="04_outbounds.json">04_outbounds.json</button><button data-tab="05_routing.json">05_routing.json</button></div>' +
'<div class="out"><pre class="code" id="out"></pre></div>' +
'<div class="actions"><button class="btn" id="cp">Скопировать</button><button class="btn ghost" id="dl">Скачать файл</button></div>' +
'<ol class="how" id="how"><li>Подключитесь к роутеру: <code>ssh root@192.168.1.1 -p 222</code></li>' +
'<li>Вставьте команду целиком и нажмите Enter — файлы запишутся, XKeen перезапустится.</li>' +
'<ol class="how" id="how"><li>Подключитесь к роутеру: <code>ssh root@192.168.x.x -p 222</code></li>' +
'<li>Вставьте команду целиком и нажмите Enter – файлы запишутся, XKeen перезапустится.</li>' +
'<li>Готово. Проверить: <code>xkeen -status</code></li></ol><div id="note"></div>';
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) => '<div class="msg warn">' + esc(n) + '</div>').join('');
}