From f6514e9bba81f64a53a21b60954a1a9c91929088 Mon Sep 17 00:00:00 2001 From: andrey271192 Date: Tue, 12 May 2026 06:28:56 +0300 Subject: [PATCH] feat: one-command install/uninstall + fix delete-ams in WebUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh: интерактивный one-liner — ставит WG, ключи, скрипты, бот, WebUI, cron; сам поднимает RU-сервер если он чистый - uninstall.sh: полная очистка (сервисы, конфиги, cron, /opt/kaskad) с подтверждением; опц. KASKAD_PURGE_KEYS=1 для затирания ключей - webui: кнопка «удалить» для зарубежных серверов теперь явно красная (class danger.solid), для локального — «⛔ нельзя» вместо disabled - webui: DELETE /api/ams/ переписан — портативный python-based peer-removal, явные ошибки, поддержка ?force=1 - README + docs/install.md: переписаны под обычного пользователя, one-command flow на первом плане, ручной — как fallback Co-authored-by: Cursor --- README.md | 214 +++++++++++++++++++-------- docs/install.md | 221 +++++++++++++++------------ install.sh | 295 +++++++++++++++++++++++++++++++++++++ uninstall.sh | 112 ++++++++++++++ webui/app.py | 73 +++++---- webui/static/style.css | 11 ++ webui/templates/index.html | 27 +++- 7 files changed, 768 insertions(+), 185 deletions(-) create mode 100755 install.sh create mode 100755 uninstall.sh diff --git a/README.md b/README.md index 0b47b26..deb317a 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,200 @@ # Kaskad -Каскадная маршрутизация русских сайтов через свой набор RU-серверов (WireGuard) с автоматическим failover, Telegram-ботом и веб-интерфейсом. +**Свой VPN для россиян за границей.** Российские сайты (госуслуги, банки, ВК) открываются через ваш сервер в РФ. Всё остальное — напрямую через зарубежный. Если RU-сервер падает — за минуту переключается на запасной. Управляется из браузера или Telegram. -## Что это +![Каскад работает](https://img.shields.io/badge/status-в%20продакшне-success) ![License](https://img.shields.io/github/license/andrey271192/kaskad) ![Stars](https://img.shields.io/github/stars/andrey271192/kaskad?style=social) -Несколько зарубежных серверов с X-ray («ам.») держат WireGuard-туннели к одному из RU-серверов. Российские IP/домены идут через RU, всё остальное — через зарубежный. Если RU упал — за минуту переключение на следующий по приоритету. Когда восстановился — пробный failback с автоматическим откатом если WG не отвечает. +--- + +## Зачем это нужно + +Живёте за границей и сталкивались с тем, что: + +- 🏦 Тинькофф не пускает с заграничного IP +- 🧾 Госуслуги «вы не из России» +- 📺 Кинопоиск, ivi, ВК — недоступны +- 🛍 Озон, Wildberries — корзина не работает + +При этом обычный VPN в РФ **тормозит всё подряд**, включая Netflix, рабочие звонки и игры. + +**Каскад** делит трафик по умному: ``` -телефон/клиент ──> ам. сервер (X-ray) ──┬──> RU primary ──> RU-сайты - ├──> RU backup (failover) - └──> RU N (по приоритету) - └──> остальное идёт напрямую через ам. + ┌──────────────────────────────────────────┐ +вы ───▶ │ зарубежный сервер (Амстердам, X-ray) │ + │ │ + │ если адрес РФ → 🇷🇺 RU-сервер → инет │ + │ всё остальное → 🌍 напрямую │ + └──────────────────────────────────────────┘ ``` -## Возможности +Vk.com видит ваш московский IP. Netflix видит амстердамский. Скорость не страдает. -- **N RU-серверов** в конфиге, упорядочены по приоритету -- **Авто-failover** по handshake age + TCP-проба; cooldown между переключениями; автоматический failback с проверкой -- **Telegram-бот** для всех операций: смотреть статус, переключать вручную, добавлять/удалять серверы, домены, IP -- **Веб-интерфейс** с тем же функционалом -- **Маршруты по доменам**: бот резолвит `vk.com → IP`, добавляет в маршруты на всех ам. серверах. Cron каждые 6ч переподнимает резолв -- **Доп. подсети/IP** добавляются на лету через бот или WebUI; переживают рестарт туннеля и сервера -- **Уведомления в TG** при каждом failover/failback +--- -## Структура репозитория +## Что умеет -``` -kaskad/ -├── bin/ # скрипты для ам. серверов (failover, switch, routes, domains) -├── bot/ # Telegram-бот + systemd unit -├── webui/ # Flask-приложение -├── examples/ # шаблоны конфигов -├── docs/ # подробная документация -└── README.md +- ⚡ **Авто-failover** — если основной RU-сервер упал, за минуту переключение на запасной. Когда вернулся — автоматически обратно. +- 🎛 **WebUI** — добавлять серверы, домены, IP в один клик. Никакого SSH. +- 🤖 **Telegram-бот** — то же самое из телефона. +- 🌐 **N RU-серверов** в каскаде с приоритетами. +- 🌍 **N зарубежных нод** под одним каскадом — все автоматически переключаются вместе. +- 🔔 **Уведомления в Telegram** при каждом переключении. +- 📋 **Маршруты по доменам** — ввёл `vk.com`, бот сам резолвит IP и держит их в маршрутах. + +--- + +## Установка в одну команду + +На зарубежном сервере (Ubuntu 20.04+) с уже работающим X-ray: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad/main/install.sh | sudo bash ``` -## Быстрый старт +Скрипт спросит: -См. [docs/install.md](docs/install.md) для полной установки. +1. Telegram bot token (от [@BotFather](https://t.me/BotFather)) +2. Telegram chat ID (узнать у [@userinfobot](https://t.me/userinfobot)) +3. Хост вашего RU-сервера + пароль root (пароль нужен один раз) +4. Логин/пароль для WebUI -Краткая последовательность: +Через 2-3 минуты получите: -1. **Настроить первый RU-сервер** вручную (см. `docs/install.md` § «RU-сервер») -2. **Настроить первый ам. сервер** (см. § «Ам. сервер») -3. **Создать `/etc/wireguard/ru-servers.json`** по шаблону `examples/ru-servers.example.json` -4. **Запустить TG-бот** на одном из ам. серверов (`bot/ru-tg-bot.service`) -5. **(опц.) Запустить WebUI** на том же ам. сервере (`webui/ru-webui.service`) -6. Дальше — добавлять RU/ам./домены/IP через бот или WebUI +- 🌐 WebUI на `http://ВАШ_СЕРВЕР:8088` +- 🤖 Активного Telegram-бота — `/status` покажет состояние +- 🔁 Авто-failover каждую минуту через cron -## Команды бота +Готово. Никаких ручных правок конфигов. + +> 💡 **Если RU-сервер ещё чистый** — скрипт сам поставит на него WireGuard, ip_forward, iptables и поднимет туннель. Достаточно SSH-пароля. + +--- + +## Удаление в одну команду + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad/main/uninstall.sh | sudo bash +``` + +или, если уже склонировано: + +```bash +sudo bash /opt/kaskad/uninstall.sh +``` + +Спросит подтверждение (`YES`), затем уберёт: + +- сервисы, скрипты, cron, WG-туннели, конфиги, `/opt/kaskad/` + +X-ray, ваши SSH-ключи и системные пакеты **не трогает**. Если хотите снести и WG-ключи — `KASKAD_PURGE_KEYS=1 sudo bash uninstall.sh`. + +--- + +## WebUI + +После установки откройте `http://ВАШ_СЕРВЕР:8088`. + +| Что | Как | +|---|---| +| Переключиться на запасной RU | кнопка `backup` в верхней таблице | +| Добавить ещё один RU-сервер | секция «RU-серверы» → «+ добавить RU-сервер», ввести IP/домен и пароль root | +| Добавить ещё одну зарубежную ноду | секция «Ам. серверы» → «+ добавить ам. сервер» | +| **Удалить зарубежный сервер** | красная кнопка `удалить` в строке — peer уберётся со всех RU и из конфига | +| Добавить русский сайт | секция «Домены» → ввести `vk.com ozon.ru` | +| Принудительный refresh DNS для доменов | кнопка `🔄 Refresh DNS` | + +Все действия идут с подтверждением, ошибки видны сразу. + +--- + +## Telegram-бот | Категория | Команды | |---|---| | Туннели | `/status`, `/use `, `/primary`, `/backup` | | RU-серверы | `/server-list`, `/server-add`, `/server-remove`, `/bot-key` | | Ам. серверы | `/ams-list`, `/ams-add`, `/ams-remove` | -| Маршруты | `/ips`, `/list`, `/add `, `/remove `, `/clear` | -| Домены | `/list-domains`, `/add-domain `, `/remove-domain `, `/show-domain `, `/refresh-domains` | +| Маршруты | `/ips`, `/list`, `/add `, `/remove `, `/clear` | +| Домены | `/list-domains`, `/add-domain `, `/remove-domain `, `/show-domain `, `/refresh-domains` | | Прочее | `/help` | -Полный синтаксис: [docs/bot.md](docs/bot.md). +Полный синтаксис — [docs/bot.md](docs/bot.md). -## Веб-интерфейс +--- -Запускается на любом ам. сервере (там же где бот), HTTP basic auth. +## Как это работает (короткая версия) -После запуска: `http://<ам-сервер>:8088` +1. На каждом зарубежном сервере поднят WireGuard-туннель `ru` → к выбранному RU-серверу. +2. iptables `mangle` помечает трафик от X-ray меткой `100`, `ip rule` отправляет его в таблицу маршрутизации `200`, где default → через туннель. +3. Маршруты на конкретные RU-подсети/IP добавляются в `ru-base.aips` и `ru-extra.list`. +4. Cron каждую минуту запускает `ru-failover.py`: проверяет handshake + TCP-пробу на RU. Если пусто > N секунд — переключает endpoint на следующий по приоритету. -Подробнее: [docs/webui.md](docs/webui.md). +Деталки — [docs/architecture.md](docs/architecture.md). -## Архитектура +--- -См. [docs/architecture.md](docs/architecture.md) — как устроены failover, маршрутизация, синхронизация конфига между серверами. +## Структура репозитория + +``` +kaskad/ +├── install.sh ← одна команда — всё ставится +├── uninstall.sh ← одна команда — всё убирается +├── bin/ ← скрипты для ам. серверов +├── bot/ ← Telegram-бот +├── webui/ ← Flask-приложение +├── examples/ ← шаблоны конфигов (если ставите вручную) +└── docs/ ← подробная документация +``` + +--- ## Безопасность -- Конфиги (`ru-servers.json`, `notify.env`, `webui.env`) с правами `600`, **не коммитятся** в репо -- SSH между серверами — по ключам; пароли используются только при первичной онбординге нового сервера через `password=` в боте/WebUI и **сразу затираются** после установки ключа -- Веб-интерфейс через basic auth, рекомендуется ставить за HTTPS reverse-proxy (nginx/caddy) -- Telegram-бот принимает команды только от заранее заданного `TG_CHAT_ID` +- Все конфиги (`ru-servers.json`, `notify.env`, `webui.env`) с правами `600`, **не коммитятся**. +- Между серверами — только SSH-ключи. Пароли используются один раз, при добавлении нового сервера, и **сразу затираются**. +- WebUI защищён HTTP basic auth. Рекомендация: поставьте за HTTPS reverse-proxy (Caddy, Nginx, Traefik). +- Telegram-бот принимает команды только от заранее заданного `TG_CHAT_ID`. +--- + +## Требования + +- Ubuntu / Debian 20.04+ на ам. сервере (где будет WebUI и бот) — root доступ +- Ubuntu / Debian 20.04+ на RU-сервере — root + SSH-пароль (нужен один раз) +- На ам. сервере: уже настроенный X-ray (3x-ui подойдёт) с интерфейсом `amn0` +- На RU-сервере: открыт UDP/1939 наружу (или проброс на роутере) + +--- + +## FAQ + +**Не работает «удалить» в WebUI?** — кнопки `удалить` рядом с зарубежными серверами теперь явно красные. Если показывает «⛔ нельзя» — это сервер, на котором сам WebUI запущен; такой нельзя убрать (надо сначала перенести WebUI на другой узел). + +**Сервер удалён, но peer остался на RU?** — при удалении WebUI сам делает `wg set wg_ru peer ... remove` на всех RU. Если RU был недоступен — попросит подтверждение и удалит запись принудительно (`force`). + +**Скорость упала?** — каскад не должен влиять на не-RU трафик. Если падает — проверьте `iptables -t mangle -L PREROUTING -v` (помечается ли трафик) и `ip rule show`. + +**Бот молчит** — `journalctl -u ru-tg-bot -f`. Чаще всего: неверный `TG_BOT_TOKEN` или `TG_CHAT_ID`, или сервер не может выйти к `api.telegram.org`. + +Подробнее: [docs/troubleshooting.md](docs/troubleshooting.md). + +--- ## Связанные проекты -- 🔧 **[keenetic-unified](https://github.com/andrey271192/keenetic-unified)** — мониторинг роутеров Keenetic с TG-ботом и веб-дашбордом -- 🌐 **[keenetic_ssh-web](https://github.com/andrey271192/keenetic_ssh-web)** — веб-панель shell-команд на самом роутере -- 🛣 **[keenetic-dns-routes](https://github.com/andrey271192/keenetic-dns-routes)** — DNS-маршрутизация без HydraRoute Neo +- 🔧 [keenetic-unified](https://github.com/andrey271192/keenetic-unified) — мониторинг роутеров Keenetic +- 🌐 [keenetic_ssh-web](https://github.com/andrey271192/keenetic_ssh-web) — веб-панель shell-команд на роутере +- 🛣 [keenetic-dns-routes](https://github.com/andrey271192/keenetic-dns-routes) — DNS-маршрутизация без HydraRoute Neo --- -## Поддержка проекта +## Поддержать проект -Если **Kaskad** оказался полезен — поддержи разработку: - -- ⭐ **GitHub:** [andrey271192/kaskad](https://github.com/andrey271192/kaskad) — поставь звезду -- 💖 **Boosty:** [boosty.to/andrey27/donate](https://boosty.to/andrey27/donate) -- 💳 **Ozon Bank (СБП):** [перевод](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) -- ✉️ **Telegram:** [@Iot_andrey](https://t.me/Iot_andrey) — вопросы, предложения, баги - ---- +- ⭐ Поставьте звезду на [andrey271192/kaskad](https://github.com/andrey271192/kaskad) +- 💖 [Boosty](https://boosty.to/andrey27/donate) +- 💳 [Ozon Bank СБП](https://finance.ozon.ru/apps/sbp/ozonbankpay/019dc200-2a5d-7931-a619-782d285f6798) +- ✉️ Telegram: [@Iot_andrey](https://t.me/Iot_andrey) — вопросы, баги, идеи ## Лицензия diff --git a/docs/install.md b/docs/install.md index 719ff9e..cc89b95 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,30 +1,98 @@ # Установка -## Требования +Есть два пути: **простой** (одна команда, всё автоматом) и **ручной** (если хочется контроля над каждым шагом). -- Минимум 1 RU-сервер (Ubuntu 20.04+) с публичным IP или пробросом UDP/1939 на роутере -- Минимум 1 ам. сервер (Ubuntu 20.04+) с установленной X-ray панелью (3x-ui) -- Telegram-бот (создать в @BotFather) и chat ID для уведомлений (узнать у @userinfobot) -- Root доступ на оба сервера +--- -## Шаг 1. Первый RU-сервер +## 🚀 Простой путь — одна команда + +### Что нужно заранее + +- **Один зарубежный сервер** (Ubuntu/Debian 20.04+, root) с настроенным X-ray (3x-ui подойдёт), интерфейс обычно `amn0`. Здесь будут жить WebUI и Telegram-бот. +- **Один RU-сервер** (Ubuntu/Debian 20.04+, root, открыт UDP/1939 наружу или проброшен с роутера). Если он чистый — скрипт сам поставит на него WireGuard. +- **Telegram-бот**: создайте через [@BotFather](https://t.me/BotFather), запишите token. +- **Свой Telegram chat ID**: узнайте через [@userinfobot](https://t.me/userinfobot). + +### Запуск + +На зарубежном сервере, под root: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad/main/install.sh | sudo bash +``` + +Скрипт интерактивный, спросит 5–6 вопросов: + +| Вопрос | Что вводить | +|---|---| +| Telegram bot token | строка от @BotFather, типа `123456:AAH...` | +| Telegram chat ID | число, например `123456789` | +| Короткое имя ам. сервера | любое, `ams1` подойдёт | +| Внешний IP | сам подтянет с `api.ipify.org` | +| Хост RU-сервера | IP или домен RU-машины | +| SSH-порт RU | обычно `22` | +| Пароль root@RU | используется ОДИН раз, потом затирается | +| Логин WebUI | например `admin` | +| Пароль WebUI | минимум 8 символов | +| Имя X-ray-интерфейса | обычно `amn0` | + +Через 2–3 минуты: + +``` +✓ Готово! + + WebUI: http://YOUR_AMS_IP:8088 + Логин: admin + Пароль: (тот, что ты ввёл) + + Telegram-бот активен — отправь ему /status. +``` + +Дальше всё через WebUI или бот. + +### Обновление + +```bash +cd /opt/kaskad && git pull && sudo bash install.sh +``` + +Скрипт идемпотентный — повторный запуск ничего не сломает, просто обновит скрипты. + +### Удаление + +```bash +sudo bash /opt/kaskad/uninstall.sh +``` + +Или одной командой: + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad/main/uninstall.sh | sudo bash +``` + +Подтверждение через ввод `YES`. Если хотите снести и WG-ключи — `KASKAD_PURGE_KEYS=1 sudo bash uninstall.sh`. + +--- + +## 🛠 Ручной путь — для тех, кто хочет понять каждый шаг + +Все шаги ниже делает за вас `install.sh` — приведены для отладки и образовательных целей. + +### Шаг 1. Первый RU-сервер На RU-сервере под root: ```bash apt update && apt install -y wireguard iptables-persistent -# 1. Генерация ключа cd /etc/wireguard umask 077 wg genkey | tee ru_private.key | wg pubkey > ru_public.key PRIVKEY=$(cat ru_private.key) -# 2. ip_forward sysctl -w net.ipv4.ip_forward=1 echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf -# 3. Конфиг — пока без peer'ов, добавятся когда подключим первый ам. IFACE=$(ip route | awk '/^default/{print $5; exit}') cat > /etc/wireguard/wg_ru.conf < ru_public.key -cat ru_public.key # записать — добавим в peer'ы RU +cat ru_public.key # запомните — добавим в peer'ы RU -# 2. Узнать имя интерфейса X-ray +# Узнать имя интерфейса X-ray ip a | grep -E 'amn|tun' | grep -v '@' -# обычно amn0 — используем дальше +# обычно amn0 -# 3. Конфиг ru.conf — IP в туннеле = 10.0.0.2 (первый ам.) PRIVKEY=$(cat ru_private.key) cat > /etc/wireguard/ru.conf <> /etc/wireguard/wg_ru.conf </dev/null; \ echo '* * * * * /usr/local/bin/ru-failover.py'; \ echo '17 */6 * * * /usr/local/bin/ru-domains.py refresh >> /var/log/ru-domains.log 2>&1' \ ) | crontab - -# 6. SSH-ключ бота (нужен для управления остальными серверами через ssh-key auth) +# SSH-ключ для бота [ -f /root/.ssh/id_ed25519 ] || ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519 cat /root/.ssh/id_ed25519.pub -# скопировать в /root/.ssh/authorized_keys на всех остальных ам. серверах -# и на всех RU-серверах +# добавьте в /root/.ssh/authorized_keys на остальных ам. и RU серверах -# 7. Установить бот -install -m 755 bot/ru-tg-bot.py /usr/local/bin/ -install -m 644 bot/ru-tg-bot.service /etc/systemd/system/ -apt install -y python3 sshpass systemctl daemon-reload -systemctl enable --now ru-tg-bot.service -journalctl -u ru-tg-bot.service -f -``` +systemctl enable --now ru-tg-bot.service ru-webui.service -Послать боту в Telegram `/status` — должно прийти текущее состояние. - -## Шаг 4. Веб-интерфейс (опционально) - -На том же ам. сервере, где бот: - -```bash -cd /opt/kaskad -apt install -y python3-flask - -install -m 755 webui/app.py /usr/local/bin/ru-webui.py -mkdir -p /usr/local/share/kaskad -cp -r webui/templates webui/static /usr/local/share/kaskad/ -# в app.py указать template_folder и static_folder если нужно; -# по умолчанию работает из текущей директории, поэтому проще: -ln -s /usr/local/share/kaskad/templates /usr/local/bin/templates -ln -s /usr/local/share/kaskad/static /usr/local/bin/static - -mkdir -p /etc/kaskad -cp webui/webui.env.example /etc/kaskad/webui.env -chmod 600 /etc/kaskad/webui.env -# ВПИСАТЬ KASKAD_WEB_USER и KASKAD_WEB_PASS - -install -m 644 webui/ru-webui.service /etc/systemd/system/ -systemctl daemon-reload -systemctl enable --now ru-webui.service - -# Открыть порт 8088 (по желанию) -iptables -I INPUT -p tcp --dport 8088 -j ACCEPT +# открываем 8088 +iptables -A INPUT -p tcp --dport 8088 -j ACCEPT netfilter-persistent save - -# Открыть в браузере: http://AMS-IP:8088 ``` -Рекомендуется поставить за HTTPS reverse-proxy (nginx, caddy, traefik) с Let's Encrypt. +Проверьте: `journalctl -u ru-tg-bot -u ru-webui -f`. Отправьте боту `/status` — должен ответить. Откройте `http://AMS_IP:8088`. -## Шаг 5. Дальше +--- -Через бот или WebUI: -- `/server-add` — добавить новый RU-сервер -- `/ams-add` — добавить новый ам. сервер -- `/add-domain vk.com ozon.ru` — добавить русские сайты по доменам -- `/add 5.45.192.1/32` — добавить конкретные IP/CIDR +## Дальнейшее расширение -Failover-скрипт каждую минуту проверяет здоровье текущего peer'а и переключает при необходимости. +После того как первая пара RU + ам. работает — добавлять новые **через WebUI или бот**, ничего больше не надо настраивать вручную. + +- **+RU-сервер** → в WebUI «+ добавить RU-сервер» (нужен root-пароль для первичной онбординги) или `/server-add` в боте +- **+ам. сервер** → «+ добавить ам. сервер» или `/ams-add` +- **+домен** (vk.com, ozon.ru) → секция «Домены» или `/add-domain vk.com ozon.ru` +- **+IP/CIDR** → секция «Доп. IP/CIDR» или `/add 5.45.192.1/32` + +Cron каждую минуту проверяет здоровье текущего RU peer'а через handshake age + TCP-пробу и переключает на следующий по приоритету при необходимости. + +--- ## Если что-то не работает -См. [docs/troubleshooting.md](troubleshooting.md). +См. [troubleshooting.md](troubleshooting.md). diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b07e909 --- /dev/null +++ b/install.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# Kaskad — one-command installer. +# Запускается на ПЕРВОМ ам. (зарубежном) сервере, который станет хостом WebUI и TG-бота. +# Сервер уже должен быть настроен как X-ray-нода (3x-ui или ручной xray) с интерфейсом amn0. +# +# curl -fsSL https://raw.githubusercontent.com/andrey271192/kaskad/main/install.sh | bash +# +# Или клонируй репо и запусти: sudo bash install.sh +# +# Скрипт интерактивный — задаст 5-6 вопросов и сам всё развернёт. + +set -euo pipefail +IFS=$'\n\t' + +# --------- цвета и хелперы --------- +RED=$(printf '\033[31m'); GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m') +BLU=$(printf '\033[34m'); BLD=$(printf '\033[1m'); RST=$(printf '\033[0m') +say() { printf "%s==>%s %s\n" "$BLU" "$RST" "$*"; } +ok() { printf "%s✓%s %s\n" "$GRN" "$RST" "$*"; } +warn() { printf "%s!%s %s\n" "$YEL" "$RST" "$*"; } +die() { printf "%s✗%s %s\n" "$RED" "$RST" "$*" >&2; exit 1; } +ask() { local p="$1" def="${2:-}" var; read -r -p "$p${def:+ [$def]}: " var; echo "${var:-$def}"; } +ask_secret() { local p="$1" var; read -r -s -p "$p: " var; echo >&2; echo "$var"; } + +[ "$(id -u)" -eq 0 ] || die "запускай под root (или через sudo)" + +REPO_URL="${KASKAD_REPO:-https://github.com/andrey271192/kaskad.git}" +KASKAD_DIR="${KASKAD_DIR:-/opt/kaskad}" + +cat </dev/null || hostname -I | awk '{print $1}') +LOCAL_HOST_ID=$(ask "Короткое имя ЭТОГО ам. сервера" "ams1") +LOCAL_IP=$(ask "Внешний IP ЭТОГО ам. сервера" "$PUBLIC_IP") + +RU_HOST=$(ask "Хост ПЕРВОГО RU-сервера (IP или домен)") +[ -n "$RU_HOST" ] || die "RU host нужен" +RU_SSH_PORT=$(ask "SSH-порт RU-сервера" "22") +RU_PASS=$(ask_secret "Пароль root@${RU_HOST} (нужен ОДИН раз — для установки SSH-ключа)") +[ -n "$RU_PASS" ] || die "пароль RU нужен (используется только сейчас)" + +WEB_USER=$(ask "Логин для WebUI" "admin") +WEB_PASS=$(ask_secret "Пароль для WebUI (минимум 8 символов)") +[ "${#WEB_PASS}" -ge 8 ] || die "пароль WebUI должен быть ≥ 8 символов" + +XRAY_IFACE=$(ask "Имя интерфейса X-ray на этом сервере" "amn0") + +# --------- 2. ставим пакеты --------- +say "Шаг 2/6: установка пакетов" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq wireguard iptables iptables-persistent python3 python3-flask \ + sshpass dnsutils curl git cron >/dev/null +ok "пакеты" + +# --------- 3. клонируем репу --------- +say "Шаг 3/6: получаем код Kaskad" +if [ -d "$KASKAD_DIR/.git" ]; then + git -C "$KASKAD_DIR" pull --ff-only >/dev/null +else + git clone --depth 1 "$REPO_URL" "$KASKAD_DIR" >/dev/null 2>&1 +fi +ok "$KASKAD_DIR" + +# --------- 4. локальные ключи и конфиги --------- +say "Шаг 4/6: WireGuard, ключи, конфиги" +mkdir -p /etc/wireguard /etc/kaskad +chmod 700 /etc/wireguard /etc/kaskad + +# WG-ключ для этого ам. +if [ ! -f /etc/wireguard/ru_private.key ]; then + umask 077 + wg genkey | tee /etc/wireguard/ru_private.key | wg pubkey > /etc/wireguard/ru_public.key +fi +AMS_PUB=$(cat /etc/wireguard/ru_public.key) +AMS_PRIV=$(cat /etc/wireguard/ru_private.key) + +# SSH-ключ для управления остальными серверами +if [ ! -f /root/.ssh/id_ed25519 ]; then + mkdir -p /root/.ssh; chmod 700 /root/.ssh + ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519 -q +fi +BOT_KEY_PUB=$(cat /root/.ssh/id_ed25519.pub) + +# Заливаем SSH-ключ на RU через пароль +say " • устанавливаю SSH-ключ на $RU_HOST" +sshpass -p "$RU_PASS" ssh -o StrictHostKeyChecking=accept-new -p "$RU_SSH_PORT" \ + "root@$RU_HOST" "mkdir -p /root/.ssh && chmod 700 /root/.ssh && \ + grep -qF '$BOT_KEY_PUB' /root/.ssh/authorized_keys 2>/dev/null || \ + echo '$BOT_KEY_PUB' >> /root/.ssh/authorized_keys && \ + chmod 600 /root/.ssh/authorized_keys" >/dev/null +ok "ssh-ключ установлен" + +# --------- настройка RU-сервера, если ещё пуст --------- +say " • настраиваю RU-сервер $RU_HOST" +ssh -p "$RU_SSH_PORT" -o StrictHostKeyChecking=no -o BatchMode=yes "root@$RU_HOST" bash <<'RUSETUP' >/dev/null +set -e +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq wireguard iptables iptables-persistent python3 curl >/dev/null +sysctl -w net.ipv4.ip_forward=1 >/dev/null +grep -q '^net.ipv4.ip_forward=1' /etc/sysctl.conf || echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf +cd /etc/wireguard +if [ ! -f wg_ru.conf ]; then + umask 077 + [ -f ru_private.key ] || wg genkey | tee ru_private.key | wg pubkey > ru_public.key + IFACE=$(ip route | awk '/^default/{print $5; exit}') + PRIVKEY=$(cat ru_private.key) + cat > wg_ru.conf </dev/null || iptables -A INPUT -p udp --dport 1939 -j ACCEPT + netfilter-persistent save >/dev/null 2>&1 || true + systemctl enable --now wg-quick@wg_ru >/dev/null 2>&1 || wg-quick up wg_ru +fi +RUSETUP +RU_PUB=$(ssh -p "$RU_SSH_PORT" "root@$RU_HOST" cat /etc/wireguard/ru_public.key) +RU_IFACE=$(ssh -p "$RU_SSH_PORT" "root@$RU_HOST" "ip route | awk '/^default/{print \$5; exit}'") +ok "RU настроен, pubkey: ${RU_PUB:0:20}…" + +# --------- локальный ru.conf на этом ам. --------- +cat > /etc/wireguard/ru.conf </dev/null; ip route flush table 200 2>/dev/null; iptables -t nat -D POSTROUTING -o ru -j MASQUERADE 2>/dev/null +[Peer] +PublicKey = $RU_PUB +Endpoint = $RU_HOST:1939 +AllowedIPs = 10.0.0.0/24 +PersistentKeepalive = 25 +EOF +chmod 600 /etc/wireguard/ru.conf + +# Базовая mangle для X-ray → wg +iptables -t mangle -C PREROUTING -i "$XRAY_IFACE" -j MARK --set-mark 100 2>/dev/null || \ + iptables -t mangle -A PREROUTING -i "$XRAY_IFACE" -j MARK --set-mark 100 +netfilter-persistent save >/dev/null 2>&1 || true + +# notify.env +cat > /etc/wireguard/notify.env < /etc/kaskad/webui.env < /etc/wireguard/ru-servers.json < /etc/wireguard/ru-domains.json +chmod 600 /etc/wireguard/ru-extra.list /etc/wireguard/ru-base.aips /etc/wireguard/ru-domains.json +ok "локальные конфиги созданы" + +# --------- 5. ставим скрипты, юниты, cron --------- +say "Шаг 5/6: скрипты, сервисы, cron" +install -m 755 "$KASKAD_DIR/bin/ru-failover.py" /usr/local/bin/ +install -m 755 "$KASKAD_DIR/bin/ru-set.sh" /usr/local/bin/ +install -m 755 "$KASKAD_DIR/bin/ru-routes.sh" /usr/local/bin/ +install -m 755 "$KASKAD_DIR/bin/ru-domains.py" /usr/local/bin/ +install -m 755 "$KASKAD_DIR/bin/add-ru-helper.sh" /usr/local/bin/ +install -m 755 "$KASKAD_DIR/bin/add-ams-helper.sh" /usr/local/bin/ +install -m 755 "$KASKAD_DIR/bot/ru-tg-bot.py" /usr/local/bin/ + +# webui — поднимаем из /opt/kaskad/webui +install -m 644 "$KASKAD_DIR/bot/ru-tg-bot.service" /etc/systemd/system/ +install -m 644 "$KASKAD_DIR/webui/ru-webui.service" /etc/systemd/system/ + +# Прокидываем LOCAL_HOST/LOCAL_IP в бот-юнит (там Environment=, мы должны их перезаписать) +sed -i "s|^Environment=LOCAL_HOST=.*$|Environment=LOCAL_HOST=$LOCAL_HOST_ID|" /etc/systemd/system/ru-tg-bot.service || true +sed -i "s|^Environment=LOCAL_IP=.*$|Environment=LOCAL_IP=$LOCAL_IP|" /etc/systemd/system/ru-tg-bot.service || true +grep -q "^Environment=LOCAL_HOST=" /etc/systemd/system/ru-tg-bot.service || \ + sed -i "/^EnvironmentFile=/a Environment=LOCAL_HOST=$LOCAL_HOST_ID\nEnvironment=LOCAL_IP=$LOCAL_IP" /etc/systemd/system/ru-tg-bot.service + +# cron +( crontab -l 2>/dev/null | grep -v 'ru-failover\|ru-domains' ; \ + echo '* * * * * /usr/local/bin/ru-failover.py' ; \ + echo '17 */6 * * * /usr/local/bin/ru-domains.py refresh >> /var/log/ru-domains.log 2>&1' \ +) | crontab - + +# WG up +systemctl enable --now wg-quick@ru >/dev/null 2>&1 || wg-quick up ru +ok "WG-туннель ru поднят" + +# Добавляем этот ам. как peer на RU +ssh -p "$RU_SSH_PORT" -o StrictHostKeyChecking=no "root@$RU_HOST" bash </dev/null +grep -qF "$AMS_PUB" /etc/wireguard/wg_ru.conf || cat >> /etc/wireguard/wg_ru.conf </dev/null || \ + (wg-quick down wg_ru; wg-quick up wg_ru) +RUPEER +ok "ам. добавлен как peer на RU" + +# --------- 6. поднимаем сервисы --------- +say "Шаг 6/6: запуск бота и WebUI" + +# WebUI юнит ссылается на /opt/kaskad-webui — пере-указываем на /opt/kaskad/webui +sed -i "s|WorkingDirectory=.*$|WorkingDirectory=$KASKAD_DIR/webui|" /etc/systemd/system/ru-webui.service +sed -i "s|ExecStart=.*$|ExecStart=/usr/bin/python3 $KASKAD_DIR/webui/app.py|" /etc/systemd/system/ru-webui.service + +systemctl daemon-reload +systemctl enable --now ru-tg-bot.service +systemctl enable --now ru-webui.service +sleep 2 + +# открываем 8088 на firewall (опционально) +iptables -C INPUT -p tcp --dport 8088 -j ACCEPT 2>/dev/null || \ + iptables -A INPUT -p tcp --dport 8088 -j ACCEPT +netfilter-persistent save >/dev/null 2>&1 || true + +# затираем пароль RU из памяти +unset RU_PASS + +cat <%s %s\n" "$BLU" "$RST" "$*"; } +ok() { printf "%s✓%s %s\n" "$GRN" "$RST" "$*"; } +warn() { printf "%s!%s %s\n" "$YEL" "$RST" "$*"; } +die() { printf "%s✗%s %s\n" "$RED" "$RST" "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "запускай под root" + +FORCE=${KASKAD_FORCE:-0} +PURGE_KEYS=${KASKAD_PURGE_KEYS:-0} + +if [ "$FORCE" != "1" ]; then + cat </dev/null || true +systemctl disable --now ru-webui.service 2>/dev/null || true +rm -f /etc/systemd/system/ru-tg-bot.service /etc/systemd/system/ru-webui.service +systemctl daemon-reload || true +ok "сервисы остановлены" + +say "опускаю WG-туннели" +wg-quick down ru 2>/dev/null || true +wg-quick down wg_ru 2>/dev/null || true +systemctl disable wg-quick@ru 2>/dev/null || true +systemctl disable wg-quick@wg_ru 2>/dev/null || true +ok "WG" + +say "удаляю скрипты" +rm -f /usr/local/bin/ru-failover.py /usr/local/bin/ru-failover.py.bak.* \ + /usr/local/bin/ru-set.sh /usr/local/bin/ru-routes.sh /usr/local/bin/ru-domains.py \ + /usr/local/bin/add-ru-helper.sh /usr/local/bin/add-ams-helper.sh \ + /usr/local/bin/ru-tg-bot.py +ok "скрипты" + +say "удаляю cron-задачи" +( crontab -l 2>/dev/null | grep -v 'ru-failover\|ru-domains' || true ) | crontab - || true +ok "cron" + +say "удаляю конфиги" +rm -rf /etc/kaskad +rm -f /etc/wireguard/ru.conf /etc/wireguard/wg_ru.conf \ + /etc/wireguard/ru-servers.json /etc/wireguard/ru-extra.list \ + /etc/wireguard/ru-base.aips /etc/wireguard/ru-domains.json \ + /etc/wireguard/notify.env \ + /etc/wireguard/ru.conf.bak.* /etc/wireguard/wg_ru.conf.bak.* +ok "конфиги" + +# Опционально — затираем WG-ключи и SSH-ключ бота +if [ "$PURGE_KEYS" = "1" ]; then + say "затираю ключи (KASKAD_PURGE_KEYS=1)" + rm -f /etc/wireguard/ru_private.key /etc/wireguard/ru_public.key + rm -f /root/.ssh/id_ed25519 /root/.ssh/id_ed25519.pub + ok "ключи затёрты" +else + warn "ключи /etc/wireguard/ru_*.key и /root/.ssh/id_ed25519 НЕ удалены" + warn "хочешь грохнуть и их: KASKAD_PURGE_KEYS=1 bash uninstall.sh" +fi + +say "удаляю /opt/kaskad" +rm -rf /opt/kaskad /opt/kaskad-webui +ok "/opt/kaskad" + +cat <&1 | tail -1 @app.route("/api/ams/", methods=["DELETE"]) @require_auth def api_ams_remove(sid): - data = load_data() - target = next((a for a in data.get("ams_servers", []) if a["id"] == sid or a["host"] == sid), None) - if not target: return jsonify(error="не найден"), 404 - if target.get("is_local"): return jsonify(error="нельзя удалить local"), 400 + """Удалить зарубежный (ам.) сервер из каскада. + Шаги: + 1) на каждом RU-сервере убрать [Peer] с pubkey удаляемого ам. + 2) обновить /etc/wireguard/ru-servers.json и разлить по остальным ам. + 3) синхронизировать живой wg-интерфейс на RU через `wg set ... peer ... remove` + + ?force=1 — продолжить, даже если какой-то RU недоступен. + """ + force = request.args.get("force") in ("1", "true", "yes") + data = load_data() + target = next((a for a in data.get("ams_servers", []) + if a["id"] == sid or a["host"] == sid), None) + if not target: + return jsonify(error=f"ам. сервер '{sid}' не найден"), 404 + if target.get("is_local"): + return jsonify(error="нельзя удалить сервер, на котором запущен WebUI"), 400 + + pubkey = target["pubkey"] rus = ru_list_data() - peer_results = [] + peer_results, hard_fail = [], [] + for ru in rus: - cmd = f""" -python3 - <<'PY' -import pathlib, re -p = pathlib.Path('/etc/wireguard/wg_ru.conf') -t = p.read_text() -parts = re.split(r'(\\[Peer\\])', t) -result = parts[0] -i = 1 -while i < len(parts): - block = parts[i] + (parts[i+1] if i+1 < len(parts) else '') - if {target['pubkey']!r} in block: - i += 2; continue - result += block - i += 2 -p.write_text(result) -PY -wg syncconf wg_ru <(wg-quick strip wg_ru) 2>&1 -""" + cmd = ( + f"set -e; cd /etc/wireguard; " + f"cp -a wg_ru.conf wg_ru.conf.bak.$(date +%s); " + f"python3 -c \"import pathlib,re,sys; " + f"p=pathlib.Path('wg_ru.conf'); t=p.read_text(); " + f"blocks=re.split(r'(?m)(?=^\\[Peer\\])', t); " + f"kept=[b for b in blocks if {pubkey!r} not in b]; " + f"p.write_text(''.join(kept))\"; " + f"wg set wg_ru peer {shlex.quote(pubkey)} remove 2>/dev/null || true; " + f"echo OK" + ) out_pr, rc_pr = ssh_ru(ru, cmd, timeout=20) - peer_results.append({"ru": ru["id"], "ok": rc_pr == 0}) + ok = (rc_pr == 0 and "OK" in (out_pr or "")) + peer_results.append({"ru": ru["id"], "ok": ok, "msg": (out_pr or "")[-200:]}) + if not ok: + hard_fail.append(f"{ru['id']}: rc={rc_pr} {(out_pr or '')[-120:]}") + + if hard_fail and not force: + return jsonify( + error=("не удалось убрать peer на: " + "; ".join(hard_fail) + + ". Повторите с ?force=1 чтобы удалить запись принудительно."), + peers=peer_results, + ), 502 + data["ams_servers"] = [a for a in data["ams_servers"] if a["id"] != target["id"]] - save_and_distribute(data) - return jsonify(removed=target["id"], peers=peer_results) + ok, err = save_and_distribute(data) + if not ok and not force: + return jsonify(error=f"sync конфига: {err}", peers=peer_results), 500 + return jsonify(removed=target["id"], peers=peer_results, force=force) @app.route("/api/domains", methods=["POST"]) diff --git a/webui/static/style.css b/webui/static/style.css index 1214ea7..8842451 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -51,6 +51,17 @@ button:hover:not(:disabled) { background: #30363d; border-color: #58a6ff; color: button:disabled { opacity: 0.4; cursor: not-allowed; } button.danger { color: #f85149; border-color: #4a1f23; } button.danger:hover:not(:disabled) { background: #4a1f23; color: #fff; border-color: #f85149; } +button.danger.solid { + background: #b1361a; + color: #fff; + border-color: #f85149; + font-weight: 600; +} +button.danger.solid:hover:not(:disabled) { + background: #f85149; + color: #fff; + border-color: #f85149; +} button.sm { padding: 2px 8px; font-size: 11px; } .actions { diff --git a/webui/templates/index.html b/webui/templates/index.html index 4395514..5ea1024 100644 --- a/webui/templates/index.html +++ b/webui/templates/index.html @@ -79,18 +79,25 @@
-

Ам. серверы

+

Ам. серверы (зарубежные)

@@ -258,8 +265,18 @@ this.load(); }, async removeAms(id) { - if (!confirm(`Удалить ам. '${id}'? Snimet peer на всех RU.`)) return; - await this.req('DELETE',`/api/ams/${id}`); this.flash('удалено'); this.load(); + if (!confirm(`Удалить зарубежный сервер '${id}'?\n\nЧто произойдёт:\n • peer уберётся со всех RU-серверов\n • запись удалится из конфига\n • сам сервер ${id} останется жив, но больше не будет частью каскада\n\nПродолжить?`)) return; + try { + const r = await this.req('DELETE',`/api/ams/${id}`); + const peers = (r.peers||[]).map(p => `${p.ru}: ${p.ok?'✓':'✗'}`).join(', '); + this.flash(`удалён ${id}${peers ? ' ('+peers+')' : ''}`); + } catch (e) { + if (confirm(`Ошибка: ${e.message}\n\nУдалить запись принудительно (force, даже если RU-серверы недоступны)?`)) { + await this.req('DELETE',`/api/ams/${id}?force=1`); + this.flash(`удалён ${id} (force)`); + } + } + this.load(); }, async addDomains() { const doms = this.forms.domains.split(/[\s,]+/).map(s=>s.trim()).filter(Boolean);
IDHostSSHTunnelPubkey