From 4cd5ef8882db7143586caef72aeff8b216c349ce Mon Sep 17 00:00:00 2001
From: StressOzz <131419324+StressOzz@users.noreply.github.com>
Date: Wed, 23 Sep 2026 21:31:43 +0300
Subject: [PATCH] =?UTF-8?q?Zapret=20Manager=20=D0=B4=D0=BB=D1=8F=20LuCI=20?=
=?UTF-8?q?1.38:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D0=B1=D1=85=D0=BE=D0=B4?=
=?UTF-8?q?=20=D0=B8=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20?=
=?UTF-8?q?steer=20=D0=B2=20=D0=B2=D0=B5=D0=B1-=D0=B8=D0=BD=D1=82=D0=B5?=
=?UTF-8?q?=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ZapretManager_LuCI.sh | 3765 +++++++++++++++++++++++++++++------------
1 file changed, 2638 insertions(+), 1127 deletions(-)
diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh
index 4d0f10a..e045397 100644
--- a/ZapretManager_LuCI.sh
+++ b/ZapretManager_LuCI.sh
@@ -1,19 +1,21 @@
#!/bin/sh
# Zapret Manager by StressOzz for LuCI installer
-# Version: 1.34
+# Version: 1.38
set -e
GREEN="\033[1;32m"; CYAN="\033[1;36m"; YELLOW="\033[1;33m"; MAGENTA="\033[1;35m"; BLUE="\033[0;34m"; NC="\033[0m"; DGRAY="\033[38;5;244m"
echo -e "\n${MAGENTA}Устанавливаем Zapret Manager для LuCI${NC}"
-# Пока идёт подбор красной кнопки, переустанавливать нельзя: ниже стирается каталог задач, а с
+# Пока идёт автообход, переустанавливать нельзя: ниже стирается каталог задач, а с
# ним журнал идущего подбора, и страница показывает пустой журнал до самого конца. Тестер при
# этом меняет стратегии zapret, и обрывать его посередине значит оставить роутер на случайной.
-if [ -f /tmp/zapret-manager-luci/redbtn.pid ] && kill -0 "$(cat /tmp/zapret-manager-luci/redbtn.pid 2>/dev/null)" 2>/dev/null; then
- echo -e "${YELLOW}Идёт подбор красной кнопки — дождитесь его окончания и запустите установку снова${NC}"
- exit 1
-fi
+for _zm_job in redbtn steer; do
+ if [ -f /tmp/zapret-manager-luci/$_zm_job.pid ] && kill -0 "$(cat /tmp/zapret-manager-luci/$_zm_job.pid 2>/dev/null)" 2>/dev/null; then
+ echo -e "${YELLOW}Идёт автообход или операция steer — дождитесь окончания и запустите установку снова${NC}"
+ exit 1
+ fi
+done
rm -rf \
/usr/lib/zapret-manager* \
@@ -61,7 +63,7 @@ chmod 0755 /opt/zapret-manager-luci
cat > '/opt/zapret-manager-luci/backend.sh' << 'ZM_INSTALLER_EOF'
CONF="/etc/config/zapret"
-ZM_VERSION="1.34"
+ZM_VERSION="1.38"
ZM_SCRIPT_URL="https://raw.githubusercontent.com/StressOzz/Zapret-Manager/refs/heads/main/ZapretManager_LuCI.sh"
GH_RAW="https://raw.githubusercontent.com"
GH_MAIN="https://github.com"
@@ -1329,7 +1331,9 @@ rm -rf /opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager \
/www/luci-static/resources/bytetube \
/www/luci-static/resources/view/bytetube \
/tmp/zapret-manager-luci /tmp/luci-indexcache* /tmp/luci-modulecache/* \
- /www/zm /www/zm-webui.html /usr/share/zm-redbtn 2>/dev/null
+ /www/zm /www/zm-webui.html 2>/dev/null
+# Списки автообхода нужны steer, пока его правила на месте: без них движок не поднимется.
+[ -s /etc/zm-steer/owned ] || [ -s /etc/zm-redbtn/owned ] || rm -rf /usr/share/zm-redbtn
uci -q delete uhttpd.zmweb && uci -q commit uhttpd
/etc/init.d/rpcd restart >/dev/null 2>&1
/etc/init.d/uhttpd restart >/dev/null 2>&1
@@ -2203,7 +2207,7 @@ test_action() {
*) echo '{"error":"неизвестный режим теста"}'; return 1 ;;
esac
if [ -f "$JOBS_DIR/redbtn.pid" ] && kill -0 "$(cat "$JOBS_DIR/redbtn.pid" 2>/dev/null)" 2>/dev/null; then
- echo '{"error":"идёт подбор красной кнопки — дождитесь его окончания"}'
+ echo '{"error":"идёт автообход — дождитесь его окончания"}'
return 1
fi
job_start strategy_test do_test_run "$mode"
@@ -3085,13 +3089,18 @@ health() {
if [ "$doh" = "2" ]; then pidof https-dns-proxy >/dev/null 2>&1 && doh=1; fi
out=$(hosts_status 2>/dev/null)
case "$out" in *'"enabled":true'*|*'"geohide":"'[a-z]*) hs=1 ;; esac
- if [ -s /etc/zm-redbtn/owned ]; then
+ if [ -s /etc/zm-redbtn/last ]; then
rb=1
grep -q '|none$' /etc/zm-redbtn/services 2>/dev/null && rb=2
- if grep -qx 'steer-spec' /etc/zm-redbtn/owned; then /etc/init.d/steer running >/dev/null 2>&1 || rb=2; fi
fi
- printf '{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"redbtn":%s}\n' \
- "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" "$rb"
+ # steer: 1 — работает с правилами, 2 — установлен, но выключен или туннель не поднят.
+ local sr=0
+ if grep -qx 'net zmwarp' /etc/zm-steer/owned 2>/dev/null; then
+ sr=2
+ if grep -qx 'steer-spec' /etc/zm-steer/owned && /etc/init.d/steer running >/dev/null 2>&1 && [ -d /sys/class/net/zmwarp ]; then sr=1; fi
+ fi
+ printf '{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"redbtn":%s,"steer":%s}\n' \
+ "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" "$rb" "$sr"
}
bytetube_installed() {
@@ -4719,78 +4728,24 @@ doh_set() {
}
-# ───────────────────────── Красная кнопка ─────────────────────────
-#
-# Автоподбор обхода по сервисам: роутер сам проверяет каждый сервис и берёт ему самый дешёвый
-# работающий способ. Идея и каталог сервисов — из BigRedButton (gitlab.com/xyzmean/brb), но
-# ступень обхода DPI здесь своя, менеджерская: стратегию подбирает тестер Zapret Manager
-# (do_test_run) и применяет её его же функциями (strategy_set_*). Своего перебора с отдельными
-# экземплярами nfqws, как было в brb, здесь нет — у zapret на роутере один хозяин, и это
-# менеджер.
-#
-# ЛЕСТНИЦА, по возрастанию цены:
-# 1. напрямую — сервис открывается и без обхода;
-# 2. через Zapret — открывается при общей стратегии zapret (её подбирает тестер ZM);
-# 3. через WARP — туннель AmneziaWG к Cloudflare, трафик сервиса уводит движок steer;
-# 4. через веб-сокет — только Telegram: микропакет tgws (его же ставит страница TG WS Proxy).
-#
-# ЧЕГО КНОПКА НЕ ДЕЛАЕТ, и это исправления против brb:
-# - не трогает DNS роутера (brb выкидывал чужие секции https-dns-proxy и ставил dnsmasq
-# noresolv=1, а при удалении не возвращал ничего). DNS — страница «DNS over HTTPS»;
-# если её перенаправление порта 53 спорит с резолвером движка, кнопка это показывает и
-# чинит только по нажатию;
-# - не пишет поверх чужой спеки steer (splify2 или настроенной руками);
-# - не делает хуже: если подобранная стратегия открыла меньше сервисов, чем было, прежняя
-# настройка zapret возвращается;
-# - заводит туннелю зону firewall с NAT: без неё проба с самого роутера проходила, а
-# устройства сети через туннель не ходили;
-# - записывает всё, что поставила и поменяла (RB_OWNED), и снимает ровно это при удалении.
+# ───────────────────────── Общее для steer и автообхода ─────────────────────────
-RB_DIR="/etc/zm-redbtn"
+# Каталог сервисов и их списки доменов/адресов (ставится установщиком панели).
RB_SHARE="/usr/share/zm-redbtn"
-RB_RUN="$JOBS_DIR/redbtn"
-RB_OWNED="$RB_DIR/owned"
-RB_RESULTS="$RB_DIR/services"
-RB_LAST="$RB_DIR/last"
-RB_STOP_FLAG="$RB_RUN/stop"
-RB_PHASE_FILE="$RB_RUN/phase"
-RB_WARP_CONF="$RB_DIR/warp.conf"
-RB_WARP_IF="zmwarp"
-RB_WARP_ZONE="zmwarp"
-RB_STEER_VER="1.5.7"
-RB_STEER_SPEC="/etc/steer/spec.json"
-RB_STEER_URLS="https://github.com/xyzmean/steer/releases/download/v@VER@ https://gitlab.com/xyzmean/steer/-/raw/dist https://raw.githubusercontent.com/xyzmean/steer/dist"
-RB_AWG_MIRROR="https://gitlab.com/xyzmean/brb/-/raw/main/deps/awg"
-# Полоса локальных портов для проверки «напрямую». Пакетам из неё ставится бит 0x40000000 —
-# тот самый, по которому общий zapret пропускает пакет мимо себя (DESYNC_MARK). Так проба
-# «без обхода» не требует останавливать zapret всей сети.
-RB_LANE_DIRECT="21000-21199"
-# Российские колонии Cloudflare: туннель через них геоблок не снимает, берутся только в запас.
-RB_RU_COLOS="DME SVX LED KJA REN OVB KZN AER VVO"
-# Свой DNS для проб. Системный резолвер может отдавать чужое: подмену провайдера или адреса
-# fakeip вышестоящего роутера со splify2 (с ними проба через туннель стучится в адрес, который
-# что-то значит только там). Первый ответивший из списка — на весь проход.
-RB_PROBE_DOHS="https://1.1.1.1/dns-query https://8.8.8.8/dns-query https://77.88.8.8/dns-query"
-RB_PROBE_DOH=""
-RB_SYS_DNS=""
-RB_PARALLEL=8
-RB_BAR=60
-
-mkdir -p "$RB_RUN" 2>/dev/null
_rb_say() { echo "==> $*"; }
_rb_warn() { echo "!! $*"; }
-_rb_phase() { printf '%s\n' "$1" > "$RB_PHASE_FILE"; }
-_rb_stopped() { [ -f "$RB_STOP_FLAG" ]; }
-_rb_own() { mkdir -p "$RB_DIR"; grep -qxF "$1" "$RB_OWNED" 2>/dev/null || echo "$1" >> "$RB_OWNED"; }
-_rb_owns() { grep -qxF "$1" "$RB_OWNED" 2>/dev/null; }
-_rb_running() { [ -f "$JOBS_DIR/redbtn.pid" ] && kill -0 "$(cat "$JOBS_DIR/redbtn.pid" 2>/dev/null)" 2>/dev/null; }
-_rb_test_running() { [ -f "$JOBS_DIR/strategy_test.pid" ] && kill -0 "$(cat "$JOBS_DIR/strategy_test.pid" 2>/dev/null)" 2>/dev/null; }
+_rb_svc_ids() { grep -v '^#' "$RB_SHARE/services.conf" 2>/dev/null | cut -d'|' -f1 | grep .; }
+_rb_svc_field() { grep "^$1|" "$RB_SHARE/services.conf" 2>/dev/null | head -n1 | cut -d'|' -f"$2"; }
+_rb_svc_names() { local id; for id in $1; do printf '%s, ' "$(_rb_svc_field "$id" 2)"; done | sed 's/, $//'; }
+# Сервис можно пустить через WARP, только если у него есть списки доменов или адресов.
+_rb_routable() { [ -n "$(_rb_svc_field "$1" 3)$(_rb_svc_field "$1" 4)" ]; }
+_rb_in() { grep -qxF "$1" "$2" 2>/dev/null; }
+_job_alive() { [ -f "$JOBS_DIR/$1.pid" ] && kill -0 "$(cat "$JOBS_DIR/$1.pid" 2>/dev/null)" 2>/dev/null; }
-# Наш объект в ubus должен пережить установку пакетов. luci-proto-amneziawg в post-install
-# зовёт `rpcd reload`, следом идёт перезапуск сети, и на живом роутере (mipsel, 25.12.5) rpcd
-# поднялся без объекта zapret-manager: страница получала «Object not found» и считала роутер
-# мёртвым до конца подбора. Повторная перезагрузка rpcd возвращает объект.
+# Наш объект в ubus должен пережить установку пакетов: luci-proto-amneziawg в post-install
+# зовёт `rpcd reload`, следом идёт перезапуск сети, и rpcd поднимался без объекта
+# zapret-manager — страница получала «Object not found» до конца операции.
_rb_rpcd_ensure() {
local i=0
while [ "$i" -lt 3 ]; do
@@ -4802,31 +4757,988 @@ _rb_rpcd_ensure() {
ubus list zapret-manager >/dev/null 2>&1
}
-_rb_svc_ids() { grep -v '^#' "$RB_SHARE/services.conf" 2>/dev/null | cut -d'|' -f1 | grep .; }
-_rb_svc_field() { grep "^$1|" "$RB_SHARE/services.conf" 2>/dev/null | head -n1 | cut -d'|' -f"$2"; }
-# ── что мешает кнопке ──
+
+# ───────────────────────── steer: выбранные сервисы через WARP ─────────────────────────
+#
+# Страница «steer» ставит и связывает всё сама: движок steer (github.com/xyzmean/steer),
+# AmneziaWG, ключи Cloudflare WARP, интерфейс zmwarp с зоной firewall (NAT) и правила, по
+# которым в туннель уходят ТОЛЬКО выбранные сервисы; остальной трафик идёт как шёл.
+#
+# Автообход этим пользуется, если steer установлен: сервис, который не открыл Zapret, но
+# открывает WARP, он сам добавляет в выбор. То, что человек из выбора убрал (ST_SKIP), он
+# обратно не добавляет.
+#
+# ЧЕГО steer ЗДЕСЬ НЕ ДЕЛАЕТ:
+# - не трогает DNS роутера: спор с перехватом порта 53 страницей DoH показывается и
+# чинится только по нажатию;
+# - не пишет поверх чужой спеки steer (splify2 или настроенной руками);
+# - не держит движок включённым без правил: пакет при установке включает его сразу, а он и
+# с пустой спекой заворачивает DNS сети на себя (на 25.12.5 так отваливался DNS по IPv6);
+# - записывает всё, что поставил и поменял (ST_OWNED), и снимает ровно это при удалении.
+
+ST_DIR="/etc/zm-steer"
+ST_OWNED="$ST_DIR/owned"
+ST_SEL="$ST_DIR/services"
+ST_SKIP="$ST_DIR/skip"
+ST_OFF="$ST_DIR/stopped"
+ST_WARP_CONF="$ST_DIR/warp.conf"
+ST_RUN="$JOBS_DIR/steer"
+ST_STOP_FLAG="$ST_RUN/stop"
+ST_PHASE_FILE="$ST_RUN/phase"
+ST_WARP_IF="zmwarp"
+ST_WARP_ZONE="zmwarp"
+ST_STEER_VER="1.5.7"
+ST_STEER_SPEC="/etc/steer/spec.json"
+ST_STEER_URLS="https://github.com/xyzmean/steer/releases/download/v@VER@ https://gitlab.com/xyzmean/steer/-/raw/dist https://raw.githubusercontent.com/xyzmean/steer/dist"
+ST_AWG_MIRRORS="${GH_MAIN}/Slava-Shchipunov/awg-openwrt/releases/download ${GH_MAIN}/2Grey/awg-openwrt/releases/download"
+ST_AWG_MIRROR_FLAT="https://gitlab.com/xyzmean/brb/-/raw/main/deps/awg"
+# Автоперезапуск: строка crontab помечается хвостом-комментарием. Команда самодостаточна —
+# переживает и удаление панели: туннель поднимается заново, steer перезапускается, если включён.
+ST_CRON_TAG="# zm-steer"
+ST_CRON_CMD="/etc/init.d/steer enabled && { ifup $ST_WARP_IF; sleep 15; /etc/init.d/steer restart; }"
+# Российские колонии Cloudflare: туннель через них геоблок не снимает, берутся только в запас.
+ST_RU_COLOS="DME SVX LED KJA REN OVB KZN AER VVO"
+# Что выбрать при первой установке, если ничего не выбрано: ИИ-сервисы закрывает геоблок,
+# и кроме туннеля им ничего не поможет.
+ST_DEFAULT_SEL="geoblock"
+
+_st_phase() { printf '%s\n' "$1" > "$ST_PHASE_FILE"; }
+_st_stopped() { [ -f "$ST_STOP_FLAG" ]; }
+_st_own() { mkdir -p "$ST_DIR"; grep -qxF "$1" "$ST_OWNED" 2>/dev/null || echo "$1" >> "$ST_OWNED"; }
+_st_owns() { grep -qxF "$1" "$ST_OWNED" 2>/dev/null; }
+_st_running() { _job_alive steer; }
+_st_installed() { _st_owns "net $ST_WARP_IF" && command -v steer >/dev/null 2>&1; }
+# Готов принимать сервисы: поставлен, не выключен человеком и ничто не мешает.
+_st_ready() { _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ]; }
+_st_sel() { [ -s "$ST_SEL" ] || return 0; local id; for id in $(cat "$ST_SEL"); do _rb_routable "$id" && echo "$id"; done; }
+
+# Переезд со старых версий (красная кнопка 1.34, автообход 1.35–1.36), где туннель и steer
+# записывались в каталог автообхода. Раньше переезд шёл, только пока каталога steer ещё нет, —
+# и если он успевал появиться (выбор списков до установки), старые правила так и оставались
+# «чужими»: страница steer и автообход отказывались их трогать. Теперь смотрим на сами записи.
+_st_migrate() {
+ local old=/etc/zm-redbtn re='^(pkg (steer|kmod-amneziawg|amneziawg-tools|luci-proto-amneziawg)|net zmwarp|fw zmwarp|steer-spec)$' f id l
+ grep -qE "$re" "$old/owned" 2>/dev/null || return 0
+ mkdir -p "$ST_DIR"
+ grep -E "$re" "$old/owned" | while read -r l; do _rb_in "$l" "$ST_OWNED" || echo "$l" >> "$ST_OWNED"; done
+ sed -i -E "/$re/d" "$old/owned"
+ for f in warp.conf warp.colo spec.before; do [ -f "$old/$f" ] && [ ! -f "$ST_DIR/$f" ] && mv "$old/$f" "$ST_DIR/$f"; done
+ if [ ! -s "$ST_SEL" ]; then
+ {
+ grep '|warp$' "$old/services" 2>/dev/null | cut -d'|' -f1
+ cat "$old/warp.pick" 2>/dev/null
+ } | sort -u | while read -r id; do _rb_in "$id" "$old/warp.skip" || echo "$id"; done > "$ST_SEL"
+ fi
+ [ -s "$old/warp.skip" ] && [ ! -s "$ST_SKIP" ] && mv "$old/warp.skip" "$ST_SKIP"
+ rm -f "$old/warp.pick" "$old/warp.skip"
+ sed -i 's/# zm-autobypass$/# zm-steer/' "$CRON_FILE" 2>/dev/null
+ # Красная кнопка 1.34 из форка держала для ИИ-сервисов свой DNS (zm-geodns). Его сторож
+ # звал команду, которой здесь нет: умри прокси — и ИИ-сервисы перестали бы разрешаться
+ # вовсе. Теперь они идут через WARP, поэтому этот DNS выключаем.
+ if grep -qx 'svc zm-geodns' "$old/owned" 2>/dev/null || [ -x /etc/init.d/zm-geodns ]; then
+ /etc/init.d/zm-geodns stop >/dev/null 2>&1
+ /etc/init.d/zm-geodns disable >/dev/null 2>&1
+ rm -f /etc/init.d/zm-geodns "$old/geo.resolver"
+ sed -i '/redbtn_geo_watch/d' "$CRON_FILE" 2>/dev/null
+ sed -i '/^svc zm-geodns$/d' "$old/owned" 2>/dev/null
+ fi
+}
+_st_migrate
+mkdir -p "$ST_RUN" 2>/dev/null
+
+# ── что мешает ──
#
# splify2 держит ту же спеку steer и сам ведёт туннели и списки: два хозяина одной спеки
-# затирали бы друг друга. Zapret2 несовместим с тестером zapret.
-_rb_blocker() {
+# затирали бы друг друга.
+_st_blocker() {
if [ -x /etc/init.d/splify2 ] || ubus list splify2 >/dev/null 2>&1; then echo splify2; return; fi
- if [ -f /etc/init.d/zapret2 ] && [ ! -f "$EXPERT_MODE_FILE" ]; then echo zapret2; return; fi
- if _rb_spec_foreign; then echo steer; return; fi
+ if _st_spec_foreign; then echo steer; return; fi
echo ""
}
# Спека steer чужая: она есть, в ней есть каналы, и писали её не мы.
-_rb_spec_foreign() {
- [ -s "$RB_STEER_SPEC" ] || return 1
- _rb_owns "steer-spec" && return 1
- grep -q '"channels"[[:space:]]*:[[:space:]]*\[[[:space:]]*{' "$RB_STEER_SPEC"
+_st_spec_foreign() {
+ [ -s "$ST_STEER_SPEC" ] || return 1
+ _st_owns "steer-spec" && return 1
+ # Выход zm_warp пишет только Zapret Manager (любая его версия): такая спека наша, даже если
+ # запись о ней потерялась при обновлении, — забираем её обратно, а не отказываемся работать.
+ if grep -q '"zm_warp"' "$ST_STEER_SPEC"; then _st_own "steer-spec"; return 1; fi
+ grep -q '"channels"[[:space:]]*:[[:space:]]*\[[[:space:]]*{' "$ST_STEER_SPEC"
}
+
+# ── пакеты ──
+
+_rb_arch() { awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release; }
+_rb_release() { awk -F\' '/DISTRIB_RELEASE/ {print $2}' /etc/openwrt_release; }
+_rb_target() { awk -F\' '/DISTRIB_TARGET/ {print $2}' /etc/openwrt_release | tr '/' '_'; }
+
+# Скачать и проверить, что это пакет, а не HTML-страница ошибки с кодом 200.
+_rb_fetch_pkg() { # URL ФАЙЛ
+ rm -f "$2"
+ curl -fsSL --connect-timeout 8 --max-time 120 -o "$2" "$1" 2>/dev/null || { rm -f "$2"; return 1; }
+ [ -s "$2" ] || { rm -f "$2"; return 1; }
+ if head -c 512 "$2" | grep -qi '/dev/null | head -n1 | awk '{print $2}'; }
+
+_st_install_steer() {
+ if command -v steer >/dev/null 2>&1; then
+ _rb_say "Движок steer уже установлен: $(_st_steer_ver)"
+ return 0
+ fi
+ local arch tmp base url ver
+ arch="$(_rb_arch)"
+ [ -n "$arch" ] || { echo "ОШИБКА: не удалось определить архитектуру роутера"; return 1; }
+ tmp="$ST_RUN/steer.$RAZ"
+ _rb_say "Устанавливаем движок steer $ST_STEER_VER"
+ $UPDATE >/dev/null 2>&1
+ for base in $ST_STEER_URLS; do
+ ver="$ST_STEER_VER"
+ url="$(echo "$base" | sed "s/@VER@/$ver/")/steer-${ver}-1_${arch}.${RAZ}"
+ if ! _rb_fetch_pkg "$url" "$tmp"; then
+ # На зеркале может лежать выпуск на шаг старше — берём тот, что там есть.
+ case "$base" in *@VER@*) continue ;; esac
+ ver=$(curl -fsSL --connect-timeout 8 --max-time 15 "$base/VERSION" 2>/dev/null | tr -d '[:space:]')
+ echo "$ver" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || continue
+ _rb_fetch_pkg "$base/steer-${ver}-1_${arch}.${RAZ}" "$tmp" || continue
+ fi
+ if $INSTALL "$tmp" >/dev/null 2>&1; then
+ rm -f "$tmp"
+ _st_own "pkg steer"
+ # Пакет включает движок сразу, а движок и с пустой спекой заворачивает DNS сети на
+ # свой резолвер. Включается он только вместе с правилами (_st_spec_apply).
+ /etc/init.d/steer stop >/dev/null 2>&1
+ /etc/init.d/steer disable >/dev/null 2>&1
+ _rb_rpcd_ensure
+ _rb_say "Движок steer $ver установлен"
+ return 0
+ fi
+ done
+ rm -f "$tmp"
+ echo "ОШИБКА: не удалось установить движок steer"
+ return 1
+}
+
+_st_awg_loaded() { grep -q '^amneziawg ' /proc/modules 2>/dev/null || [ -d /sys/module/amneziawg ]; }
+
+_st_install_awg() {
+ if _st_awg_loaded && command -v awg >/dev/null 2>&1; then return 0; fi
+ if ! _pkg_is_installed kmod-amneziawg || ! command -v awg >/dev/null 2>&1; then
+ _rb_say "Устанавливаем AmneziaWG"
+ local rel arch tgt p base ok=0 bases m
+ rel="$(_rb_release)"; arch="$(_rb_arch)"; tgt="$(_rb_target)"
+ bases=""
+ for m in $ST_AWG_MIRRORS; do bases="$bases $m/v$rel"; done
+ bases="$bases $ST_AWG_MIRROR_FLAT/$rel"
+ $UPDATE >/dev/null 2>&1
+ for base in $bases; do
+ ok=1
+ for p in kmod-amneziawg amneziawg-tools; do
+ _pkg_is_installed "$p" && continue
+ if _rb_fetch_pkg "$base/${p}_v${rel}_${arch}_${tgt}.${RAZ}" "$ST_RUN/$p.$RAZ" &&
+ $INSTALL "$ST_RUN/$p.$RAZ" >/dev/null 2>&1; then
+ _st_own "pkg $p"
+ else
+ ok=0
+ fi
+ rm -f "$ST_RUN/$p.$RAZ"
+ done
+ [ "$ok" = 1 ] && break
+ done
+ # Обработчик для веб-интерфейса — чтобы туннель было видно в «Сеть → Интерфейсы».
+ # Без него всё работает, поэтому неудача здесь не ошибка.
+ if [ "$ok" = 1 ] && ! _pkg_is_installed luci-proto-amneziawg; then
+ _rb_fetch_pkg "$base/luci-proto-amneziawg_v${rel}_${arch}_${tgt}.${RAZ}" "$ST_RUN/lpa.$RAZ" &&
+ $INSTALL "$ST_RUN/lpa.$RAZ" >/dev/null 2>&1 && _st_own "pkg luci-proto-amneziawg"
+ rm -f "$ST_RUN/lpa.$RAZ"
+ fi
+ _rb_rpcd_ensure
+ fi
+ # Установщик пакета модуль в ядро не грузит, а /etc/init.d/kmod грузит его только при
+ # загрузке: без modprobe интерфейс не поднимается до перезагрузки роутера.
+ _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1
+ if ! _st_awg_loaded || ! command -v awg >/dev/null 2>&1; then
+ echo "ОШИБКА: не удалось установить AmneziaWG — для вашей версии OpenWrt может не быть готового пакета"
+ return 1
+ fi
+ # netifd узнаёт о протоколе amneziawg только при своём запуске.
+ if ! ubus call network get_proto_handlers 2>/dev/null | grep -q '"amneziawg"'; then
+ _rb_say "Перезапускаем сеть, чтобы она узнала протокол AmneziaWG (страница может ненадолго пропасть)"
+ /etc/init.d/network restart >/dev/null 2>&1
+ sleep 8
+ _rb_rpcd_ensure
+ fi
+ return 0
+}
+
+# ── WARP ──
+
+_st_warp_field() { sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$ST_WARP_CONF" | head -n1; }
+
+# Ключи WARP — напрямую у Cloudflare, как это делает wgcf: свой ключ генерирует awg, Cloudflare
+# в ответ называет свой ключ и адрес. Разбор — jsonfilter, он есть в каждой OpenWrt, jq не
+# нужен. Не вышло — запасные генераторы страницы Mixomo (сторонние сайты).
+ST_WARP_API="https://api.cloudflareclient.com/v0a2158/reg https://api.cloudflareclient.com/v0a1922/reg"
+
+_st_warp_conf_write() { # ПРИВАТНЫЙ ПИР v4 v6
+ mkdir -p "$ST_DIR"
+ printf '%s\n' \
+ "[Interface]" "PrivateKey = $1" "Address = ${3}${4:+, $4}" "MTU = 1280" \
+ "S1 = $MIXOMO_AWG_S1" "S2 = $MIXOMO_AWG_S2" "Jc = $MIXOMO_AWG_JC" "Jmin = $MIXOMO_AWG_JMIN" "Jmax = $MIXOMO_AWG_JMAX" \
+ "H1 = $MIXOMO_AWG_H1" "H2 = $MIXOMO_AWG_H2" "H3 = $MIXOMO_AWG_H3" "H4 = $MIXOMO_AWG_H4" "I1 = $MIXOMO_AWG_I1" "" \
+ "[Peer]" "PublicKey = $2" "AllowedIPs = 0.0.0.0/0" "Endpoint = engage.cloudflareclient.com:4500" "PersistentKeepalive = 25" \
+ > "$ST_WARP_CONF"
+ chmod 600 "$ST_WARP_CONF"
+}
+
+_st_warp_register() {
+ [ -s "$ST_WARP_CONF" ] && [ -n "$(_st_warp_field PrivateKey)" ] && return 0
+ mkdir -p "$ST_DIR" "$ST_RUN"
+ local priv pub api reg="$ST_RUN/reg.json" peer v4 v6 tos
+ if command -v awg >/dev/null 2>&1 && command -v jsonfilter >/dev/null 2>&1; then
+ priv="$(awg genkey 2>/dev/null)"
+ pub="$(printf '%s' "$priv" | awg pubkey 2>/dev/null)"
+ tos="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
+ for api in $ST_WARP_API; do
+ [ -n "$pub" ] || break
+ rm -f "$reg"
+ curl -fsS --connect-timeout 8 --max-time 25 -X POST \
+ -H 'User-Agent: okhttp/3.12.1' -H 'CF-Client-Version: a-6.10-2158' -H 'Content-Type: application/json' \
+ -d "{\"install_id\":\"\",\"tos\":\"$tos\",\"key\":\"$pub\",\"fcm_token\":\"\",\"type\":\"ios\",\"locale\":\"en_US\"}" \
+ -o "$reg" "$api" 2>/dev/null || continue
+ peer="$(jsonfilter -i "$reg" -e '@.config.peers[0].public_key' 2>/dev/null)"
+ v4="$(jsonfilter -i "$reg" -e '@.config.interface.addresses.v4' 2>/dev/null)"
+ v6="$(jsonfilter -i "$reg" -e '@.config.interface.addresses.v6' 2>/dev/null)"
+ if [ -n "$peer" ] && [ -n "$v4" ]; then
+ _st_warp_conf_write "$priv" "$peer" "$v4" "$v6"
+ rm -f "$reg"
+ _rb_say "Ключи WARP получены у Cloudflare"
+ return 0
+ fi
+ done
+ rm -f "$reg"
+ _rb_warn "Cloudflare не выдал ключи напрямую — пробуем запасные генераторы"
+ fi
+ # Тот же генератор, что у страницы Mixomo, но в свой файл: чужой /root/WARP.conf не трогаем.
+ ( MIXOMO_WARP_CONF="$ST_WARP_CONF"; $UPDATE >/dev/null 2>&1; do_mixomo_warp_register manual ) || return 1
+ chmod 600 "$ST_WARP_CONF"
+ [ -n "$(_st_warp_field PrivateKey)" ] && [ -n "$(_st_warp_field PublicKey)" ]
+}
+
+# Кандидаты в точку входа: адреса Cloudflare, ответившие быстрее всех по HTTP. Это только
+# отсев — годность точки решает настоящее рукопожатие туннеля и данные через него.
+_st_warp_candidates() {
+ local prefixes="188.114.96. 188.114.97. 188.114.98. 188.114.99. 162.159.192. 162.159.193. 162.159.195. 8.34.146. 8.39.214. 8.39.204. 8.6.112. 8.35.211. 8.39.125. 8.47.69."
+ local pings="$ST_RUN/pings" ip count=0
+ rm -f "$pings"
+ for ip in $(awk -v prefixes="$prefixes" 'BEGIN { srand(); n = split(prefixes, arr, " "); for (i = 0; i < 40; i++) { print arr[int(rand() * n) + 1] int(rand() * 256) } }'); do
+ (
+ t=$(curl -s --connect-timeout 2 -o /dev/null -w '%{time_total}' -H "Host: trace.cloudflare.com" "http://$ip/cdn-cgi/trace" 2>/dev/null) || exit 0
+ [ -n "$t" ] && echo "$t $ip" | awk '{printf "%d %s\n", $1 * 1000, $2}' >> "$pings"
+ ) &
+ count=$((count + 1))
+ [ $((count % 20)) -eq 0 ] && wait
+ done
+ wait
+ [ -s "$pings" ] && sort -n "$pings" | head -n 5 | awk '{print $2}'
+ echo "engage.cloudflareclient.com"
+}
+
+_st_warp_iface_write() { # ХОСТ ПОРТ
+ local priv addr peer
+ priv="$(_st_warp_field PrivateKey)"
+ addr="$(_st_warp_field Address | tr ',' '\n' | sed 's/[[:space:]]//g' | grep -v ':' | head -n1)"
+ peer="$(_st_warp_field PublicKey)"
+ case "$addr" in */*) ;; *) addr="$addr/32" ;; esac
+ uci -q delete "network.$ST_WARP_IF"
+ uci -q delete "network.${ST_WARP_IF}_peer"
+ uci set "network.$ST_WARP_IF=interface"
+ uci set "network.$ST_WARP_IF.proto=amneziawg"
+ uci set "network.$ST_WARP_IF.private_key=$priv"
+ uci add_list "network.$ST_WARP_IF.addresses=$addr"
+ uci set "network.$ST_WARP_IF.mtu=1280"
+ uci set "network.$ST_WARP_IF.awg_jc=$MIXOMO_AWG_JC"
+ uci set "network.$ST_WARP_IF.awg_jmin=$MIXOMO_AWG_JMIN"
+ uci set "network.$ST_WARP_IF.awg_jmax=$MIXOMO_AWG_JMAX"
+ uci set "network.$ST_WARP_IF.awg_s1=$MIXOMO_AWG_S1"
+ uci set "network.$ST_WARP_IF.awg_s2=$MIXOMO_AWG_S2"
+ uci set "network.$ST_WARP_IF.awg_h1=$MIXOMO_AWG_H1"
+ uci set "network.$ST_WARP_IF.awg_h2=$MIXOMO_AWG_H2"
+ uci set "network.$ST_WARP_IF.awg_h3=$MIXOMO_AWG_H3"
+ uci set "network.$ST_WARP_IF.awg_h4=$MIXOMO_AWG_H4"
+ uci set "network.$ST_WARP_IF.awg_i1=$MIXOMO_AWG_I1"
+ uci set "network.${ST_WARP_IF}_peer=amneziawg_$ST_WARP_IF"
+ uci set "network.${ST_WARP_IF}_peer.description=WARP"
+ uci set "network.${ST_WARP_IF}_peer.public_key=$peer"
+ uci add_list "network.${ST_WARP_IF}_peer.allowed_ips=0.0.0.0/0"
+ # Маршрутов в главную таблицу не ставим: трафик в туннель уводит движок, остальное
+ # идёт как шло.
+ uci set "network.${ST_WARP_IF}_peer.route_allowed_ips=0"
+ uci set "network.${ST_WARP_IF}_peer.persistent_keepalive=25"
+ uci set "network.${ST_WARP_IF}_peer.endpoint_host=$1"
+ uci set "network.${ST_WARP_IF}_peer.endpoint_port=$2"
+ uci commit network
+ _st_own "net $ST_WARP_IF"
+}
+
+# Зона firewall туннеля: NAT и выравнивание MSS. Без NAT устройства сети уходят в туннель
+# со своими адресами из LAN, и ответы не возвращаются.
+_st_warp_zone() {
+ if [ "$(uci -q get "firewall.$ST_WARP_ZONE")" != "zone" ]; then
+ uci set "firewall.$ST_WARP_ZONE=zone"
+ uci set "firewall.$ST_WARP_ZONE.name=$ST_WARP_ZONE"
+ uci add_list "firewall.$ST_WARP_ZONE.network=$ST_WARP_IF"
+ uci set "firewall.$ST_WARP_ZONE.input=REJECT"
+ uci set "firewall.$ST_WARP_ZONE.output=ACCEPT"
+ uci set "firewall.$ST_WARP_ZONE.forward=REJECT"
+ uci set "firewall.$ST_WARP_ZONE.masq=1"
+ uci set "firewall.$ST_WARP_ZONE.mtu_fix=1"
+ uci set "firewall.${ST_WARP_ZONE}_fwd=forwarding"
+ uci set "firewall.${ST_WARP_ZONE}_fwd.src=lan"
+ uci set "firewall.${ST_WARP_ZONE}_fwd.dest=$ST_WARP_ZONE"
+ uci commit firewall
+ _st_own "fw $ST_WARP_ZONE"
+ /etc/init.d/firewall reload >/dev/null 2>&1
+ fi
+}
+
+_st_warp_hs_epoch() { awg show "$ST_WARP_IF" latest-handshakes 2>/dev/null | awk '{print $2}' | head -n1; }
+
+_st_warp_handshake() { # ждать рукопожатия до 10 с
+ local i=0 hs
+ while [ "$i" -lt 10 ]; do
+ hs=$(_st_warp_hs_epoch)
+ [ -n "$hs" ] && [ "$hs" != 0 ] && return 0
+ ping -c1 -W1 -I "$ST_WARP_IF" 1.1.1.1 >/dev/null 2>&1
+ i=$((i + 1))
+ done
+ return 1
+}
+
+# Колония Cloudflare ИЗНУТРИ туннеля. Ответ означает, что данные через туннель идут, а не
+# только прошло рукопожатие.
+_st_warp_colo() {
+ curl -s --interface "$ST_WARP_IF" --connect-timeout 5 --max-time 8 https://1.1.1.1/cdn-cgi/trace 2>/dev/null |
+ sed -n 's/^colo=//p' | head -n1
+}
+
+_st_warp_is_ru() { case " $ST_RU_COLOS " in *" $1 "*) return 0 ;; esac; return 1; }
+
+_st_warp_try() { # ХОСТ ПОРТ -> печатает колонию
+ local colo
+ uci set "network.${ST_WARP_IF}_peer.endpoint_host=$1"
+ uci set "network.${ST_WARP_IF}_peer.endpoint_port=$2"
+ uci commit network
+ # ifup сам перечитывает настройку сети (ubus call network reload) и перезапускает интерфейс.
+ ifup "$ST_WARP_IF" >/dev/null 2>&1
+ sleep 3
+ _st_warp_handshake || return 1
+ colo="$(_st_warp_colo)"
+ [ -n "$colo" ] || return 1
+ echo "$colo"
+}
+
+_st_warp_up() { # [repick] — не пробовать прежнюю точку входа, подобрать заново
+ local repick="$1" host port colo spare="" cand
+ _st_install_awg || return 1
+ _rb_say "Получаем ключи WARP"
+ _st_warp_register || { echo "ОШИБКА: не удалось получить ключи WARP"; return 1; }
+ [ "$(uci -q get "network.$ST_WARP_IF.proto")" = amneziawg ] || _st_warp_iface_write engage.cloudflareclient.com 4500
+ _st_warp_zone
+ if [ "$repick" != repick ]; then
+ # Прежняя точка работает — не перебираем заново.
+ ifup "$ST_WARP_IF" >/dev/null 2>&1
+ sleep 3
+ if _st_warp_handshake && colo="$(_st_warp_colo)" && [ -n "$colo" ] && ! _st_warp_is_ru "$colo"; then
+ _rb_say "Туннель WARP работает, колония $colo"
+ echo "$colo" > "$ST_DIR/warp.colo"
+ return 0
+ fi
+ fi
+ _rb_say "Подбираем точку входа WARP"
+ cand="$(_st_warp_candidates)"
+ _rb_say "Кандидатов: $(echo "$cand" | grep -c .)"
+ # Порты 4500 и 2408 выколоты из игрового фильтра zapret — туннель не попадёт под обработку.
+ for port in 4500 2408; do
+ for host in $cand; do
+ _st_stopped && return 1
+ echo " Пробуем $host:$port"
+ colo="$(_st_warp_try "$host" "$port")" || { echo " не отвечает"; continue; }
+ echo " работает, колония $colo"
+ if _st_warp_is_ru "$colo"; then
+ [ -n "$spare" ] || spare="$host $port $colo"
+ continue
+ fi
+ _rb_say "Туннель WARP работает: $host:$port, колония $colo"
+ echo "$colo" > "$ST_DIR/warp.colo"
+ return 0
+ done
+ done
+ if [ -n "$spare" ]; then
+ set -- $spare
+ _st_warp_try "$1" "$2" >/dev/null
+ _rb_warn "Туннель WARP работает только через российскую колонию $3: ИИ-сервисы через него не откроются"
+ echo "$3" > "$ST_DIR/warp.colo"
+ return 0
+ fi
+ ifdown "$ST_WARP_IF" >/dev/null 2>&1
+ rm -f "$ST_DIR/warp.colo"
+ echo "ОШИБКА: туннель WARP не поднялся ни через одну точку входа"
+ return 1
+}
+
+
+# ── спека steer ──
+
+_st_json_list() { # файлы через пробел -> "a","b"
+ local f out=""
+ for f in $1; do [ -s "$RB_SHARE/lists/$f" ] && out="$out${out:+,}\"$RB_SHARE/lists/$f\""; done
+ printf '%s' "$out"
+}
+
+_st_spec_build() { # ID... -> JSON в stdout
+ local id name dom pre match chans="" sep=""
+ for id in "$@"; do
+ name="$(_rb_svc_field "$id" 2)"
+ dom="$(_st_json_list "$(_rb_svc_field "$id" 3 | tr ',' ' ')")"
+ pre="$(_st_json_list "$(_rb_svc_field "$id" 4 | tr ',' ' ')")"
+ match=""
+ [ -n "$dom" ] && match="\"domains_files\":[$dom]"
+ [ -n "$pre" ] && match="$match${match:+,}\"prefixes_files\":[$pre]"
+ [ -n "$match" ] || continue
+ chans="$chans$sep{\"name\":\"$name\",\"out\":\"zm_warp\",\"match\":{$match}}"
+ sep=","
+ done
+ [ -n "$chans" ] || return 1
+ printf '{"schema":1,"lan_devices":["br-lan"],"outputs":{"zm_warp":{"kind":"interface","devices":["%s"],"on_fail":"direct"}},"channels":[%s]}\n' \
+ "$ST_WARP_IF" "$chans"
+}
+
+_st_spec_apply() { # ID...
+ local tmp="$ST_RUN/spec.json" out
+ _st_spec_build "$@" > "$tmp" || return 1
+ if ! out=$(steer apply --spec "$tmp" --dry-run 2>&1 >/dev/null); then
+ echo "ОШИБКА: движок steer отверг настройку"
+ printf '%s\n' "$out" | tail -n 5
+ return 1
+ fi
+ mkdir -p /etc/steer
+ if [ -s "$ST_STEER_SPEC" ] && ! _st_owns "steer-spec" && [ ! -f "$ST_DIR/spec.before" ]; then
+ cp "$ST_STEER_SPEC" "$ST_DIR/spec.before"
+ fi
+ if [ -s "$ST_STEER_SPEC" ] && cmp -s "$tmp" "$ST_STEER_SPEC" && /etc/init.d/steer running >/dev/null 2>&1; then
+ _rb_say "Правила steer не изменились"
+ return 0
+ fi
+ cp "$tmp" "$ST_STEER_SPEC.tmp" && mv "$ST_STEER_SPEC.tmp" "$ST_STEER_SPEC"
+ _st_own "steer-spec"
+ /etc/init.d/steer enable >/dev/null 2>&1
+ /etc/init.d/steer restart >/dev/null 2>&1
+ _rb_say "Правила steer применены"
+}
+
+_st_spec_clear() {
+ _st_owns "steer-spec" || return 0
+ /etc/init.d/steer stop >/dev/null 2>&1
+ /etc/init.d/steer disable >/dev/null 2>&1
+ if [ -s "$ST_DIR/spec.before" ]; then mv "$ST_DIR/spec.before" "$ST_STEER_SPEC"; else rm -f "$ST_STEER_SPEC"; fi
+ sed -i '/^steer-spec$/d' "$ST_OWNED"
+}
+
+# Перезапустить steer, если он в работе (включён нами).
+_st_kick() {
+ _st_owns "steer-spec" || return 0
+ /etc/init.d/steer enabled 2>/dev/null || return 0
+ /etc/init.d/steer restart >/dev/null 2>&1
+ _rb_say "steer перезапущен"
+}
+
+
+# ── DNS: спор за порт 53 ──
+#
+# https-dns-proxy с force_dns=1 (так его всегда пишет страница DoH) заворачивает DNS сети на
+# dnsmasq; движок заворачивает те же запросы на свой резолвер, и только там домены
+# превращаются в правила. Побеждает тот, кто зарегистрировался раньше, а проигравший молчит.
+_st_dns_conflict() {
+ [ -f /etc/config/https-dns-proxy ] || return 1
+ pidof https-dns-proxy >/dev/null 2>&1 || return 1
+ _st_owns "steer-spec" || return 1
+ # Ключа нет — у прокси умолчание единица.
+ [ "$(uci -q get https-dns-proxy.config.force_dns)" != "0" ]
+}
+
+# Перехват порта 53 DoH включён — независимо от того, стоят ли уже наши правила.
+_st_doh_force_on() {
+ [ -f /etc/config/https-dns-proxy ] || return 1
+ pidof https-dns-proxy >/dev/null 2>&1 || return 1
+ [ "$(uci -q get https-dns-proxy.config.force_dns)" != "0" ]
+}
+
+_st_doh_unforce() {
+ # Секция main 'config' — её так пишет и пакет, и страница DoH. Прежняя правка через sed
+ # ничего не делала, когда ключа force_dns в файле не было (умолчание — единица).
+ uci -q get https-dns-proxy.config >/dev/null 2>&1 || uci set https-dns-proxy.config=main
+ uci set https-dns-proxy.config.force_dns='0'
+ uci commit https-dns-proxy
+ /etc/init.d/https-dns-proxy restart >/dev/null 2>&1
+}
+
+do_steer_dns_fix() {
+ _st_doh_unforce
+ /etc/init.d/steer restart >/dev/null 2>&1
+}
+
+
+
+# Автоперезапуск по расписанию — так же, как у Mihomo.
+_st_cron_get() {
+ local line hour
+ line=$(grep -F "$ST_CRON_TAG" "$CRON_FILE" 2>/dev/null | head -n1)
+ [ -n "$line" ] || return 0
+ hour=$(echo "$line" | awk '{print $2}')
+ case "$hour" in
+ */*) echo "every:${hour#*/}" ;;
+ *) echo "daily:$hour" ;;
+ esac
+}
+
+_st_cron_set() { # off | every:N | daily:H
+ local mode="${1%%:*}" value="${1#*:}" spec
+ case "$mode" in
+ off) spec="" ;;
+ every)
+ case "$value" in
+ 2|4|6|8|12) spec="0 */$value * * *" ;;
+ *) echo '{"error":"допустимо каждые 2, 4, 6, 8 или 12 часов"}'; return 1 ;;
+ esac ;;
+ daily)
+ case "$value" in ''|*[!0-9]*) echo '{"error":"введите час от 0 до 23"}'; return 1 ;; esac
+ [ "$value" -ge 0 ] && [ "$value" -le 23 ] || { echo '{"error":"допустимый диапазон 0-23"}'; return 1; }
+ spec="0 $value * * *" ;;
+ *) echo '{"error":"неизвестный режим"}'; return 1 ;;
+ esac
+ mkdir -p "$(dirname "$CRON_FILE")"
+ touch "$CRON_FILE"
+ sed -i "\\|$ST_CRON_TAG|d" "$CRON_FILE"
+ [ -n "$spec" ] && echo "$spec $ST_CRON_CMD $ST_CRON_TAG" >> "$CRON_FILE"
+ /etc/init.d/cron enable >/dev/null 2>&1
+ /etc/init.d/cron restart >/dev/null 2>&1
+ printf '{"ok":true}\n'
+}
+
+
+
+# ── правила по выбору ──
+
+# Выключить движок и туннель, не удаляя ничего.
+_st_down() {
+ /etc/init.d/steer stop >/dev/null 2>&1
+ /etc/init.d/steer disable >/dev/null 2>&1
+ _st_owns "net $ST_WARP_IF" && ifdown "$ST_WARP_IF" >/dev/null 2>&1
+}
+
+# Применить выбор: туннель и правила, или, если ничего не выбрано, всё выключить.
+_st_apply() { # [tunnel_ready] — туннель только что проверен, второй раз не поднимаем
+ local sel
+ sel="$(_st_sel | tr '\n' ' ')"
+ if [ -z "$(echo $sel)" ]; then
+ _st_spec_clear
+ _st_down
+ _rb_say "Ничего не выбрано — туннель и steer выключены"
+ return 0
+ fi
+ [ "$1" = tunnel_ready ] || _st_warp_up || return 1
+ # Домены уходят в туннель, только если DNS устройств отвечает резолвер steer. Перехват
+ # порта 53 страницей DoH его перебивает — выключаем перехват сами: шифрованный DNS при
+ # этом работает как работал, dnsmasq по-прежнему спрашивает его.
+ if _st_doh_force_on; then
+ _st_doh_unforce
+ _rb_warn "DNS over HTTPS перехватывал DNS сети — перехват выключен, шифрованный DNS работает как прежде"
+ fi
+ _rb_say "Через WARP: $(_rb_svc_names "$sel")"
+ _st_spec_apply $sel
+}
+
+# Самопроверка: что говорит сам движок (steer diag) и идёт ли трафик через WARP на деле.
+# Печатает в журнал; код 1 — есть поломка.
+_st_selfcheck() {
+ local f="$ST_RUN/diag.json" n i v what why bad=0 trace
+ _rb_say "Проверяем, что всё работает"
+ if [ "$(_st_sel)" ]; then
+ trace="$(curl -s --interface "$ST_WARP_IF" --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)"
+ case "$trace" in
+ *warp=on*|*warp=plus*) echo "[ OK ] Туннель WARP: трафик идёт через Cloudflare ($(echo "$trace" | sed -n 's/^colo=//p'))" ;;
+ *) echo "[FAIL] Туннель WARP: трафик через него не идёт"; bad=1 ;;
+ esac
+ /etc/init.d/steer running >/dev/null 2>&1 && echo "[ OK ] Служба steer запущена" || { echo "[FAIL] Служба steer не запущена"; bad=1; }
+ fi
+ command -v steer >/dev/null 2>&1 || return $bad
+ [ -s "$ST_STEER_SPEC" ] || return $bad
+ steer diag --spec "$ST_STEER_SPEC" > "$f" 2>/dev/null
+ n=$(jsonfilter -i "$f" -e '@.checks[*].id' 2>/dev/null | wc -l)
+ i=0
+ while [ "$i" -lt "$n" ]; do
+ v=$(jsonfilter -i "$f" -e "@.checks[$i].verdict" 2>/dev/null)
+ what=$(jsonfilter -i "$f" -e "@.checks[$i].what" 2>/dev/null)
+ why=$(jsonfilter -i "$f" -e "@.checks[$i].why" 2>/dev/null)
+ case "$v" in
+ ok) echo "[ OK ] $what" ;;
+ warn) echo "!! $what${why:+ — $why}" ;;
+ fail) echo "[FAIL] $what${why:+ — $why}"; bad=1 ;;
+ esac
+ i=$((i + 1))
+ done
+ if [ "$bad" = 0 ]; then _rb_say "Проверка пройдена"; else _rb_warn "Проверка нашла поломку — подробности выше"; fi
+ return $bad
+}
+
+# Добавить сервис в выбор (зовёт автообход). Код 1 — человек его убирал, не добавляем.
+_st_sel_add() {
+ _rb_in "$1" "$ST_SKIP" && return 1
+ mkdir -p "$ST_DIR"
+ _rb_in "$1" "$ST_SEL" || echo "$1" >> "$ST_SEL"
+}
+
+# ── задачи страницы ──
+
+do_steer_install() {
+ local blk id
+ _st_phase install
+ rm -f "$ST_STOP_FLAG"
+ blk="$(_st_blocker)"
+ case "$blk" in
+ splify2) echo "ОШИБКА: установлен splify2 — туннели и списки настраиваются в нём"; return 1 ;;
+ steer) echo "ОШИБКА: у движка steer уже есть чужие правила — не перезаписываем их"; return 1 ;;
+ esac
+ _ensure_deps
+ mkdir -p "$ST_DIR"
+ _st_phase pkgs
+ _st_install_steer || return 1
+ # conntrack нужен движку, чтобы при включённой выгрузке потоков (flow offloading) уже
+ # открытые соединения тоже переходили в туннель. Не встал — не беда, это не ошибка.
+ if ! command -v conntrack >/dev/null 2>&1; then
+ $INSTALL conntrack >/dev/null 2>&1 && _st_own "pkg conntrack"
+ fi
+ _st_phase awg
+ _st_install_awg || return 1
+ _st_phase keys
+ _st_warp_register || { echo "ОШИБКА: не удалось получить ключи WARP — Cloudflare и запасные генераторы не ответили"; return 1; }
+ # Ничего не выбрано — берём ИИ-сервисы и то, что автообход не смог открыть.
+ if [ -z "$(_st_sel)" ]; then
+ for id in $ST_DEFAULT_SEL $(grep '|none$' /etc/zm-redbtn/services 2>/dev/null | cut -d'|' -f1); do
+ _rb_routable "$id" && _st_sel_add "$id"
+ done
+ fi
+ rm -f "$ST_OFF"
+ _st_phase tunnel
+ _st_warp_up || return 1
+ _st_phase rules
+ _st_apply tunnel_ready || return 1
+ _st_phase check
+ sleep 2
+ _st_selfcheck
+ _rb_say "Готово, steer установлен и работает"
+}
+
+do_steer_apply() {
+ _st_phase rules
+ rm -f "$ST_STOP_FLAG"
+ _st_installed || { echo "ОШИБКА: steer ещё не установлен"; return 1; }
+ [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: спеку steer сейчас ведёт не Zapret Manager"; return 1; }
+ rm -f "$ST_OFF"
+ _st_apply || return 1
+ _st_phase check
+ sleep 2
+ _st_selfcheck
+ _rb_say "Готово"
+}
+
+do_steer_stop() {
+ _st_phase rules
+ _st_installed || { echo "ОШИБКА: steer ещё не установлен"; return 1; }
+ touch "$ST_OFF"
+ _st_down
+ _rb_say "Готово, steer и туннель выключены — всё идёт напрямую"
+}
+
+_st_need_warp() {
+ _st_owns "net $ST_WARP_IF" && [ -s "$ST_WARP_CONF" ] && return 0
+ echo "ОШИБКА: туннеля WARP ещё нет — сначала установите steer"
+ return 1
+}
+
+do_steer_warp_restart() {
+ _st_phase warp
+ _st_need_warp || return 1
+ rm -f "$ST_STOP_FLAG"
+ _rb_say "Перезапускаем туннель WARP"
+ _st_install_awg || return 1
+ ifup "$ST_WARP_IF" >/dev/null 2>&1
+ sleep 3
+ local colo=""
+ if _st_warp_handshake && colo="$(_st_warp_colo)" && [ -n "$colo" ]; then
+ echo "$colo" > "$ST_DIR/warp.colo"
+ _rb_say "Туннель WARP работает, колония $colo"
+ else
+ _rb_warn "Точка входа не отвечает — подбираем новую"
+ _st_warp_up repick || return 1
+ fi
+ _st_kick
+ _rb_say "Готово"
+}
+
+do_steer_warp_endpoint() {
+ _st_phase warp
+ _st_need_warp || return 1
+ rm -f "$ST_STOP_FLAG"
+ _st_warp_up repick || return 1
+ _st_kick
+ _rb_say "Готово, точка входа подобрана"
+}
+
+do_steer_warp_recreate() {
+ _st_phase warp
+ _st_need_warp || return 1
+ rm -f "$ST_STOP_FLAG"
+ local host port
+ host="$(uci -q get "network.${ST_WARP_IF}_peer.endpoint_host")"
+ port="$(uci -q get "network.${ST_WARP_IF}_peer.endpoint_port")"
+ _rb_say "Пересоздаём WARP с новыми ключами"
+ mv "$ST_WARP_CONF" "$ST_WARP_CONF.old"
+ if ! _st_warp_register; then
+ mv "$ST_WARP_CONF.old" "$ST_WARP_CONF"
+ echo "ОШИБКА: не удалось получить новые ключи — оставляем прежние"
+ return 1
+ fi
+ rm -f "$ST_WARP_CONF.old"
+ _st_warp_iface_write "${host:-engage.cloudflareclient.com}" "${port:-4500}"
+ _st_warp_up || return 1
+ _st_kick
+ _rb_say "Готово, WARP пересоздан"
+}
+
+do_steer_remove() {
+ _st_phase remove
+ _rb_say "Удаляем steer и туннель WARP"
+ _st_spec_clear
+ /etc/init.d/steer stop >/dev/null 2>&1
+ if _st_owns "net $ST_WARP_IF"; then
+ ifdown "$ST_WARP_IF" >/dev/null 2>&1
+ uci -q delete "network.$ST_WARP_IF"
+ uci -q delete "network.${ST_WARP_IF}_peer"
+ uci commit network
+ /etc/init.d/network reload >/dev/null 2>&1
+ fi
+ if _st_owns "fw $ST_WARP_ZONE"; then
+ uci -q delete "firewall.$ST_WARP_ZONE"
+ uci -q delete "firewall.${ST_WARP_ZONE}_fwd"
+ uci commit firewall
+ /etc/init.d/firewall reload >/dev/null 2>&1
+ fi
+ if grep -qF "$ST_CRON_TAG" "$CRON_FILE" 2>/dev/null; then
+ sed -i "\\|$ST_CRON_TAG|d" "$CRON_FILE"
+ /etc/init.d/cron restart >/dev/null 2>&1
+ fi
+ if _st_owns "pkg steer"; then
+ _rb_say "Удаляем движок steer"
+ $DELETE steer >/dev/null 2>&1
+ fi
+ _st_owns "pkg conntrack" && $DELETE conntrack >/dev/null 2>&1
+ # AmneziaWG — только если других туннелей на нём нет.
+ if ! uci show network 2>/dev/null | grep -q "\.proto='amneziawg'"; then
+ local p
+ for p in luci-proto-amneziawg amneziawg-tools kmod-amneziawg; do
+ _st_owns "pkg $p" && $DELETE "$p" >/dev/null 2>&1
+ done
+ fi
+ # Результаты автообхода «через WARP» больше не правда.
+ sed -i 's/|warp$/|none/' /etc/zm-redbtn/services 2>/dev/null
+ _rb_rpcd_ensure
+ rm -rf "$ST_DIR" "$ST_RUN"
+ _rb_say "Готово, steer удалён"
+}
+
+steer_status() {
+ local running=false phase="" blk colo="" warp_up=false installed=false ver="" run=false chans=0 dns=false
+ local host="" port="" hs="" age="" rx=0 tx=0 off=false svc="" sep="" id sel=" " skip w k r
+ _st_running && running=true
+ [ -f "$ST_PHASE_FILE" ] && phase=$(cat "$ST_PHASE_FILE")
+ blk="$(_st_blocker)"
+ _st_installed && installed=true
+ [ -f "$ST_OFF" ] && off=true
+ [ -f "$ST_DIR/warp.colo" ] && colo=$(cat "$ST_DIR/warp.colo")
+ [ -d "/sys/class/net/$ST_WARP_IF" ] && warp_up=true
+ if _st_owns "net $ST_WARP_IF"; then
+ host="$(uci -q get "network.${ST_WARP_IF}_peer.endpoint_host")"
+ port="$(uci -q get "network.${ST_WARP_IF}_peer.endpoint_port")"
+ fi
+ if [ "$warp_up" = true ] && command -v awg >/dev/null 2>&1; then
+ hs=$(_st_warp_hs_epoch)
+ [ -n "$hs" ] && [ "$hs" != 0 ] && age=$(( $(date +%s) - hs ))
+ set -- $(awg show "$ST_WARP_IF" transfer 2>/dev/null | head -n1)
+ rx="${2:-0}"; tx="${3:-0}"
+ fi
+ if command -v steer >/dev/null 2>&1; then
+ ver="$(_st_steer_ver)"
+ /etc/init.d/steer running >/dev/null 2>&1 && run=true
+ _st_owns "steer-spec" && chans=$(grep -o '"out"' "$ST_STEER_SPEC" 2>/dev/null | wc -l)
+ fi
+ _st_dns_conflict && dns=true
+ sel=" $(_st_sel | tr '\n' ' ') "
+ for id in $(_rb_svc_ids); do
+ _rb_routable "$id" || continue
+ w=false; k=false
+ case "$sel" in *" $id "*) w=true ;; esac
+ _rb_in "$id" "$ST_SKIP" && k=true
+ r="$(grep "^$id|" /etc/zm-redbtn/services 2>/dev/null | head -n1 | cut -d'|' -f2)"
+ svc="$svc$sep{\"id\":\"$id\",\"name\":\"$(esc "$(_rb_svc_field "$id" 2)")\",\"on\":$w,\"skip\":$k,\"auto\":\"$r\"}"
+ sep=","
+ done
+ printf '{"running":%s,"phase":"%s","blocker":"%s","installed":%s,"stopped":%s,"version":"%s","steer_running":%s,"channels":%s,"warp_up":%s,"warp_colo":"%s","warp_host":"%s","warp_port":"%s","warp_hs_age":"%s","warp_rx":%s,"warp_tx":%s,"autorestart":"%s","dns_conflict":%s,"redbtn_running":%s,"services":[%s]}\n' \
+ "$running" "$(esc "$phase")" "$blk" "$installed" "$off" "$(esc "$ver")" "$run" "${chans:-0}" "$warp_up" "$(esc "$colo")" \
+ "$(esc "$host")" "$(esc "$port")" "$age" "${rx:-0}" "${tx:-0}" "$(_st_cron_get)" "$dns" \
+ "$(_job_alive redbtn && echo true || echo false)" "$svc"
+}
+
+# Выбор сервисов с страницы: итоговый набор через запятую.
+_st_sel_set() {
+ local want id
+ want=" $(echo "$1" | tr ',' ' ') "
+ for id in $want; do
+ [ -n "$(_rb_svc_field "$id" 1)" ] && _rb_routable "$id" || { echo '{"error":"неизвестный сервис"}'; return 1; }
+ done
+ mkdir -p "$ST_DIR"
+ touch "$ST_SKIP"
+ # Снятое человеком запоминаем, чтобы автообход не вернул; отмеченное снова — забываем.
+ for id in $(_st_sel); do case "$want" in *" $id "*) ;; *) _rb_in "$id" "$ST_SKIP" || echo "$id" >> "$ST_SKIP" ;; esac; done
+ for id in $want; do sed -i "/^$id\$/d" "$ST_SKIP"; done
+ printf '%s\n' $want | grep . > "$ST_SEL"
+ [ -s "$ST_SKIP" ] || rm -f "$ST_SKIP"
+ return 0
+}
+
+steer_action() {
+ local action="$1" mode="$2"
+ case "$action" in
+ install|apply|start|stop|remove|warp_restart|warp_endpoint|warp_recreate|lists)
+ _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; }
+ _job_alive redbtn && { echo '{"error":"идёт автообход — дождитесь его окончания"}'; return 1; }
+ case "$action" in
+ install) job_start steer do_steer_install ;;
+ apply|start) job_start steer do_steer_apply ;;
+ stop) job_start steer do_steer_stop ;;
+ remove) job_start steer do_steer_remove ;;
+ warp_restart) job_start steer do_steer_warp_restart ;;
+ warp_endpoint) job_start steer do_steer_warp_endpoint ;;
+ warp_recreate) job_start steer do_steer_warp_recreate ;;
+ lists)
+ _st_sel_set "$mode" || return 1
+ if _st_installed && [ ! -f "$ST_OFF" ]; then job_start steer do_steer_apply
+ else printf '{"ok":true,"saved":true}\n'; fi ;;
+ esac
+ ;;
+ halt)
+ _st_running || { echo '{"error":"ничего не выполняется"}'; return 1; }
+ touch "$ST_STOP_FLAG"
+ printf '{"ok":true}\n'
+ ;;
+ restart)
+ _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; }
+ _st_owns "steer-spec" || { echo '{"error":"правил для steer нет — выберите сервисы"}'; return 1; }
+ /etc/init.d/steer enable >/dev/null 2>&1
+ /etc/init.d/steer restart >/dev/null 2>&1
+ sleep 1
+ /etc/init.d/steer running >/dev/null 2>&1 || { echo '{"error":"steer не запустился — загляните в системный журнал"}'; return 1; }
+ printf '{"ok":true}\n'
+ ;;
+ autorestart) _st_cron_set "$mode" ;;
+ diag)
+ local trace warp="none" colo=""
+ if _st_installed && [ -n "$(_st_sel)" ] && [ ! -f "$ST_OFF" ]; then
+ trace="$(curl -s --interface "$ST_WARP_IF" --connect-timeout 4 --max-time 8 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)"
+ case "$trace" in *warp=on*|*warp=plus*) warp=on ;; *) warp=off ;; esac
+ colo="$(echo "$trace" | sed -n 's/^colo=//p')"
+ fi
+ local d=""
+ if command -v steer >/dev/null 2>&1 && [ -s "$ST_STEER_SPEC" ] && _st_owns "steer-spec"; then
+ d="$(steer diag --spec "$ST_STEER_SPEC" 2>/dev/null | tr '\n' ' ')"
+ case "$d" in '{'*'}'*) ;; *) d="" ;; esac
+ fi
+ printf '{"warp":"%s","colo":"%s","diag":%s}\n' "$warp" "$(esc "$colo")" "${d:-null}"
+ ;;
+ dns_fix)
+ do_steer_dns_fix
+ printf '{"ok":true}\n'
+ ;;
+ *) echo '{"error":"неизвестное действие"}' ;;
+ esac
+}
+
+
+
+# ───────────────────────── Автообход ─────────────────────────
+#
+# Автоподбор обхода по сервисам (внутри по-прежнему redbtn: пути и имя задачи не менялись,
+# чтобы обновление панели не теряло состояние). Роутер сам проверяет каждый сервис и берёт ему
+# самый дешёвый работающий способ. Идея и каталог сервисов — из BigRedButton
+# (gitlab.com/xyzmean/brb); стратегию подбирает тестер Zapret Manager (do_test_run) и
+# применяет её его же функциями.
+#
+# ЛЕСТНИЦА, по возрастанию цены:
+# 1. напрямую — сервис открывается и без обхода;
+# 2. через Zapret — открывается при общей стратегии zapret (её подбирает тестер ZM);
+# 3. через WARP — ТОЛЬКО если установлен steer (своя страница): сервис, который WARP
+# открывает, добавляется в его выбор. Сам автообход steer не ставит;
+# 4. через веб-сокет — только Telegram: микропакет tgws (его же ставит страница TG WS Proxy).
+#
+# Не делает хуже: если подобранная стратегия открыла меньше сервисов, чем было, прежняя
+# настройка zapret возвращается.
+
+RB_DIR="/etc/zm-redbtn"
+RB_RUN="$JOBS_DIR/redbtn"
+RB_OWNED="$RB_DIR/owned"
+RB_RESULTS="$RB_DIR/services"
+RB_LAST="$RB_DIR/last"
+RB_STOP_FLAG="$RB_RUN/stop"
+RB_PHASE_FILE="$RB_RUN/phase"
+# Видео на время подбора. Настройка — вне RB_DIR, в каталоге панели.
+RB_VIDEO_FILE="/opt/zapret-manager-luci/autobypass_video"
+RB_VIDEO_LIST_URLS="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/AutoBypass/videos.txt https://cdn.jsdelivr.net/gh/StressOzz/Zapret-Manager@main/files/AutoBypass/videos.txt"
+# Свой DNS для проб: системный резолвер может отдавать подмену провайдера. Первый ответивший
+# из списка — на весь проход; ни один не ответил (или curl собран без DoH) — берём системный.
+RB_PROBE_DOHS="https://1.1.1.1/dns-query https://8.8.8.8/dns-query https://77.88.8.8/dns-query"
+RB_PROBE_DOH=""
+# Полоса локальных портов для проверки «напрямую». Пакетам из неё ставится бит 0x40000000 —
+# тот самый, по которому общий zapret пропускает пакет мимо себя (DESYNC_MARK). Так проба
+# «без обхода» не требует останавливать zapret всей сети.
+RB_LANE_DIRECT="21000-21199"
+RB_PARALLEL=8
+RB_BAR=60
+
+mkdir -p "$RB_RUN" 2>/dev/null
+
+_rb_phase() { printf '%s\n' "$1" > "$RB_PHASE_FILE"; }
+_rb_stopped() { [ -f "$RB_STOP_FLAG" ]; }
+_rb_own() { mkdir -p "$RB_DIR"; grep -qxF "$1" "$RB_OWNED" 2>/dev/null || echo "$1" >> "$RB_OWNED"; }
+_rb_owns() { grep -qxF "$1" "$RB_OWNED" 2>/dev/null; }
+_rb_running() { _job_alive redbtn; }
+_rb_test_running() { _job_alive strategy_test; }
+
+# Zapret2 несовместим с тестером zapret.
+_rb_blocker() {
+ if [ -f /etc/init.d/zapret2 ] && [ ! -f "$EXPERT_MODE_FILE" ]; then echo zapret2; return; fi
+ echo ""
+}
+
+
# ── пробы ──
#
# Успех — curl вернул 0, сервер ответил кодом (не 000 и не 451) и в теле нет признака отказа.
-# Без проверки кода brb считал «открылось» и заглушку провайдера, и 403.
_rb_probe_nft_up() {
nft delete table inet zm_rb_probe >/dev/null 2>&1
nft -f - >/dev/null 2>&1 <<-RBNFT
@@ -4844,7 +5756,7 @@ _rb_probe_nft_down() { nft delete table inet zm_rb_probe >/dev/null 2>&1; }
_rb_doh_pick() {
local u
for u in $RB_PROBE_DOHS; do
- curl -s -o /dev/null --max-time 6 --doh-url "$u" https://www.google.com/ 2>/dev/null && { echo "$u"; return 0; }
+ curl -s -o /dev/null --connect-timeout 4 --max-time 6 --doh-url "$u" https://www.google.com/ 2>/dev/null && { echo "$u"; return 0; }
done
return 1
}
@@ -4852,10 +5764,10 @@ _rb_doh_pick() {
_rb_check_one() { # ХОСТ РЕЖИМ(as_is|direct|warp) ОТКАЗ ФАЙЛ_УСПЕХОВ
local host="$1" mode="$2" deny="$3" okf="$4" body res code size rc extra=""
body="$RB_RUN/body.$$.$host"
- [ -n "$RB_PROBE_DOH" ] && [ -z "$RB_SYS_DNS" ] && extra="--doh-url $RB_PROBE_DOH"
+ [ -n "$RB_PROBE_DOH" ] && extra="--doh-url $RB_PROBE_DOH"
case "$mode" in
direct) extra="$extra --local-port $RB_LANE_DIRECT" ;;
- warp) extra="$extra --interface $RB_WARP_IF" ;;
+ warp) extra="$extra --interface $ST_WARP_IF" ;;
esac
res=$(curl -sL $extra --connect-timeout 4 --max-time 8 --speed-time 4 --speed-limit 1 --range 0-65535 \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) curl/8.0" -o "$body" -w '%{http_code} %{size_download}' "https://$host/" 2>/dev/null)
@@ -4913,7 +5825,7 @@ _rb_check_service() { # ID РЕЖИМ -> код 0, если сервис раб
_rb_check_all() { # РЕЖИМ [ПОДПИСЬ]
local id
for id in $(_rb_svc_ids); do
- case "$id" in telegram|geoblock) continue ;; esac
+ [ "$id" = telegram ] && continue
_rb_stopped && return 0
if _rb_check_service "$id" "$1"; then
echo "$id ok"
@@ -4943,453 +5855,12 @@ _rb_test_progress() {
_rb_count_ok() { printf '%s\n' "$1" | grep -c ' ok$'; }
_rb_failed() { printf '%s\n' "$1" | awk '$2=="fail"{print $1}'; }
-
-# ── пакеты ──
-
-_rb_arch() { awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release; }
-_rb_release() { awk -F\' '/DISTRIB_RELEASE/ {print $2}' /etc/openwrt_release; }
-_rb_target() { awk -F\' '/DISTRIB_TARGET/ {print $2}' /etc/openwrt_release | tr '/' '_'; }
-
-# Скачать и проверить, что это пакет, а не HTML-страница ошибки с кодом 200.
-_rb_fetch_pkg() { # URL ФАЙЛ
- rm -f "$2"
- curl -fsSL --connect-timeout 8 --max-time 120 -o "$2" "$1" 2>/dev/null || { rm -f "$2"; return 1; }
- [ -s "$2" ] || { rm -f "$2"; return 1; }
- if head -c 512 "$2" | grep -qi '/dev/null | head -n1 | awk '{print $2}'; }
-
-_rb_install_steer() {
- if command -v steer >/dev/null 2>&1; then
- _rb_say "Движок steer уже установлен: $(_rb_steer_ver)"
- return 0
- fi
- local arch file tmp base url ver
- arch="$(_rb_arch)"
- [ -n "$arch" ] || { echo "ОШИБКА: не удалось определить архитектуру роутера"; return 1; }
- tmp="$RB_RUN/steer.$RAZ"
- _rb_say "Устанавливаем движок steer $RB_STEER_VER"
- $UPDATE >/dev/null 2>&1
- for base in $RB_STEER_URLS; do
- ver="$RB_STEER_VER"
- url="$(echo "$base" | sed "s/@VER@/$ver/")/steer-${ver}-1_${arch}.${RAZ}"
- if ! _rb_fetch_pkg "$url" "$tmp"; then
- # На зеркале может лежать выпуск на шаг старше — берём тот, что там есть.
- case "$base" in *@VER@*) continue ;; esac
- ver=$(curl -fsSL --connect-timeout 8 --max-time 15 "$base/VERSION" 2>/dev/null | tr -d '[:space:]')
- echo "$ver" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || continue
- _rb_fetch_pkg "$base/steer-${ver}-1_${arch}.${RAZ}" "$tmp" || continue
- fi
- if $INSTALL "$tmp" >/dev/null 2>&1; then
- rm -f "$tmp"
- _rb_own "pkg steer"
- # Пакет включает движок сразу, а движок и с пустой спекой заворачивает DNS сети на
- # свой резолвер. На живом роутере так оборвался DNS клиентов по IPv6: запросы к
- # роутеру по v6 уходили в тайм-аут. Включается он только вместе с правилами.
- /etc/init.d/steer stop >/dev/null 2>&1
- /etc/init.d/steer disable >/dev/null 2>&1
- _rb_rpcd_ensure
- _rb_say "Движок steer $ver установлен"
- return 0
- fi
- done
- rm -f "$tmp"
- echo "ОШИБКА: не удалось установить движок steer"
- return 1
-}
-
-_rb_awg_loaded() { grep -q '^amneziawg ' /proc/modules 2>/dev/null || [ -d /sys/module/amneziawg ]; }
-
-_rb_install_awg() {
- if _rb_awg_loaded && command -v awg >/dev/null 2>&1; then return 0; fi
- if ! _pkg_is_installed kmod-amneziawg || ! command -v awg >/dev/null 2>&1; then
- _rb_say "Устанавливаем AmneziaWG"
- local rel arch tgt p base ok
- rel="$(_rb_release)"; arch="$(_rb_arch)"; tgt="$(_rb_target)"
- $UPDATE >/dev/null 2>&1
- for base in "${GH_MAIN}/2Grey/awg-openwrt/releases/download/v$rel" "$RB_AWG_MIRROR/$rel"; do
- ok=1
- for p in kmod-amneziawg amneziawg-tools; do
- _pkg_is_installed "$p" && continue
- if _rb_fetch_pkg "$base/${p}_v${rel}_${arch}_${tgt}.${RAZ}" "$RB_RUN/$p.$RAZ" &&
- $INSTALL "$RB_RUN/$p.$RAZ" >/dev/null 2>&1; then
- _rb_own "pkg $p"
- else
- ok=0
- fi
- rm -f "$RB_RUN/$p.$RAZ"
- done
- [ "$ok" = 1 ] && break
- done
- # Обработчик для веб-интерфейса — чтобы туннель было видно в «Сеть → Интерфейсы».
- # Без него всё работает, поэтому неудача здесь не ошибка.
- if [ "$ok" = 1 ] && ! _pkg_is_installed luci-proto-amneziawg; then
- _rb_fetch_pkg "$base/luci-proto-amneziawg_v${rel}_${arch}_${tgt}.${RAZ}" "$RB_RUN/lpa.$RAZ" &&
- $INSTALL "$RB_RUN/lpa.$RAZ" >/dev/null 2>&1 && _rb_own "pkg luci-proto-amneziawg"
- rm -f "$RB_RUN/lpa.$RAZ"
- fi
- fi
- # Установщик пакета модуль в ядро не грузит, а /etc/init.d/kmod грузит его только при
- # загрузке: без modprobe интерфейс не поднимается до перезагрузки роутера.
- _rb_awg_loaded || modprobe amneziawg >/dev/null 2>&1
- if ! _rb_awg_loaded || ! command -v awg >/dev/null 2>&1; then
- echo "ОШИБКА: не удалось установить AmneziaWG"
- return 1
- fi
- # netifd узнаёт о протоколе amneziawg только при своём запуске.
- if ! ubus call network get_proto_handlers 2>/dev/null | grep -q '"amneziawg"'; then
- _rb_say "Перезапускаем сеть, чтобы она узнала протокол AmneziaWG"
- /etc/init.d/network restart >/dev/null 2>&1
- sleep 8
- fi
- _rb_rpcd_ensure
- return 0
-}
-
-# ── WARP ──
-
-_rb_warp_field() { sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$RB_WARP_CONF" | head -n1; }
-
-_rb_warp_register() {
- [ -s "$RB_WARP_CONF" ] && return 0
- mkdir -p "$RB_DIR"
- # Тот же генератор, что у страницы Mixomo, но в свой файл: чужой /root/WARP.conf не трогаем.
- ( MIXOMO_WARP_CONF="$RB_WARP_CONF"; do_mixomo_warp_register manual ) || return 1
- chmod 600 "$RB_WARP_CONF"
- [ -n "$(_rb_warp_field PrivateKey)" ] && [ -n "$(_rb_warp_field PublicKey)" ]
-}
-
-# Кандидаты в точку входа: адреса Cloudflare, ответившие быстрее всех по HTTP. Это только
-# отсев — годность точки решает настоящее рукопожатие туннеля и данные через него.
-_rb_warp_candidates() {
- local prefixes="188.114.96. 188.114.97. 188.114.98. 188.114.99. 162.159.192. 162.159.193. 162.159.195. 8.34.146. 8.39.214. 8.39.204. 8.6.112. 8.35.211. 8.39.125. 8.47.69."
- local pings="$RB_RUN/pings" ip count=0
- rm -f "$pings"
- for ip in $(awk -v prefixes="$prefixes" 'BEGIN { srand(); n = split(prefixes, arr, " "); for (i = 0; i < 40; i++) { print arr[int(rand() * n) + 1] int(rand() * 256) } }'); do
- (
- t=$(curl -s --connect-timeout 2 -o /dev/null -w '%{time_total}' -H "Host: trace.cloudflare.com" "http://$ip/cdn-cgi/trace" 2>/dev/null) || exit 0
- [ -n "$t" ] && echo "$t $ip" | awk '{printf "%d %s\n", $1 * 1000, $2}' >> "$pings"
- ) &
- count=$((count + 1))
- [ $((count % 20)) -eq 0 ] && wait
- done
- wait
- [ -s "$pings" ] && sort -n "$pings" | head -n 5 | awk '{print $2}'
- echo "engage.cloudflareclient.com"
-}
-
-_rb_warp_iface_write() { # ХОСТ ПОРТ
- local priv addr peer
- priv="$(_rb_warp_field PrivateKey)"
- addr="$(_rb_warp_field Address | tr ',' '\n' | sed 's/[[:space:]]//g' | grep -v ':' | head -n1)"
- peer="$(_rb_warp_field PublicKey)"
- case "$addr" in */*) ;; *) addr="$addr/32" ;; esac
- uci -q delete "network.$RB_WARP_IF"
- uci -q delete "network.${RB_WARP_IF}_peer"
- uci set "network.$RB_WARP_IF=interface"
- uci set "network.$RB_WARP_IF.proto=amneziawg"
- uci set "network.$RB_WARP_IF.private_key=$priv"
- uci add_list "network.$RB_WARP_IF.addresses=$addr"
- uci set "network.$RB_WARP_IF.mtu=1280"
- uci set "network.$RB_WARP_IF.awg_jc=$MIXOMO_AWG_JC"
- uci set "network.$RB_WARP_IF.awg_jmin=$MIXOMO_AWG_JMIN"
- uci set "network.$RB_WARP_IF.awg_jmax=$MIXOMO_AWG_JMAX"
- uci set "network.$RB_WARP_IF.awg_s1=$MIXOMO_AWG_S1"
- uci set "network.$RB_WARP_IF.awg_s2=$MIXOMO_AWG_S2"
- uci set "network.$RB_WARP_IF.awg_h1=$MIXOMO_AWG_H1"
- uci set "network.$RB_WARP_IF.awg_h2=$MIXOMO_AWG_H2"
- uci set "network.$RB_WARP_IF.awg_h3=$MIXOMO_AWG_H3"
- uci set "network.$RB_WARP_IF.awg_h4=$MIXOMO_AWG_H4"
- uci set "network.$RB_WARP_IF.awg_i1=$MIXOMO_AWG_I1"
- uci set "network.${RB_WARP_IF}_peer=amneziawg_$RB_WARP_IF"
- uci set "network.${RB_WARP_IF}_peer.description=WARP"
- uci set "network.${RB_WARP_IF}_peer.public_key=$peer"
- uci add_list "network.${RB_WARP_IF}_peer.allowed_ips=0.0.0.0/0"
- # Маршрутов в главную таблицу не ставим: трафик в туннель уводит движок, остальное
- # идёт как шло.
- uci set "network.${RB_WARP_IF}_peer.route_allowed_ips=0"
- uci set "network.${RB_WARP_IF}_peer.persistent_keepalive=25"
- uci set "network.${RB_WARP_IF}_peer.endpoint_host=$1"
- uci set "network.${RB_WARP_IF}_peer.endpoint_port=$2"
- uci commit network
- _rb_own "net $RB_WARP_IF"
-}
-
-# Зона firewall туннеля: NAT и выравнивание MSS. Без NAT устройства сети уходят в туннель
-# со своими адресами из LAN, и ответы не возвращаются.
-_rb_warp_zone() {
- if [ "$(uci -q get "firewall.$RB_WARP_ZONE")" != "zone" ]; then
- uci set "firewall.$RB_WARP_ZONE=zone"
- uci set "firewall.$RB_WARP_ZONE.name=$RB_WARP_ZONE"
- uci add_list "firewall.$RB_WARP_ZONE.network=$RB_WARP_IF"
- uci set "firewall.$RB_WARP_ZONE.input=REJECT"
- uci set "firewall.$RB_WARP_ZONE.output=ACCEPT"
- uci set "firewall.$RB_WARP_ZONE.forward=REJECT"
- uci set "firewall.$RB_WARP_ZONE.masq=1"
- uci set "firewall.$RB_WARP_ZONE.mtu_fix=1"
- uci set "firewall.${RB_WARP_ZONE}_fwd=forwarding"
- uci set "firewall.${RB_WARP_ZONE}_fwd.src=lan"
- uci set "firewall.${RB_WARP_ZONE}_fwd.dest=$RB_WARP_ZONE"
- uci commit firewall
- _rb_own "fw $RB_WARP_ZONE"
- /etc/init.d/firewall reload >/dev/null 2>&1
- fi
-}
-
-_rb_warp_handshake() { # ждать рукопожатия до 10 с
- local i=0 hs
- while [ "$i" -lt 10 ]; do
- hs=$(awg show "$RB_WARP_IF" latest-handshakes 2>/dev/null | awk '{print $2}' | head -n1)
- [ -n "$hs" ] && [ "$hs" != 0 ] && return 0
- ping -c1 -W1 -I "$RB_WARP_IF" 1.1.1.1 >/dev/null 2>&1
- i=$((i + 1))
- done
- return 1
-}
-
-# Колония Cloudflare ИЗНУТРИ туннеля. Ответ означает, что данные через туннель идут, а не
-# только прошло рукопожатие.
-_rb_warp_colo() {
- # По https: http-вариант этого адреса теперь отвечает переадресацией без тела.
- curl -s --interface "$RB_WARP_IF" --connect-timeout 5 --max-time 8 https://1.1.1.1/cdn-cgi/trace 2>/dev/null |
- sed -n 's/^colo=//p' | head -n1
-}
-
-_rb_warp_is_ru() { case " $RB_RU_COLOS " in *" $1 "*) return 0 ;; esac; return 1; }
-
-_rb_warp_try() { # ХОСТ ПОРТ -> печатает колонию
- local colo
- uci set "network.${RB_WARP_IF}_peer.endpoint_host=$1"
- uci set "network.${RB_WARP_IF}_peer.endpoint_port=$2"
- uci commit network
- ifdown "$RB_WARP_IF" >/dev/null 2>&1
- sleep 1
- ifup "$RB_WARP_IF" >/dev/null 2>&1
- sleep 3
- _rb_warp_handshake || return 1
- colo="$(_rb_warp_colo)"
- [ -n "$colo" ] || return 1
- echo "$colo"
-}
-
-_rb_warp_up() {
- _rb_install_awg || return 1
- _rb_say "Получаем ключи WARP"
- _rb_warp_register || { echo "ОШИБКА: не удалось получить ключи WARP"; return 1; }
- local host port colo spare="" cand
- [ "$(uci -q get "network.$RB_WARP_IF.proto")" = amneziawg ] || _rb_warp_iface_write engage.cloudflareclient.com 4500
- _rb_warp_zone
- # Прежняя точка работает — не перебираем заново.
- host="$(uci -q get "network.${RB_WARP_IF}_peer.endpoint_host")"
- port="$(uci -q get "network.${RB_WARP_IF}_peer.endpoint_port")"
- ifup "$RB_WARP_IF" >/dev/null 2>&1
- sleep 3
- if _rb_warp_handshake && colo="$(_rb_warp_colo)" && [ -n "$colo" ] && ! _rb_warp_is_ru "$colo"; then
- _rb_say "Туннель WARP работает, колония $colo"
- echo "$colo" > "$RB_DIR/warp.colo"
- return 0
- fi
- _rb_say "Подбираем точку входа WARP"
- cand="$(_rb_warp_candidates)"
- _rb_say "Кандидатов: $(echo "$cand" | grep -c .)"
- # Порты 4500 и 2408 выколоты из игрового фильтра zapret — туннель не попадёт под обработку.
- for port in 4500 2408; do
- for host in $cand; do
- _rb_stopped && return 1
- echo " Пробуем $host:$port"
- colo="$(_rb_warp_try "$host" "$port")" || { echo " не отвечает"; continue; }
- echo " работает, колония $colo"
- if _rb_warp_is_ru "$colo"; then
- [ -n "$spare" ] || spare="$host $port $colo"
- continue
- fi
- _rb_say "Туннель WARP работает: $host:$port, колония $colo"
- echo "$colo" > "$RB_DIR/warp.colo"
- return 0
- done
- done
- if [ -n "$spare" ]; then
- set -- $spare
- _rb_warp_try "$1" "$2" >/dev/null
- _rb_warn "Туннель WARP работает только через российскую колонию $3: ИИ-сервисы через него не откроются"
- echo "$3" > "$RB_DIR/warp.colo"
- return 0
- fi
- ifdown "$RB_WARP_IF" >/dev/null 2>&1
- rm -f "$RB_DIR/warp.colo"
- echo "ОШИБКА: туннель WARP не поднялся ни через одну точку входа"
- return 1
-}
-
-# ── списки для туннеля ──
-#
-# Списки сервисов берутся наборами .srs из каталога splify2-lists (github.com/xyzmean/splify2-lists):
-# его lists.json называет для каждого набора ссылку на релиз издателя С ЗАФИКСИРОВАННЫМ тегом,
-# так что два роутера с одним каталогом уводят в туннель одно и то же. Набор раскладывает сам
-# движок (`steer srs-read`) на домены, подсети и сужение по протоколу и портам.
-#
-# Качается сначала напрямую, потом ЧЕРЕЗ ТУННЕЛЬ: списки нужны ровно тем сервисам, которые
-# уходят в WARP, значит туннель к этому моменту уже поднят, а GitHub у аудитории кнопки часто
-# закрыт. Не скачалось вовсе — берётся список из пакета: хуже свежего, но лучше никакого.
-RB_LISTS_MANIFEST="https://github.com/xyzmean/splify2-lists/releases/latest/download/lists.json"
-RB_SRS_FALLBACK="https://github.com/itdoginfo/allow-domains/releases/latest/download"
-
-_rb_fetch() { # URL ФАЙЛ — напрямую, потом через туннель
- curl -fsSL --connect-timeout 6 --max-time 60 -o "$2" "$1" 2>/dev/null && [ -s "$2" ] && return 0
- [ -d "/sys/class/net/$RB_WARP_IF" ] &&
- curl -fsSL --interface "$RB_WARP_IF" --connect-timeout 6 --max-time 60 -o "$2" "$1" 2>/dev/null && [ -s "$2" ]
-}
-
-_rb_srs_url() { # НАБОР -> ссылка из каталога или «последний релиз» издателя
- local m="$RB_RUN/lists.json" u=""
- [ -s "$m" ] || _rb_fetch "$RB_LISTS_MANIFEST" "$m" || rm -f "$m"
- [ -s "$m" ] && u=$(grep -o '"url"[[:space:]]*:[[:space:]]*"[^"]*/allow-domains/releases/download/[^"]*/'"$1"'\.srs"' "$m" |
- head -n1 | sed 's/.*"\(https[^"]*\)"$/\1/')
- echo "${u:-$RB_SRS_FALLBACK/$1.srs}"
-}
-
-# Набор -> $RB_DIR/lists/НАБОР.dom / .pfx / .meta. Код 0 — разложен.
-_rb_srs_get() { # НАБОР
- local d="$RB_DIR/lists" f
- mkdir -p "$d"
- [ -f "$RB_RUN/srs.$1.done" ] && return 0
- f="$RB_RUN/$1.srs"
- _rb_fetch "$(_rb_srs_url "$1")" "$f" || return 1
- steer srs-read "$f" --out "$d/$1.dom.tmp" --prefixes-out "$d/$1.pfx.tmp" --meta-out "$d/$1.meta.tmp" >/dev/null 2>&1 || {
- rm -f "$f" "$d/$1".*.tmp; return 1; }
- for x in dom pfx meta; do touch "$d/$1.$x.tmp"; mv "$d/$1.$x.tmp" "$d/$1.$x"; done
- rm -f "$f"
- touch "$RB_RUN/srs.$1.done"
-}
-
-# Каналы сервиса в спеке: печатает JSON-объекты через запятую (без внешних скобок).
-_rb_svc_channels() { # ID
- local id="$1" name sets set dom="" pfx="" narrow="" f proto ports ch="" sep=""
- name="$(_rb_svc_field "$id" 2)"
- sets="$(_rb_svc_field "$id" 8 | tr ',' ' ')"
- for set in $sets; do
- # Один набор на несколько сервисов (Instagram и WhatsApp — оба meta) — в спеку один раз.
- # Пометка файлом: функцию зовут в подоболочке, переменная оттуда не возвращается.
- [ -f "$RB_RUN/used.$set" ] && continue
- if _rb_srs_get "$set"; then
- touch "$RB_RUN/used.$set"
- f="$RB_DIR/lists/$set"
- [ -s "$f.dom" ] && dom="$dom${dom:+,}\"$f.dom\""
- if [ -s "$f.pfx" ]; then
- if [ -s "$f.meta" ]; then narrow="$narrow $set"; else pfx="$pfx${pfx:+,}\"$f.pfx\""; fi
- fi
- else
- _rb_warn "Список $set не скачался — беру список из пакета" >&2
- sets=""
- break
- fi
- done
- # Ни одного набора или набор не скачался — списки из пакета.
- if [ -z "$sets" ]; then
- dom="$(_rb_json_list "$(_rb_svc_field "$id" 3 | tr ',' ' ')")"
- pfx="$(_rb_json_list "$(_rb_svc_field "$id" 4 | tr ',' ' ')")"
- narrow=""
- fi
- [ -n "$dom" ] && { ch="$ch$sep{\"name\":\"$name\",\"out\":\"zm_warp\",\"match\":{\"domains_files\":[$dom]}}"; sep=","; }
- [ -n "$pfx" ] && { ch="$ch$sep{\"name\":\"$name (адреса)\",\"out\":\"zm_warp\",\"match\":{\"prefixes_files\":[$pfx]}}"; sep=","; }
- # Подсети с сужением — отдельным каналом с протоколом и портами (схема 2): без сужения
- # в туннель ушёл бы весь TCP к подсетям Cloudflare, а не только голос Discord.
- for set in $narrow; do
- f="$RB_DIR/lists/$set"
- proto=$(sed -n 's/^proto=//p' "$f.meta" | head -n1)
- ports=$(sed -n 's/^ports=//p' "$f.meta" | head -n1 | sed 's/,/","/g')
- ch="$ch$sep{\"name\":\"$name (голос)\",\"out\":\"zm_warp\",\"match\":{\"prefixes_files\":[\"$f.pfx\"]${proto:+,\"proto\":\"$proto\"}${ports:+,\"ports\":[\"$ports\"]}}}"
- sep=","
- done
- printf '%s' "$ch"
-}
-
-# ── спека steer ──
-
-_rb_json_list() { # файлы через пробел -> "a","b"
- local f out=""
- for f in $1; do [ -s "$RB_SHARE/lists/$f" ] && out="$out${out:+,}\"$RB_SHARE/lists/$f\""; done
- printf '%s' "$out"
-}
-
-_rb_spec_build() { # ID... -> JSON в stdout
- local id c chans="" schema=1
- rm -f "$RB_RUN"/used.*
- for id in "$@"; do
- c="$(_rb_svc_channels "$id")"
- [ -n "$c" ] && chans="$chans${chans:+,}$c"
- done
- [ -n "$chans" ] || return 1
- case "$chans" in *'"ports"'*|*'"proto"'*) schema=2 ;; esac
- printf '{"schema":%s,"lan_devices":["br-lan"],"outputs":{"zm_warp":{"kind":"interface","devices":["%s"],"on_fail":"direct"}},"channels":[%s]}\n' \
- "$schema" "$RB_WARP_IF" "$chans"
-}
-
-_rb_spec_apply() { # ID...
- local tmp="$RB_RUN/spec.json" out
- _rb_spec_build "$@" > "$tmp" || return 1
- if ! out=$(steer apply --spec "$tmp" --dry-run 2>&1 >/dev/null); then
- echo "ОШИБКА: движок отверг настройку"
- printf '%s\n' "$out" | tail -n 5
- return 1
- fi
- mkdir -p /etc/steer
- if [ -s "$RB_STEER_SPEC" ] && ! _rb_owns "steer-spec" && [ ! -f "$RB_DIR/spec.before" ]; then
- cp "$RB_STEER_SPEC" "$RB_DIR/spec.before"
- fi
- if [ -s "$RB_STEER_SPEC" ] && cmp -s "$tmp" "$RB_STEER_SPEC" && /etc/init.d/steer running >/dev/null 2>&1; then
- _rb_say "Правила движка не изменились"
- return 0
- fi
- cp "$tmp" "$RB_STEER_SPEC.tmp" && mv "$RB_STEER_SPEC.tmp" "$RB_STEER_SPEC"
- _rb_own "steer-spec"
- /etc/init.d/steer enable >/dev/null 2>&1
- /etc/init.d/steer restart >/dev/null 2>&1
- _rb_say "Правила движка применены"
-}
-
-_rb_spec_clear() {
- _rb_owns "steer-spec" || return 0
- /etc/init.d/steer stop >/dev/null 2>&1
- /etc/init.d/steer disable >/dev/null 2>&1
- if [ -s "$RB_DIR/spec.before" ]; then mv "$RB_DIR/spec.before" "$RB_STEER_SPEC"; else rm -f "$RB_STEER_SPEC"; fi
- sed -i '/^steer-spec$/d' "$RB_OWNED"
-}
-
-# ── DNS: спор за порт 53 ──
-#
-# https-dns-proxy с force_dns=1 (так его всегда пишет страница DoH) заворачивает DNS сети на
-# dnsmasq; движок заворачивает те же запросы на свой резолвер, и только там домены
-# превращаются в правила. Побеждает тот, кто зарегистрировался раньше, а проигравший молчит.
-_rb_dns_conflict() {
- [ -f /etc/config/https-dns-proxy ] || return 1
- # Служба самого пакета, а не любой процесс https-dns-proxy: свой экземпляр для ИИ-сервисов
- # кнопка держит отдельно (zm-geodns), и порт 53 он не трогает.
- /etc/init.d/https-dns-proxy running >/dev/null 2>&1 || return 1
- _rb_owns "steer-spec" || return 1
- # Ключа нет — у прокси умолчание единица.
- local fd
- fd=$(sed -n "s/^[[:space:]]*option force_dns '\([^']*\)'.*/\1/p" /etc/config/https-dns-proxy | head -n1)
- [ "$fd" != "0" ]
-}
-
-do_redbtn_dns_fix() {
- sed -i "s/^\([[:space:]]*option force_dns\) .*/\1 '0'/" /etc/config/https-dns-proxy
- /etc/init.d/https-dns-proxy restart >/dev/null 2>&1
- /etc/init.d/steer restart >/dev/null 2>&1
-}
-
# ── Telegram ──
#
# Сайт открывается ещё не значит, что работает приложение: оно ходит к дата-центрам по MTProto,
# и режут именно его. Поэтому каждому дата-центру шлётся настоящий первый запрос протокола —
# req_pq_multi в транспорте abridged, — и ждётся ответ. Молчание хотя бы одного — ставим tgws:
# он сам решает по каждому дата-центру, вести его напрямую или через веб-сокет.
-#
-# Код ответа curl по адресу ДЦ здесь ничего не говорит: ДЦ принимает TCP и ждёт MTProto, а не
-# TLS, так что тайм-аут у открытого и у закрытого одинаковый.
RB_TG_DCS="149.154.175.50 149.154.167.51 149.154.175.100 149.154.167.91 91.108.56.130"
_rb_mtproto_req() { # байты req_pq_multi в виде \ooo для printf
@@ -5424,6 +5895,7 @@ _rb_tg_ok() {
_rb_tg_dc_ok
}
+
# ── zapret: подбор тестером менеджера ──
# Применить победителя теста функциями менеджера.
@@ -5472,196 +5944,20 @@ _rb_result_write() { # ID СОСТОЯНИЕ
echo "$1|$2" >> "$RB_RESULTS"
}
-# ── ИИ-сервисы: только DNS ──
-#
-# Геоблок не лечится ни стратегией zapret, ни туннелем: сервис смотрит, откуда пришли, и
-# открывает его только резолвер, выдающий адрес своего прокси. Поэтому проба здесь идёт
-# СИСТЕМНЫМ DNS — тем, что лечит, — а подбор zapret и WARP этот сервис не запускает.
-#
-# РЕЗОЛВЕР — ТОЛЬКО ДЛЯ ДОМЕНОВ СПИСКА, а не для всей сети. Список — тот, что публикует
-# dns.malw.link (файл hosts его репозитория: какие имена этот резолвер разблокирует); адреса к
-# ним отдаёт выбранный резолвер, а не файл, поэтому они не стареют вместе со списком. dnsmasq
-# получает свой файл `server=/домен/127.0.0.1#5055`, остальное разрешается как раньше.
-#
-# Резолвер крутится СВОИМ экземпляром https-dns-proxy (служба zm-geodns), а не секцией в
-# /etc/config/https-dns-proxy: та принадлежит человеку и странице DoH, и её dnsmasq_config_update
-# '*' увела бы в георезолвер вообще всё. Пакет, если его ставит кнопка, свою службу тут же
-# выключает — иначе он при установке сам забирает dnsmasq и порт 53 (проверено на 25.12.5).
-RB_GEO_PORT=5055
-RB_GEO_RESOLVER="$RB_DIR/geo.resolver"
-RB_GEO_DOMAINS="$RB_DIR/geo-domains.lst"
-RB_GEO_LIST_URLS="https://raw.githubusercontent.com/ImMALWARE/dns.malw.link/refs/heads/master/hosts https://cdn.jsdelivr.net/gh/ImMALWARE/dns.malw.link@master/hosts"
-RB_GEO_FALLBACK="chatgpt.com openai.com oaistatic.com oaiusercontent.com sora.com claude.ai anthropic.com claudeusercontent.com gemini.google.com aistudio.google.com generativelanguage.googleapis.com copilot.microsoft.com grok.com x.ai perplexity.ai"
-
-_rb_geo_how() {
- if grep -qE '[[:space:]](chatgpt\.com|openai\.com)([[:space:]]|$)' /etc/hosts 2>/dev/null; then echo hosts
- else echo doh; fi
-}
-
-# Проверить один резолвер: ответил ли он и жив ли прокси, адрес которого он выдал. Проба —
-# настоящий запрос к chatgpt.com через этот резолвер: curl спрашивает DoH и подключается к
-# выданному адресу. Печатает «время_мс id», если годится.
-_rb_geo_try() { # СТРОКА_doh.conf
- local id url boot role host h port ip b res rc code t body
- IFS='|' read -r id _t url boot role <<-EOF
- $1
- EOF
- host=${url#https://}; host=${host%%/*}; h=${host%%:*}; port=443
- case "$host" in *:*) port=${host##*:} ;; esac
- # Имя самого резолвера разрешаем его bootstrap-серверами: DoH и нужен там, где системный
- # DNS врёт.
- ip=""
- for b in $(echo "$boot" | tr ',' ' '); do
- ip=$(nslookup "$h" "$b" 2>/dev/null | awk '/^Address/ && !/#/ && $2 !~ /:/ {print $2}' | tail -n1)
- [ -n "$ip" ] && break
- done
- body="$RB_RUN/geo.$id"
- res=$(curl -s --connect-timeout 5 --max-time 10 ${ip:+--resolve "$h:$port:$ip"} --doh-url "$url" \
- -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) curl/8.0" -o "$body" -w '%{http_code} %{time_total}' https://chatgpt.com/ 2>/dev/null)
- rc=$?
- code="${res%% *}"; t="${res##* }"
- if [ "$rc" = 0 ] && [ "$code" != 000 ] && ! grep -qiE "$(_rb_svc_field geoblock 7 | sed 's/,/|/g')" "$body" 2>/dev/null; then
- echo "$t $id" | awk '{printf "%d %s\n", $1 * 1000, $2}'
- fi
- rm -f "$body"
-}
-
-# Лучший резолвер пула — самый быстрый из тех, чей прокси отвечает. Все проверяются сразу.
-_rb_geo_pick() {
- local line out="$RB_RUN/geo.pick"
- : > "$out"
- while IFS= read -r line; do
- case "$line" in ''|\#*) continue ;; esac
- [ "$(echo "$line" | cut -d'|' -f5)" = geo ] || continue
- ( _rb_geo_try "$line" >> "$out" ) &
- done < "$RB_SHARE/doh.conf"
- wait
- [ -s "$out" ] || return 1
- sort -n "$out" | awk 'NR == 1 {print $2}'
-}
-
-# Список доменов: скачать (напрямую, потом через туннель), взять имена и свернуть поддомены
-# под родителя — dnsmasq-у `server=/example.com/` хватает на все поддомены.
-_rb_geo_domains() {
- local u f="$RB_RUN/geo.hosts" ok=0
- for u in $RB_GEO_LIST_URLS; do _rb_fetch "$u" "$f" && grep -q '^[0-9]' "$f" && { ok=1; break; }; done
- if [ "$ok" = 1 ]; then
- awk '/^[0-9]/ {print tolower($2)}' "$f" | sort -u | awk '
- { d[NR] = $0; have[$0] = 1 }
- END { for (i = 1; i <= NR; i++) { n = split(d[i], p, "."); c = 0
- for (j = 2; j < n; j++) { s = p[j]; for (k = j + 1; k <= n; k++) s = s "." p[k]; if (s in have) { c = 1; break } }
- if (!c) print d[i] } }' > "$RB_GEO_DOMAINS.tmp" && mv "$RB_GEO_DOMAINS.tmp" "$RB_GEO_DOMAINS"
- fi
- rm -f "$f"
- [ -s "$RB_GEO_DOMAINS" ] || { printf '%s\n' $RB_GEO_FALLBACK > "$RB_GEO_DOMAINS"; _rb_warn "Список доменов не скачался — беру короткий список ИИ-сервисов"; }
- _rb_say "Доменов для DNS ИИ-сервисов: $(grep -c . "$RB_GEO_DOMAINS")"
-}
-
-_rb_geo_enable() {
- local id line
- if [ ! -x /usr/sbin/https-dns-proxy ]; then
- local had_conf=0
- [ -s /etc/config/https-dns-proxy ] && had_conf=1
- _rb_say "Устанавливаем https-dns-proxy"
- $UPDATE >/dev/null 2>&1
- $INSTALL https-dns-proxy >/dev/null 2>&1 || { echo "ОШИБКА: https-dns-proxy не установился"; return 1; }
- _rb_own "pkg https-dns-proxy"
- # Своя служба пакета при установке сама забирает dnsmasq. Если настройки до установки не
- # было, это умолчание пакета — выключаем, stop возвращает dnsmasq как было. Если была —
- # её писал человек (или страница DoH), и dnsmasq уже на неё смотрит: выключить службу
- # значило бы оставить весь DNS роутера на мёртвых портах. Так и случилось на живом
- # роутере, пока этой проверки не было.
- if [ "$had_conf" = 0 ]; then
- /etc/init.d/https-dns-proxy stop >/dev/null 2>&1
- /etc/init.d/https-dns-proxy disable >/dev/null 2>&1
- fi
- _rb_rpcd_ensure
- fi
- _rb_say "Подбираем DNS для ИИ-сервисов"
- id="$(_rb_geo_pick)" || { echo "!! Ни один DNS не открыл ИИ-сервисы"; return 1; }
- line="$(grep "^$id|" "$RB_SHARE/doh.conf" | head -n1)"
- _rb_say "Лучший: $(echo "$line" | cut -d'|' -f2)"
- _rb_geo_domains
- {
- echo "GEO_ID='$id'"
- echo "GEO_TITLE='$(echo "$line" | cut -d'|' -f2)'"
- echo "GEO_URL='$(echo "$line" | cut -d'|' -f3)'"
- echo "GEO_BOOT='$(echo "$line" | cut -d'|' -f4)'"
- } > "$RB_GEO_RESOLVER"
- _rb_own "svc zm-geodns"
- /etc/init.d/zm-geodns enable >/dev/null 2>&1
- /etc/init.d/zm-geodns restart >/dev/null 2>&1
- # Экземпляру нужно время разрешить имя самого резолвера (bootstrap): первые секунды он
- # молчит, и проверка сразу после запуска отвечала «не открывается».
- local i=0
- while [ "$i" -lt 20 ]; do
- nslookup -type=a chatgpt.com "127.0.0.1:$RB_GEO_PORT" 2>/dev/null | grep -q '^Address: [0-9]' && break
- sleep 1; i=$((i + 1))
- done
-}
-
-_rb_geo_disable() {
- /etc/init.d/zm-geodns stop >/dev/null 2>&1
- /etc/init.d/zm-geodns disable >/dev/null 2>&1
- rm -f "$RB_GEO_RESOLVER"
-}
-
-_rb_geo_ok() {
- local ok=0
- RB_SYS_DNS=1
- _rb_check_service geoblock as_is && ok=1
- RB_SYS_DNS=""
- [ "$ok" = 1 ]
-}
-
-_rb_geo_step() {
- _rb_say "Проверяем ИИ-сервисы"
- if _rb_geo_ok; then
- _rb_result_write geoblock "$( [ -s "$RB_GEO_RESOLVER" ] && echo doh || _rb_geo_how)"
- echo "[ OK ] $(_rb_svc_field geoblock 2) — открывается"
- return 0
- fi
- if _rb_geo_enable && _rb_geo_ok; then
- _rb_result_write geoblock doh
- echo "[ OK ] $(_rb_svc_field geoblock 2) — через DoH"
- grep -q redbtn_geo_watch "$CRON_FILE" 2>/dev/null || {
- echo "*/30 * * * * /opt/zapret-manager-luci/backend.sh redbtn_geo_watch >/dev/null 2>&1" >> "$CRON_FILE"
- /etc/init.d/cron restart >/dev/null 2>&1; }
- else
- _rb_result_write geoblock none
- echo "[FAIL] $(_rb_svc_field geoblock 2) — не открывается"
- fi
-}
-
-# Кнопка «Подобрать DNS заново» и сторож раз в 30 минут: прокси резолвера может умереть.
-do_redbtn_geo_doh() {
- _rb_geo_enable || { _rb_result_write geoblock none; return 1; }
- if _rb_geo_ok; then _rb_result_write geoblock doh; _rb_say "Готово, ИИ-сервисы открываются"
- else _rb_result_write geoblock none; echo "!! ИИ-сервисы не открываются"; fi
-}
-
-redbtn_geo_watch() {
- [ -s "$RB_GEO_RESOLVER" ] || return 0
- _rb_running && return 0
- _rb_geo_ok && return 0
- logger -t zm-redbtn "ИИ-сервисы перестали открываться — подбираю DNS заново"
- do_redbtn_geo_doh >/dev/null 2>&1
-}
# ── основной проход ──
do_redbtn_run() {
- local mode="${1:-quick}" blk before after failed id zbak="$RB_RUN/zapret.before" pick name warp_ids="" warp_need=""
+ local mode="${1:-quick}" blk before after failed zfailed id zbak="$RB_RUN/zapret.before" pick name warp_ids=""
rm -f "$RB_STOP_FLAG"
mkdir -p "$RB_DIR" "$RB_RUN"
_ensure_deps
blk="$(_rb_blocker)"
case "$blk" in
- splify2) echo "ОШИБКА: установлен splify2 — туннели и списки настраиваются в нём"; return 1 ;;
zapret2) echo "ОШИБКА: установлен Zapret2 — сначала удалите его"; return 1 ;;
- steer) echo "ОШИБКА: у движка steer уже есть чужие правила — кнопка их не перезаписывает"; return 1 ;;
esac
_rb_test_running && { echo "ОШИБКА: идёт тест стратегий — дождитесь его окончания"; return 1; }
+ _job_alive steer && { echo "ОШИБКА: на странице steer идёт операция — дождитесь её окончания"; return 1; }
: > "$RB_RESULTS"
_rb_phase zapret_install
@@ -5674,7 +5970,7 @@ do_redbtn_run() {
trap '_rb_probe_nft_down' EXIT INT TERM
_rb_probe_nft_up
- RB_PROBE_DOH="$(_rb_doh_pick)" || _rb_warn "Ни один DNS для проверок не ответил — проверяем системным"
+ RB_PROBE_DOH="$(_rb_doh_pick)" || _rb_warn "Шифрованный DNS для проверок не ответил — проверяем через DNS роутера"
_rb_phase check
_rb_say "Проверяем сервисы при текущей настройке"
@@ -5682,8 +5978,11 @@ do_redbtn_run() {
_rb_stopped && { _rb_say "Остановлено"; return 0; }
# Подбор стратегии — только если что-то не открывается: рабочую настройку не трогаем.
+ # ИИ-сервисы не в счёт: их закрывает геоблок, а его стратегия Zapret не снимает — ради них
+ # одних гонять тестер десять минут бессмысленно, им сразу WARP.
failed="$(_rb_failed "$before")"
- if [ -n "$failed" ]; then
+ zfailed="$(printf '%s\n' "$failed" | grep -vx 'geoblock')"
+ if [ -n "$zfailed" ]; then
_rb_phase zapret
cp "$CONF" "$zbak"
local tmode=v
@@ -5708,16 +6007,16 @@ do_redbtn_run() {
_rb_say "Стратегия $name применена"
fi
fi
- else
+ elif ! _rb_stopped; then
_rb_warn "Ни одна стратегия не открыла больше, чем без Zapret — настройку не меняем"
fi
_rb_stopped && { _rb_say "Остановлено"; return 0; }
# YouTube отдельно: у менеджера для него свой набор стратегий (Yv).
- if ! _rb_stopped && printf '%s\n' "$before" | grep -qx 'youtube fail'; then
+ if printf '%s\n' "$before" | grep -qx 'youtube fail'; then
_rb_say "Подбираем стратегию для YouTube"
cp "$CONF" "$zbak"
- if name="$(_rb_youtube_pick)"; then
+ if name="$(_rb_youtube_pick)" && ! _rb_stopped; then
case "$(strategy_set_youtube "$name")" in
*'"ok":true'*)
if _rb_check_service youtube as_is; then
@@ -5747,15 +6046,21 @@ do_redbtn_run() {
fi
done
- # WARP — для того, что не открыл zapret.
+ # WARP — для того, что не открыл zapret, и только если установлен steer.
failed="$(_rb_failed "$before")"
- if [ -n "$failed" ]; then
+ if [ -n "$failed" ] && _st_ready; then
_rb_phase warp
- _rb_say "Не открылось: $(for id in $failed; do printf '%s, ' "$(_rb_svc_field "$id" 2)"; done | sed 's/, $//')"
- if _rb_install_steer && _rb_warp_up; then
+ _rb_say "Не открылось: $(_rb_svc_names "$failed") — пробуем через WARP"
+ if _st_warp_up; then
for id in $failed; do
- if _rb_check_service "$id" warp; then
+ if ! _rb_routable "$id"; then
+ _rb_result_write "$id" none
+ elif _rb_in "$id" "$ST_SKIP"; then
+ echo "[ -- ] $(_rb_svc_field "$id" 2) — через WARP не пускаем: вы убрали его на странице steer"
+ _rb_result_write "$id" none
+ elif _rb_check_service "$id" warp; then
echo "[ OK ] $(_rb_svc_field "$id" 2) — через WARP"
+ _st_sel_add "$id"
warp_ids="$warp_ids $id"
_rb_result_write "$id" warp
else
@@ -5763,32 +6068,26 @@ do_redbtn_run() {
_rb_result_write "$id" none
fi
done
+ if [ -n "$warp_ids" ]; then
+ _rb_phase steer
+ _st_apply || _rb_warn "steer не принял правила — загляните на страницу steer"
+ fi
else
for id in $failed; do _rb_result_write "$id" none; done
fi
+ elif [ -n "$failed" ]; then
+ for id in $failed; do _rb_result_write "$id" none; done
+ _rb_warn "Не открылось: $(_rb_svc_names "$failed"). Их можно пустить через WARP — установите steer на странице «steer»"
fi
_rb_stopped && { _rb_say "Остановлено"; return 0; }
- _rb_phase steer
- if [ -n "$warp_ids" ]; then
- _rb_say "Скачиваем списки сервисов"
- _rb_spec_apply $warp_ids || return 1
- else
- _rb_spec_clear
- # Правил нет — движку, поставленному кнопкой, работать незачем (см. _rb_install_steer).
- if _rb_owns "pkg steer"; then
- /etc/init.d/steer stop >/dev/null 2>&1
- /etc/init.d/steer disable >/dev/null 2>&1
- fi
- if _rb_owns "net $RB_WARP_IF"; then ifdown "$RB_WARP_IF" >/dev/null 2>&1; fi
- fi
-
- _rb_phase geo
- _rb_geo_step
-
_rb_phase telegram
_rb_say "Проверяем Telegram"
- if [ -x /etc/init.d/tgws ] && [ -n "$(tgws status 2>/dev/null)" ]; then
+ if _st_ready && _rb_in telegram "$ST_SEL"; then
+ # Telegram отмечен на странице steer — он уже идёт через WARP, веб-сокет не нужен.
+ _rb_result_write telegram warp
+ echo "[ OK ] Telegram — через WARP (выбран на странице steer)"
+ elif [ -x /etc/init.d/tgws ] && [ -n "$(tgws status 2>/dev/null)" ]; then
_rb_result_write telegram tgws
echo "[ OK ] Telegram — через веб-сокет"
elif _rb_tg_ok; then
@@ -5796,8 +6095,8 @@ do_redbtn_run() {
echo "[ OK ] Telegram — открывается"
else
if do_tgws_install; then
- _rb_rpcd_ensure
_rb_own "pkg tgws"
+ _rb_rpcd_ensure
_rb_result_write telegram tgws
echo "[ OK ] Telegram — через веб-сокет"
else
@@ -5808,70 +6107,76 @@ do_redbtn_run() {
_rb_phase done
date +%s > "$RB_LAST"
- if _rb_dns_conflict; then
- _rb_warn "DNS over HTTPS перехватывает DNS сети — правила по доменам не действуют, исправьте на странице кнопки"
+ if [ -n "$warp_ids" ] && _st_dns_conflict; then
+ _rb_warn "DNS over HTTPS перехватывает DNS сети — сервисы через WARP работать не будут, нажмите «Исправить» на странице steer"
fi
_rb_say "Готово, обход подобран"
}
-do_redbtn_remove() {
- _rb_phase remove
- _rb_say "Снимаем настройки красной кнопки"
- _rb_spec_clear
- if _rb_owns "net $RB_WARP_IF"; then
- ifdown "$RB_WARP_IF" >/dev/null 2>&1
- uci -q delete "network.$RB_WARP_IF"
- uci -q delete "network.${RB_WARP_IF}_peer"
- uci commit network
- /etc/init.d/network reload >/dev/null 2>&1
- fi
- if _rb_owns "fw $RB_WARP_ZONE"; then
- uci -q delete "firewall.$RB_WARP_ZONE"
- uci -q delete "firewall.${RB_WARP_ZONE}_fwd"
- uci commit firewall
- /etc/init.d/firewall reload >/dev/null 2>&1
- fi
- if _rb_owns "svc zm-geodns"; then _rb_geo_disable; fi
- sed -i '/redbtn_geo_watch/d' "$CRON_FILE" 2>/dev/null && /etc/init.d/cron restart >/dev/null 2>&1
- if _rb_owns "pkg https-dns-proxy"; then $DELETE https-dns-proxy >/dev/null 2>&1; fi
- _rb_owns "pkg tgws" && do_tgws_remove
- if _rb_owns "pkg steer"; then
- _rb_say "Удаляем движок steer"
- $DELETE steer >/dev/null 2>&1
- fi
- # AmneziaWG — только если других туннелей на нём нет.
- if ! uci show network 2>/dev/null | grep -q "\.proto='amneziawg'"; then
- local p
- for p in luci-proto-amneziawg amneziawg-tools kmod-amneziawg; do
- _rb_owns "pkg $p" && $DELETE "$p" >/dev/null 2>&1
+
+# ── видео на время подбора ──
+#
+# Каталог — файл videos.txt в репозитории Zapret Manager (files/AutoBypass), строки
+# «файл.mp4|Название|постер.jpg». Сами ролики браузер берёт с jsDelivr или GitHub; роутер только
+# хранит выбор, чтобы он был одинаковым в LuCI и в Web UI на любом устройстве.
+redbtn_video_list() {
+ local cache="$JOBS_DIR/autobypass_videos.txt" u tmp="$JOBS_DIR/autobypass_videos.tmp" out="" sep="" file name poster
+ if [ ! -s "$cache" ] || [ -n "$(find "$cache" -mmin +60 2>/dev/null)" ]; then
+ for u in $RB_VIDEO_LIST_URLS; do
+ if curl -fsSL --connect-timeout 5 --max-time 10 -o "$tmp" "$u" 2>/dev/null && grep -q '\.mp4|' "$tmp"; then
+ mv "$tmp" "$cache"; break
+ fi
done
+ rm -f "$tmp"
fi
- _rb_probe_nft_down
- rm -rf "$RB_DIR" "$RB_RUN"
- _rb_say "Готово, настройки красной кнопки сняты"
+ [ -s "$cache" ] || printf '%s\n' 'wait.mp4|Стандартное|poster.jpg' > "$cache"
+ while IFS='|' read -r file name poster; do
+ case "$file" in ''|\#*) continue ;; esac
+ echo "$file" | grep -qE '^[A-Za-z0-9._-]+\.mp4$' || continue
+ echo "$poster" | grep -qE '^[A-Za-z0-9._-]+\.(jpg|jpeg|png|webp)$' || poster=""
+ out="$out$sep{\"file\":\"$file\",\"name\":\"$(esc "${name:-$file}")\",\"poster\":\"$poster\"}"
+ sep=","
+ done < "$cache"
+ printf '{"items":[%s]}\n' "$out"
}
+redbtn_video_set() {
+ local v="$1"
+ case "$v" in
+ ''|default) rm -f "$RB_VIDEO_FILE"; printf '{"ok":true}\n'; return 0 ;;
+ off) ;;
+ cat:*) echo "${v#cat:}" | grep -qE '^[A-Za-z0-9._-]+\.mp4$' || { echo '{"error":"неверное имя файла"}'; return 1; } ;;
+ url:https://*|url:http://*)
+ case "$v" in *[[:space:]\"\'\\\<\>]*) echo '{"error":"ссылка содержит недопустимые символы"}'; return 1 ;; esac ;;
+ *) echo '{"error":"ссылка должна начинаться с http:// или https://"}'; return 1 ;;
+ esac
+ mkdir -p "$(dirname "$RB_VIDEO_FILE")"
+ printf '%s\n' "$v" > "$RB_VIDEO_FILE"
+ printf '{"ok":true}\n'
+}
+
+
+
redbtn_status() {
- local running=false phase="" last="" blk colo="" warp_up=false steer_ver="" dns=false svc="" sep="" id st name
+ local running=false phase="" last="" blk video="" svc="" sep="" id st name w steer=false sel=" "
_rb_running && running=true
[ -f "$RB_PHASE_FILE" ] && phase=$(cat "$RB_PHASE_FILE")
[ -f "$RB_LAST" ] && last=$(cat "$RB_LAST")
blk="$(_rb_blocker)"
- [ -f "$RB_DIR/warp.colo" ] && colo=$(cat "$RB_DIR/warp.colo")
- [ -d "/sys/class/net/$RB_WARP_IF" ] && warp_up=true
- command -v steer >/dev/null 2>&1 && steer_ver="$(_rb_steer_ver)"
- _rb_dns_conflict && dns=true
- local geo=""
- [ -s "$RB_GEO_RESOLVER" ] && geo=$(sed -n "s/^GEO_TITLE='\(.*\)'$/\1/p" "$RB_GEO_RESOLVER")
+ [ -s "$RB_VIDEO_FILE" ] && video=$(head -n1 "$RB_VIDEO_FILE")
+ if _st_ready; then steer=true; sel=" $(_st_sel | tr '\n' ' ') "; fi
for id in $(_rb_svc_ids); do
name="$(_rb_svc_field "$id" 2)"
st="$(grep "^$id|" "$RB_RESULTS" 2>/dev/null | head -n1 | cut -d'|' -f2)"
- svc="$svc$sep{\"id\":\"$id\",\"name\":\"$(esc "$name")\",\"state\":\"$st\"}"
+ # Выбор на странице steer важнее результата подбора: отмеченный сервис идёт через WARP.
+ w=false
+ case "$sel" in *" $id "*) w=true ;; esac
+ svc="$svc$sep{\"id\":\"$id\",\"name\":\"$(esc "$name")\",\"state\":\"$st\",\"warp\":$w}"
sep=","
done
- printf '{"running":%s,"phase":"%s","last":"%s","blocker":"%s","warp_up":%s,"warp_colo":"%s","steer":"%s","dns_conflict":%s,"geo_dns":"%s","configured":%s,"services":[%s]}\n' \
- "$running" "$(esc "$phase")" "$last" "$blk" "$warp_up" "$(esc "$colo")" "$(esc "$steer_ver")" "$dns" "$(esc "$geo")" \
- "$([ -s "$RB_OWNED" ] && echo true || echo false)" "$svc"
+ printf '{"running":%s,"phase":"%s","last":"%s","blocker":"%s","steer":%s,"steer_busy":%s,"video":"%s","configured":%s,"services":[%s]}\n' \
+ "$running" "$(esc "$phase")" "$last" "$blk" "$steer" "$(_job_alive steer && echo true || echo false)" "$(esc "$video")" \
+ "$([ -s "$RB_LAST" ] && echo true || echo false)" "$svc"
}
redbtn_action() {
@@ -5880,26 +6185,17 @@ redbtn_action() {
start)
case "$mode" in quick|full) ;; *) mode=quick ;; esac
_rb_test_running && { echo '{"error":"идёт тест стратегий — дождитесь его окончания"}'; return 1; }
+ _job_alive steer && { echo '{"error":"на странице steer идёт операция — дождитесь её окончания"}'; return 1; }
job_start redbtn do_redbtn_run "$mode"
;;
stop)
_rb_running || { echo '{"error":"подбор не запущен"}'; return 1; }
mkdir -p "$RB_RUN" "$TEST_DIR"
- touch "$RB_STOP_FLAG" "$TEST_STOP_FLAG"
+ touch "$RB_STOP_FLAG" "$TEST_STOP_FLAG" "$ST_STOP_FLAG"
printf '{"ok":true}\n'
;;
- remove)
- _rb_running && { echo '{"error":"дождитесь окончания подбора"}'; return 1; }
- job_start redbtn do_redbtn_remove
- ;;
- dns_fix)
- do_redbtn_dns_fix
- printf '{"ok":true}\n'
- ;;
- geo_doh)
- _rb_running && { echo '{"error":"дождитесь окончания подбора"}'; return 1; }
- job_start redbtn do_redbtn_geo_doh
- ;;
+ video_list) redbtn_video_list ;;
+ video_set) redbtn_video_set "$mode" ;;
*) echo '{"error":"неизвестное действие"}' ;;
esac
}
@@ -5980,7 +6276,8 @@ case "$cmd" in
health) health ;;
redbtn_status) redbtn_status ;;
redbtn_action) redbtn_action "$1" "$2" ;;
- redbtn_geo_watch) redbtn_geo_watch ;;
+ steer_status) steer_status ;;
+ steer_action) steer_action "$1" "$2" ;;
bytetube_action) bytetube_action "$1" ;;
*) echo '{"error":"неизвестная команда"}'; exit 1 ;;
esac
@@ -6071,6 +6368,8 @@ list_methods() {
json_add_object "health"; json_close_object
json_add_object "redbtn_status"; json_close_object
json_add_object "redbtn_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object
+ json_add_object "steer_status"; json_close_object
+ json_add_object "steer_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object
json_add_object "bytetube_action"; json_add_string "action" "string"; json_close_object
json_dump
}
@@ -6155,6 +6454,8 @@ call_method() {
health) "$BACKEND" health ;;
redbtn_status) "$BACKEND" redbtn_status ;;
redbtn_action) json_get_var action action; json_get_var mode mode; "$BACKEND" redbtn_action "$action" "$mode" ;;
+ steer_status) "$BACKEND" steer_status ;;
+ steer_action) json_get_var action action; json_get_var mode mode; "$BACKEND" steer_action "$action" "$mode" ;;
bytetube_action) json_get_var action action; "$BACKEND" bytetube_action "$action" ;;
*) echo '{"error":"unknown method"}'; return 1 ;;
esac
@@ -6183,7 +6484,7 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF'
"system_status", "mirror_status", "exclusions_status", "exclusions_file_get", "nfqws_opt_get", "tg_status", "tgws_status",
"test_status", "test_results", "zm_update_status", "mixomo_status", "mixomo_config_get",
"mixomo_warp_status",
- "zapret_latest_version", "bytetube_installed", "health", "redbtn_status"
+ "zapret_latest_version", "bytetube_installed", "health", "redbtn_status", "steer_status"
],
"system": [ "info" ]
},
@@ -6208,7 +6509,7 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF'
"mixomo_action", "mixomo_config_set", "mixomo_subscription_set",
"mixomo_magitrickle_list_set", "mixomo_autorestart_set", "mixomo_ui_action",
"mixomo_warp_action", "mixomo_warp_integrate_action", "mixomo_warp_config_set",
- "bytetube_action", "redbtn_action"
+ "bytetube_action", "redbtn_action", "steer_action"
]
},
"uci": [ "bytetube" ]
@@ -6236,10 +6537,15 @@ cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF
"action": { "type": "view", "path": "zapret-manager/dashboard" }
},
"admin/services/zapret-manager/redbtn": {
- "title": "Красная кнопка",
+ "title": "Автообход",
"order": 15,
"action": { "type": "view", "path": "zapret-manager/redbtn" }
},
+ "admin/services/zapret-manager/steer": {
+ "title": "steer",
+ "order": 16,
+ "action": { "type": "view", "path": "zapret-manager/steer" }
+ },
"admin/services/zapret-manager/strategy": {
"title": "Zapret",
"order": 20,
@@ -6295,6 +6601,8 @@ cat > '/www/luci-static/resources/zapret-manager/common.js' << 'ZM_INSTALLER_EOF
var callStatus = rpc.declare({ object: 'zapret-manager', method: 'status', expect: {} });
var callRedbtnStatus = rpc.declare({ object: 'zapret-manager', method: 'redbtn_status', expect: {} });
var callRedbtnAction = rpc.declare({ object: 'zapret-manager', method: 'redbtn_action', params: ['action', 'mode'], expect: {} });
+var callSteerStatus = rpc.declare({ object: 'zapret-manager', method: 'steer_status', expect: {} });
+var callSteerAction = rpc.declare({ object: 'zapret-manager', method: 'steer_action', params: ['action', 'mode'], expect: {} });
var callSystemInfo = rpc.declare({ object: 'zapret-manager', method: 'system_info', expect: {} });
var callJobStatus = rpc.declare({ object: 'zapret-manager', method: 'job_status', params: ['job'], expect: {} });
var callLogTail = rpc.declare({ object: 'zapret-manager', method: 'log_tail', params: ['job'], expect: {} });
@@ -6632,6 +6940,8 @@ return baseclass.extend({
health: callHealth,
redbtnStatus: callRedbtnStatus,
redbtnAction: callRedbtnAction,
+ steerStatus: callSteerStatus,
+ steerAction: callSteerAction,
boardInfo: callBoardInfo,
parseSize: parseSize,
fmtSize: fmtSize,
@@ -6707,6 +7017,12 @@ return view.extend({
if (zrSt !== 0 && d.zapret_version) items.push(row('Версия Zapret', E('span', {}, d.zapret_version)));
if (zrSt !== 0 && d.strategy) items.push(row('Стратегия', E('span', {}, d.strategy)));
items.push(row('Zapret2', zm.stateBadge(zr2St)));
+ var rbSt = st(h, 'redbtn', 0);
+ items.push(row('Автообход', rbSt === 1 ? zm.badge(true, 'настроен', '')
+ : E('span', { 'class': 'zm-badge ' + (rbSt === 2 ? 'zm-warn' : 'zm-off') }, [ E('span', { 'class': 'zm-dot' }), rbSt === 2 ? 'требует внимания' : 'не запускался' ])));
+ var stSt = st(h, 'steer', 0);
+ items.push(row('steer (WARP)', stSt === 1 ? zm.badge(true, 'работает', '') : stSt === 2
+ ? E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), 'выключен' ]) : zm.badge(false, '', 'не установлен')));
items.push(row('ByeTube', zm.stateBadge(st(h, 'bytetube', 0))));
items.push(row('TG WS Proxy', zm.stateBadge(st(h, 'tg', 0))));
items.push(row('Mixomo', zm.stateBadge(st(h, 'mixomo', 0))));
@@ -6942,42 +7258,72 @@ cat > '/www/luci-static/resources/view/zapret-manager/redbtn.js' << 'ZM_INSTALLE
'require view';
'require zapret-manager.common as zm';
+// Автообход: роутер сам проверяет сервисы и подбирает каждому рабочий способ.
+// Внутреннее имя страницы и методов — redbtn (так было задумано первой версией).
+
var STATES = {
direct: { text: 'напрямую', cls: 'zm-ok' },
zapret: { text: 'через Zapret', cls: 'zm-ok' },
warp: { text: 'через WARP', cls: 'zm-ok' },
tgws: { text: 'через веб-сокет', cls: 'zm-ok' },
- doh: { text: 'через DoH', cls: 'zm-ok' },
- hosts: { text: 'через hosts', cls: 'zm-ok' },
none: { text: 'не открывается', cls: 'zm-bad' }
};
-var PHASES = {
- zapret_install: 'Устанавливаем Zapret',
+// Шаги подбора в том порядке, в каком их проходит роутер.
+var STEPS = [
+ { id: 'zapret_install', label: 'Подготовка' },
+ { id: 'check', label: 'Проверка' },
+ { id: 'zapret', label: 'Zapret' },
+ { id: 'warp', label: 'WARP' },
+ { id: 'steer', label: 'Правила' },
+ { id: 'telegram', label: 'Telegram' }
+];
+
+var PHASE_TEXT = {
+ zapret_install: 'Готовим Zapret',
check: 'Проверяем сервисы',
zapret: 'Подбираем стратегию Zapret',
warp: 'Поднимаем туннель WARP',
steer: 'Применяем правила',
- geo: 'Проверяем ИИ-сервисы',
telegram: 'Проверяем Telegram',
- remove: 'Снимаем настройки'
};
+var RUN_PHASES = [ 'zapret_install', 'check', 'zapret', 'warp', 'steer', 'telegram' ];
var BLOCKERS = {
- splify2: 'Установлен splify2 — туннели и списки настраиваются в нём.',
- zapret2: 'Установлен Zapret2 — удалите его на странице Zapret2.',
- steer: 'Обход на роутере уже настроен вручную — кнопка его не перезаписывает.'
+ zapret2: 'Установлен Zapret2 — удалите его на странице Zapret2.'
};
-// Ролик на время подбора. Лежит в репозитории форка; jsDelivr первым, потому что он отдаёт
-// правильный тип файла и чаще открывается там, где raw.githubusercontent закрыт.
-var WAIT_VIDEO = [
- 'https://cdn.jsdelivr.net/gh/xyzmean/Zapret-Manager-Exp@main/files/RedButton/wait.mp4',
- 'https://raw.githubusercontent.com/xyzmean/Zapret-Manager-Exp/main/files/RedButton/wait.mp4'
+var MODES = [
+ { id: 'quick', label: 'Быстрый подбор', hint: 'Стратегии v — обычно 5–15 минут.' },
+ { id: 'full', label: 'Полный подбор', hint: 'Стратегии v и Flowseal — дольше, до получаса, зато вариантов больше.' }
];
-var WAIT_POSTER = 'https://cdn.jsdelivr.net/gh/xyzmean/Zapret-Manager-Exp@main/files/RedButton/poster.jpg';
-function stateBadge(st) {
+var TABS = [
+ { id: 'main', label: 'Автообход' },
+ { id: 'video', label: 'Видео' }
+];
+
+// Видео берутся с сервера Zapret Manager: jsDelivr первым — он чаще открывается там, где
+// raw.githubusercontent закрыт, и отдаёт правильный тип файла.
+var VIDEO_BASES = [
+ 'https://cdn.jsdelivr.net/gh/StressOzz/Zapret-Manager@main/files/AutoBypass/',
+ 'https://raw.githubusercontent.com/StressOzz/Zapret-Manager/refs/heads/main/files/AutoBypass/'
+];
+// Запас для стандартного ролика: он же лежит в репозитории автора кнопки. Плеер берёт первый
+// источник, который открылся, так что пока на нашем сервере файла нет, играет этот.
+var VIDEO_SPARE = [
+ 'https://cdn.jsdelivr.net/gh/xyzmean/Zapret-Manager-Exp@main/files/RedButton/',
+ 'https://raw.githubusercontent.com/xyzmean/Zapret-Manager-Exp/main/files/RedButton/'
+];
+var VIDEO_DEFAULT = { file: 'wait.mp4', name: 'Стандартное', poster: 'poster.jpg' };
+
+var ICON_BOLT = '';
+
+function stateBadge(svc) {
+ var st = svc.state;
+ // Ручной выбор списков важнее подобранного: отмеченный сервис идёт через WARP, снятый — нет.
+ if (svc.warp) st = 'warp';
+ else if (st === 'warp') return E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'WARP выключен' ]);
var s = STATES[st];
return E('span', { 'class': 'zm-badge ' + (s ? s.cls : 'zm-off') }, [
E('span', { 'class': 'zm-dot' }),
@@ -6985,61 +7331,135 @@ function stateBadge(st) {
]);
}
+function plainBadge(cls, text) {
+ return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]);
+}
+
function fmtTime(ts) {
if (!ts) return 'ещё не было';
var d = new Date(parseInt(ts, 10) * 1000);
- return d.toLocaleString();
+ return isNaN(d) ? '—' : d.toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
}
return view.extend({
load: function() {
zm.injectCss();
- return zm.redbtnStatus();
+ return zm.redbtnStatus().catch(function() { return {}; });
},
render: function(data) {
- var wrap = E('div', { 'class': 'zm-wrap' });
- var logEl = E('pre', { 'class': 'zm-log' });
+ data = data || {};
+ var wrap = E('div', { 'class': 'zm-wrap zm-ab' });
var busy = false;
var mode = 'quick';
+ var activeTab = 'main';
+ var lastAction = '';
+ var maxStep = -1;
+ var videos = null; // каталог с сервера, грузится при первом открытии вкладки «Видео»
+ var customOpen = false;
- var mainCard = E('div', { 'class': 'zm-card zm-redbtn-card' });
+ // ── вкладки ──
+ var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin-bottom:14px' });
+ var panels = {};
+ TABS.forEach(function(t) { panels[t.id] = E('div', { 'class': 'zm-ab-panel', 'style': t.id === activeTab ? '' : 'display:none' }); });
+
+ function renderTabBar() {
+ tabBar.innerHTML = '';
+ TABS.forEach(function(t) {
+ tabBar.appendChild(E('button', {
+ 'class': 'cbi-button' + (t.id === activeTab ? ' cbi-button-positive' : ''),
+ 'click': function() { switchTab(t.id); }
+ }, t.label));
+ });
+ }
+
+ function switchTab(id) {
+ activeTab = id;
+ TABS.forEach(function(t) { panels[t.id].style.display = t.id === activeTab ? '' : 'none'; });
+ renderTabBar();
+ placeRunCards();
+ if (id === 'video') loadVideos();
+ }
+
+ // ── карточки ──
+ var heroCard = E('div', { 'class': 'zm-card zm-ab-hero' });
+ var steerCard = E('div', {});
var svcCard = E('div', { 'class': 'zm-card' });
- var warpCard = E('div', { 'class': 'zm-card' });
- var dnsCard = E('div', {});
- // Плеер и журнал — отдельными блоками под главным: плеер крупный, журнал сразу под ним.
- var videoCard = E('div', { 'class': 'zm-card zm-redbtn-player', 'style': 'display:none' });
- var logCard = E('div', { 'class': 'zm-card zm-redbtn-logcard' }, [ logEl ]);
- var videoEl = null, videoBox = null, soundBtn = null;
+ var videoSetCard = E('div', { 'class': 'zm-card' });
- // Выбор «без звука» запоминается в браузере: кто выключил звук один раз, не хочет его
- // снова на следующем подборе.
+ // Видео и журнал — общие: переезжают под первую карточку открытой вкладки.
+ var logEl = E('pre', { 'class': 'zm-log' });
+ var logCard = E('div', { 'class': 'zm-card zm-ab-logcard', 'style': 'display:none' }, [
+ E('h3', {}, 'Журнал'), logEl
+ ]);
+ var playerCard = E('div', { 'class': 'zm-card zm-ab-player', 'style': 'display:none' });
+ var videoEl = null, soundBtn = null, previewing = false;
+
+ function placeRunCards() {
+ var p = panels[activeTab], first = p.firstChild;
+ var anchor = first ? first.nextSibling : null;
+ p.insertBefore(playerCard, anchor);
+ p.insertBefore(logCard, playerCard.nextSibling);
+ }
+
+ // ── видео ──
+
+ function videoPref() { return data.video || ''; }
+
+ function catalogItem(file) {
+ var list = videos || [ VIDEO_DEFAULT ];
+ for (var i = 0; i < list.length; i++) if (list[i].file === file) return list[i];
+ return file === VIDEO_DEFAULT.file ? VIDEO_DEFAULT : { file: file, name: file, poster: '' };
+ }
+
+ function videoSource(pref) {
+ if (pref === 'off') return null;
+ if (pref.indexOf('url:') === 0) return { srcs: [ pref.slice(4) ], poster: '' };
+ var it = catalogItem(pref.indexOf('cat:') === 0 ? pref.slice(4) : VIDEO_DEFAULT.file);
+ var bases = it.file === VIDEO_DEFAULT.file ? VIDEO_BASES.concat(VIDEO_SPARE) : VIDEO_BASES;
+ return {
+ srcs: bases.map(function(b) { return b + it.file; }),
+ // Постер — только из каталога, который реально пришёл с сервера: иначе вместо
+ // заставки была бы битая картинка.
+ poster: (videos && it.poster) ? VIDEO_BASES[0] + it.poster : ''
+ };
+ }
+
+ // Выбор «без звука» запоминается в браузере: кто выключил звук один раз, не хочет его снова.
function soundPref() { try { return localStorage.getItem('zm-redbtn-mute') !== '1'; } catch (e) { return true; } }
function soundSave(on) { try { localStorage.setItem('zm-redbtn-mute', on ? '0' : '1'); } catch (e) {} }
function soundLabel() { if (soundBtn && videoEl) soundBtn.textContent = videoEl.muted ? 'Включить звук' : 'Выключить звук'; }
- // Видео создаётся заново на каждый подбор и удаляется после: пока подбора нет, оно не
- // должно ни качаться, ни играть. Со звуком — если браузер разрешит (подбор начат
- // нажатием), иначе без звука, и включить его можно в самом плеере.
- function videoShow(withSound) {
- if (videoEl) return videoEl;
+ // Плеер создаётся на каждый показ и удаляется после: пока видео не нужно, оно не качается.
+ function videoShow(withSound, preview, prefOverride) {
+ var src = videoSource(prefOverride != null ? prefOverride : videoPref());
+ if (!src) return;
+ if (videoEl) videoHide();
+ previewing = !!preview;
videoEl = E('video', {
- 'class': 'zm-redbtn-video', 'controls': '', 'loop': '', 'playsinline': '',
- 'preload': 'auto', 'poster': WAIT_POSTER
- }, WAIT_VIDEO.map(function(u) { return E('source', { 'src': u, 'type': 'video/mp4' }); }));
+ 'class': 'zm-ab-video', 'controls': '', 'loop': '', 'playsinline': '', 'preload': 'auto',
+ 'poster': src.poster || null
+ }, src.srcs.map(function(u) { return E('source', { 'src': u, 'type': 'video/mp4' }); }));
videoEl.muted = !(withSound && soundPref());
videoEl.addEventListener('volumechange', function() { soundSave(!videoEl.muted); soundLabel(); });
- soundBtn = E('button', {
- 'class': 'cbi-button',
- 'click': function() { if (videoEl) videoEl.muted = !videoEl.muted; }
- }, '');
- videoBox = E('div', {}, [
- E('h3', {}, 'А пока подбираем…'),
- videoEl, E('div', { 'class': 'zm-actions' }, [ soundBtn ])
- ]);
- videoCard.innerHTML = '';
- videoCard.appendChild(videoBox);
- videoCard.style.display = '';
+ // Не открылся ни один источник — прячем плеер и говорим об этом одной строкой.
+ var sources = videoEl.querySelectorAll('source');
+ if (sources.length) sources[sources.length - 1].addEventListener('error', function() {
+ playerCard.innerHTML = '';
+ playerCard.appendChild(E('h3', {}, previewing ? 'Просмотр' : 'Пока ждём'));
+ playerCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Видео не загрузилось — сервер недоступен или ссылка неверная. Подбор это не мешает.'));
+ videoEl = soundBtn = null;
+ });
+ soundBtn = E('button', { 'class': 'cbi-button', 'click': function() { if (videoEl) videoEl.muted = !videoEl.muted; } }, '');
+ playerCard.innerHTML = '';
+ playerCard.appendChild(E('h3', {}, previewing ? 'Просмотр' : 'Пока ждём'));
+ playerCard.appendChild(videoEl);
+ playerCard.appendChild(E('div', { 'class': 'zm-actions' }, [
+ soundBtn,
+ E('button', { 'class': 'cbi-button', 'click': function() { videoHide(); } }, previewing ? 'Закрыть' : 'Скрыть видео')
+ ]));
+ playerCard.style.display = '';
+ placeRunCards();
soundLabel();
setTimeout(function() {
if (!videoEl) return;
@@ -7050,20 +7470,23 @@ return view.extend({
videoEl.play().catch(function() {});
});
}, 0);
- return videoEl;
}
function videoHide() {
- if (!videoEl) return;
- videoEl.pause();
- videoEl.removeAttribute('src');
- while (videoEl.firstChild) videoEl.removeChild(videoEl.firstChild);
- videoEl.load();
- videoCard.innerHTML = '';
- videoCard.style.display = 'none';
- videoEl = videoBox = soundBtn = null;
+ if (videoEl) {
+ videoEl.pause();
+ while (videoEl.firstChild) videoEl.removeChild(videoEl.firstChild);
+ videoEl.removeAttribute('src');
+ videoEl.load();
+ }
+ playerCard.innerHTML = '';
+ playerCard.style.display = 'none';
+ videoEl = soundBtn = null;
+ previewing = false;
}
+ // ── ход операции ──
+
function refresh() {
return zm.redbtnStatus().then(function(res) {
data = res || {};
@@ -7071,229 +7494,762 @@ return view.extend({
});
}
- function follow(withVideo, withSound) {
- var ticks = 0;
- busy = true;
- if (withVideo) videoShow(withSound);
- renderMain();
- zm.pollJob('redbtn', logEl, function(ok) {
- busy = false;
- // Опрос сдаётся, когда роутер на время перестаёт отвечать (перезапуск сети при
- // установке туннеля). Подбор при этом идёт дальше — ждём роутер и следим снова.
- zm.redbtnStatus().then(function(res) {
- if (res && res.running) { busy = true; follow(withVideo && !!videoEl, false); return; }
- videoHide();
- zm.toast(ok ? 'Готово' : 'Операция завершилась с ошибкой', ok ? 'info' : 'error');
- refresh();
- }).catch(function() {
- zm.toast('Роутер не отвечает — ждём', 'warning');
- setTimeout(function() { busy = false; waitRouter(); }, 5000);
- });
- }, function() {
- if (++ticks % 5) return;
- zm.redbtnStatus().then(function(res) {
- if (!res) return;
- data.phase = res.phase;
- renderPhase();
- data.services = res.services;
- renderServices();
- });
- });
+ function finish(ok, res) {
+ busy = false;
+ if (!previewing) videoHide();
+ if (res) data = res;
+ var msg;
+ if (!ok) msg = 'Операция завершилась с ошибкой — подробности в журнале';
+ else if (lastAction === 'start') msg = data.phase === 'done' ? 'Готово, обход подобран' : 'Подбор остановлен';
+ else msg = 'Готово';
+ zm.toast(msg, ok ? 'info' : 'error');
+ lastAction = '';
+ renderAll();
}
function waitRouter() {
zm.redbtnStatus().then(function(res) {
data = res || {};
- if (data.running) { follow(!!videoEl, false); return; }
- videoHide();
- renderAll();
+ if (data.running) { follow(!!videoEl); return; }
+ finish(true, res);
}).catch(function() { setTimeout(waitRouter, 5000); });
}
- function start() {
- if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; }
- zm.redbtnAction('start', mode).then(function(res) {
- if (res.error) { zm.toast(res.error, 'error'); return; }
- zm.toast('Подбор начат', 'warning');
- data.running = true;
- follow(true, true);
+ function follow(withVideo) {
+ var ticks = 0;
+ busy = true;
+ logCard.style.display = '';
+ if (withVideo && !videoEl && videoPref() !== 'off') videoShow(false);
+ renderAll();
+ placeRunCards();
+ zm.pollJob('redbtn', logEl, function(ok) {
+ // Опрос сдаётся, когда роутер на время замолкает (перезапуск сети при установке
+ // туннеля). Подбор при этом идёт дальше — ждём роутер и следим снова.
+ zm.redbtnStatus().then(function(res) {
+ if (res && res.running) { data = res; follow(withVideo && !!videoEl); return; }
+ finish(ok, res);
+ }).catch(function() {
+ zm.toast('Роутер не отвечает — ждём', 'warning');
+ setTimeout(waitRouter, 5000);
+ });
+ }, function() {
+ if (++ticks % 4) return;
+ zm.redbtnStatus().then(function(res) {
+ if (!res) return;
+ data = res;
+ renderHero();
+ renderServices();
+ }).catch(function() {});
});
}
+ function act(action, arg, toastText, withVideo) {
+ if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; }
+ zm.redbtnAction(action, arg || '').then(function(res) {
+ if (res.error) { zm.toast(res.error, 'error'); return; }
+ if (toastText) zm.toast(toastText, 'warning');
+ lastAction = action;
+ data.running = true;
+ if (action === 'start') { maxStep = -1; data.phase = 'zapret_install'; }
+ if (withVideo && videoPref() !== 'off') videoShow(true);
+ follow(false);
+ }).catch(function() { zm.toast('Роутер не ответил', 'error'); });
+ }
+
+ function start() { act('start', mode, 'Подбор запущен — страницу можно закрыть, он продолжится', true); }
+
function stop() {
zm.redbtnAction('stop', '').then(function(res) {
if (res.error) { zm.toast(res.error, 'error'); return; }
- zm.toast('Останавливаем подбор', 'warning');
+ zm.toast('Останавливаем — роутер закончит текущий шаг', 'warning');
});
}
- function remove() {
- if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; }
- if (!confirm('Снять туннель WARP и правила красной кнопки? Стратегия Zapret останется.')) return;
- zm.redbtnAction('remove', '').then(function(res) {
- if (res.error) { zm.toast(res.error, 'error'); return; }
- follow(false);
- });
+ // ── главная карточка ──
+
+ function stepIndex(phase) {
+ for (var i = 0; i < STEPS.length; i++) if (STEPS[i].id === phase) return i;
+ return -1;
}
- var phaseEl = E('div', { 'class': 'zm-hint' });
- function renderPhase() {
- phaseEl.textContent = busy ? (PHASES[data.phase] || 'Работаем') + '…' : '';
+ function renderSteps() {
+ var cur = stepIndex(data.phase);
+ if (cur > maxStep) maxStep = cur;
+ return E('div', { 'class': 'zm-ab-steps' }, STEPS.map(function(s, i) {
+ var cls = i < maxStep ? ' zm-ab-done' : (i === maxStep ? ' zm-ab-now' : '');
+ return E('div', { 'class': 'zm-ab-step' + cls }, [
+ E('span', { 'class': 'zm-ab-step-dot' }, i < maxStep ? '✓' : String(i + 1)),
+ E('span', { 'class': 'zm-ab-step-label' }, s.label)
+ ]);
+ }));
}
- function renderMain() {
- mainCard.innerHTML = '';
- mainCard.appendChild(E('h3', {}, 'Красная кнопка'));
- mainCard.appendChild(E('p', { 'class': 'zm-hint' },
- 'Роутер проверит сервисы и подберёт каждому способ: напрямую, через Zapret, через WARP, а Telegram — через веб-сокет. Подбор занимает от пяти минут до получаса.'));
+ function summary() {
+ var list = (data.services || []).filter(function(s) { return s.state; });
+ if (!list.length) return null;
+ var ok = list.filter(function(s) { return s.warp || (s.state !== 'none' && s.state !== 'warp'); }).length;
+ return E('div', { 'class': 'zm-ab-sum' + (ok === list.length ? ' zm-ab-sum-ok' : '') }, [
+ E('b', {}, ok + ' из ' + list.length), ' сервисов открываются'
+ ]);
+ }
+
+ function renderHero() {
+ heroCard.innerHTML = '';
+ var status;
+ if (busy || data.running) status = plainBadge('zm-warn', PHASE_TEXT[data.phase] || 'Работаем');
+ else if (data.configured) status = plainBadge('zm-ok', 'настроен');
+ else status = plainBadge('zm-off', 'ещё не запускался');
+
+ heroCard.appendChild(E('div', { 'class': 'zm-ab-head' }, [
+ E('div', { 'class': 'zm-ab-icon' }, [ E(ICON_BOLT) ]),
+ E('div', { 'class': 'zm-ab-title' }, [
+ E('h3', {}, 'Автообход'),
+ E('p', { 'class': 'zm-hint' }, 'Одна кнопка — и роутер сам подберёт каждому сервису рабочий способ: напрямую или через Zapret, а если установлен steer — и через WARP.')
+ ]),
+ status
+ ]));
if (data.blocker) {
- mainCard.appendChild(E('div', { 'class': 'zm-row' }, [
- E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), BLOCKERS[data.blocker] || data.blocker ])
+ heroCard.appendChild(E('div', { 'class': 'zm-ab-note zm-ab-note-warn' }, BLOCKERS[data.blocker] || data.blocker));
+ return;
+ }
+ if (!busy && data.steer_busy) heroCard.appendChild(E('div', { 'class': 'zm-ab-note zm-ab-note-warn' }, 'На странице steer идёт операция — запуск станет доступен, когда она закончится.'));
+
+ if (busy) {
+ if (RUN_PHASES.indexOf(data.phase) >= 0 || lastAction === 'start') heroCard.appendChild(renderSteps());
+ heroCard.appendChild(E('div', { 'class': 'zm-actions' }, [
+ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': stop }, 'Остановить')
]));
+ heroCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно закрыть страницу — подбор продолжится на роутере.'));
return;
}
- if (!busy) {
- var grid = E('div', { 'class': 'zm-grid' });
- [
- { id: 'quick', label: 'Быстрый подбор' },
- { id: 'full', label: 'Полный подбор (дольше, больше стратегий)' }
- ].forEach(function(m) {
- grid.appendChild(E('div', {
- 'class': 'zm-tile' + (mode === m.id ? ' zm-active' : ''),
- 'click': function() { mode = m.id; renderMain(); }
- }, m.label));
- });
- mainCard.appendChild(grid);
- }
+ var sel = MODES.filter(function(m) { return m.id === mode; })[0];
+ heroCard.appendChild(E('div', { 'class': 'zm-grid' }, MODES.map(function(m) {
+ return E('div', {
+ 'class': 'zm-tile' + (mode === m.id ? ' zm-active' : ''),
+ 'click': function() { mode = m.id; renderHero(); }
+ }, m.label);
+ })));
+ heroCard.appendChild(E('p', { 'class': 'zm-hint' }, sel.hint + ' Стратегия Zapret меняется, только если так откроется больше сервисов.'));
- var actions = busy ? [
- E('button', { 'class': 'cbi-button cbi-button-remove', 'click': stop }, 'Остановить подбор')
- ] : [
- E('button', { 'class': 'cbi-button zm-redbtn', 'click': start }, 'Подобрать обход')
- ];
- if (!busy && data.configured)
- actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': remove }, 'Снять настройки'));
- mainCard.appendChild(E('div', { 'class': 'zm-actions' }, actions));
- renderPhase();
- mainCard.appendChild(phaseEl);
- mainCard.appendChild(E('div', { 'class': 'zm-row' }, [
- E('span', { 'class': 'zm-label' }, 'Последний подбор'),
- E('span', {}, fmtTime(data.last))
- ]));
+ var actions = [ E('button', { 'class': 'cbi-button cbi-button-positive zm-ab-go', 'click': start }, [ E(ICON_BOLT), data.configured ? 'Подобрать заново' : 'Запустить автообход' ]) ];
+ heroCard.appendChild(E('div', { 'class': 'zm-actions' }, actions));
+ var foot = [ E('span', {}, 'Последний подбор: ' + fmtTime(data.last)) ];
+ var s = summary();
+ if (s) foot.unshift(s);
+ heroCard.appendChild(E('div', { 'class': 'zm-ab-foot' }, foot));
}
function renderServices() {
svcCard.innerHTML = '';
svcCard.appendChild(E('h3', {}, 'Сервисы'));
- (data.services || []).forEach(function(s) {
- svcCard.appendChild(E('div', { 'class': 'zm-row' }, [
- E('span', { 'class': 'zm-label' }, s.name),
- stateBadge(s.state)
- ]));
+ svcCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Как сейчас открывается каждый сервис.'));
+ svcCard.appendChild(E('div', { 'class': 'zm-ab-svc' }, (data.services || []).map(function(s) {
+ return E('div', { 'class': 'zm-ab-svc-item' + (s.state === 'none' ? ' zm-ab-svc-bad' : '') }, [
+ E('span', { 'class': 'zm-ab-svc-name' }, s.name),
+ stateBadge(s)
+ ]);
+ })));
+ }
+
+ // Не всё открылось, а steer не стоит — подсказываем, куда идти.
+ function steerHref() {
+ return document.querySelector('.zmw-body') ? '#/steer' : L.url('admin/services/zapret-manager/steer');
+ }
+
+ function renderSteerHint() {
+ steerCard.innerHTML = '';
+ steerCard.style.display = 'none';
+ var bad = (data.services || []).filter(function(s) { return s.state === 'none' && !s.warp && s.id !== 'telegram'; });
+ if (busy || data.steer || !bad.length) return;
+ steerCard.style.display = '';
+ steerCard.appendChild(E('div', { 'class': 'zm-card' }, [
+ E('h3', {}, 'Не всё открылось'),
+ E('p', { 'class': 'zm-hint' }, bad.map(function(s) { return s.name; }).join(', ') + ' — не помог и Zapret. Их можно пустить через бесплатный туннель WARP: установите steer, и они сразу пойдут через него.'),
+ E('div', { 'class': 'zm-actions' }, [
+ E('a', { 'class': 'cbi-button cbi-button-positive', 'style': 'text-decoration:none', 'href': steerHref() }, 'Открыть steer')
+ ])
+ ]));
+ }
+
+ // ── вкладка «Видео» ──
+
+ function loadVideos() {
+ if (videos) return;
+ videos = [ VIDEO_DEFAULT ];
+ zm.redbtnAction('video_list', '').then(function(res) {
+ if (res && res.items && res.items.length) videos = res.items;
+ renderVideoSet();
+ }).catch(function() {});
+ }
+
+ function saveVideo(v, text) {
+ zm.redbtnAction('video_set', v).then(function(res) {
+ if (res.error) { zm.toast(res.error, 'error'); return; }
+ data.video = (v === 'default') ? '' : v;
+ customOpen = false;
+ zm.toast(text || 'Видео выбрано', 'info');
+ renderVideoSet();
});
- if (data.geo_dns)
- svcCard.appendChild(E('div', { 'class': 'zm-row' }, [
- E('span', { 'class': 'zm-label' }, 'DNS для ИИ-сервисов'),
- E('span', {}, data.geo_dns)
+ }
+
+ function renderVideoSet() {
+ videoSetCard.innerHTML = '';
+ videoSetCard.appendChild(E('h3', {}, 'Видео во время подбора'));
+ videoSetCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Пока роутер подбирает обход, можно посмотреть видео. Выберите из списка с сервера Zapret Manager или вставьте свою ссылку.'));
+ var pref = videoPref();
+ var curFile = pref === '' ? VIDEO_DEFAULT.file : (pref.indexOf('cat:') === 0 ? pref.slice(4) : '');
+ var isUrl = pref.indexOf('url:') === 0;
+
+ var tiles = (videos || [ VIDEO_DEFAULT ]).map(function(v) {
+ return E('div', {
+ 'class': 'zm-tile' + (!customOpen && curFile === v.file ? ' zm-active' : ''),
+ 'click': function() { saveVideo(v.file === VIDEO_DEFAULT.file ? 'default' : 'cat:' + v.file, 'Выбрано: ' + v.name); }
+ }, v.name);
+ });
+ tiles.push(E('div', {
+ 'class': 'zm-tile' + (customOpen || isUrl ? ' zm-active' : ''),
+ 'click': function() { customOpen = true; renderVideoSet(); }
+ }, 'Своя ссылка'));
+ tiles.push(E('div', {
+ 'class': 'zm-tile' + (!customOpen && pref === 'off' ? ' zm-active' : ''),
+ 'click': function() { saveVideo('off', 'Видео выключено'); }
+ }, 'Без видео'));
+ videoSetCard.appendChild(E('div', { 'class': 'zm-grid' }, tiles));
+
+ if (customOpen || isUrl) {
+ var input = E('input', {
+ 'type': 'text', 'class': 'cbi-input-text', 'placeholder': 'https://…/video.mp4',
+ 'value': isUrl ? pref.slice(4) : ''
+ });
+ videoSetCard.appendChild(E('div', { 'class': 'zm-actions' }, [
+ input,
+ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() {
+ var u = (input.value || '').trim();
+ if (!/^https?:\/\/\S+$/.test(u)) { zm.toast('Ссылка должна начинаться с http:// или https://', 'error'); return; }
+ saveVideo('url:' + u, 'Своё видео сохранено');
+ } }, 'Сохранить')
]));
- }
+ videoSetCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Прямая ссылка на файл MP4 (H.264 + AAC). Ссылки на страницы YouTube не подойдут.'));
+ }
- function renderWarp() {
- warpCard.innerHTML = '';
- var used = (data.services || []).some(function(s) { return s.state === 'warp'; });
- if (!used && !data.warp_up) { warpCard.style.display = 'none'; return; }
- warpCard.style.display = '';
- warpCard.appendChild(E('h3', {}, 'Туннель WARP'));
- warpCard.appendChild(E('div', { 'class': 'zm-row' }, [
- E('span', { 'class': 'zm-label' }, 'Состояние'),
- zm.badge(data.warp_up === true, 'работает', 'не поднят')
- ]));
- if (data.warp_colo)
- warpCard.appendChild(E('div', { 'class': 'zm-row' }, [
- E('span', { 'class': 'zm-label' }, 'Точка Cloudflare'),
- E('span', {}, data.warp_colo)
- ]));
- }
-
- function geoCard() {
- var geo = (data.services || []).filter(function(s) { return s.id === 'geoblock'; })[0];
- if (!geo || geo.state !== 'none') return null;
- return E('div', { 'class': 'zm-card' }, [
- E('h3', {}, 'ИИ-сервисы'),
- E('p', { 'class': 'zm-hint' }, 'ChatGPT, Claude и Gemini не открылись ни через один DNS из списка.'),
- E('div', { 'class': 'zm-actions' }, [
- E('button', {
- 'class': 'cbi-button cbi-button-positive',
- 'click': function() {
- if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; }
- zm.redbtnAction('geo_doh', '').then(function(res) {
- if (res.error) { zm.toast(res.error, 'error'); return; }
- follow(false);
- });
- }
- }, 'Подобрать DNS заново')
- ])
- ]);
- }
-
- function renderDns() {
- dnsCard.innerHTML = '';
- var g = busy ? null : geoCard();
- if (g) dnsCard.appendChild(g);
- if (!data.dns_conflict) return;
- dnsCard.appendChild(E('div', { 'class': 'zm-card' }, [
- E('h3', {}, 'DNS'),
- E('p', { 'class': 'zm-hint' },
- 'DNS over HTTPS забирает запросы сети себе: сервисы, уведённые в WARP, на устройствах пойдут мимо туннеля.'),
- E('div', { 'class': 'zm-actions' }, [
- E('button', {
- 'class': 'cbi-button cbi-button-positive',
- 'click': function() {
- zm.redbtnAction('dns_fix', '').then(function(res) {
- if (res.error) { zm.toast(res.error, 'error'); return; }
- zm.toast('DNS исправлен', 'info');
- refresh();
- });
- }
- }, 'Исправить')
- ])
+ if (pref !== 'off') videoSetCard.appendChild(E('div', { 'class': 'zm-actions' }, [
+ E('button', { 'class': 'cbi-button', 'click': function() {
+ if (busy && videoEl && !previewing) { zm.toast('Видео уже играет на вкладке «Автообход»', 'warning'); return; }
+ videoShow(true, true);
+ } }, 'Посмотреть')
]));
}
+ // ── сборка ──
+
function renderAll() {
- renderMain();
- renderDns();
+ renderTabBar();
+ renderHero();
+ renderSteerHint();
renderServices();
- renderWarp();
+ renderVideoSet();
}
- renderAll();
- wrap.appendChild(mainCard);
- wrap.appendChild(videoCard);
- wrap.appendChild(logCard);
- wrap.appendChild(dnsCard);
- wrap.appendChild(svcCard);
- wrap.appendChild(warpCard);
+ panels.main.appendChild(heroCard);
+ panels.main.appendChild(steerCard);
+ panels.main.appendChild(svcCard);
+ panels.video.appendChild(videoSetCard);
- if (data.running) follow(data.phase !== 'remove', false);
+ renderAll();
+ placeRunCards();
+ wrap.appendChild(tabBar);
+ TABS.forEach(function(t) { wrap.appendChild(panels[t.id]); });
+
+ if (data.running) {
+ lastAction = 'start';
+ follow(true);
+ }
return wrap;
}
});
ZM_INSTALLER_EOF
chmod 0644 '/www/luci-static/resources/view/zapret-manager/redbtn.js'
+cat > '/www/luci-static/resources/view/zapret-manager/steer.js' << 'ZM_INSTALLER_EOF'
+'use strict';
+'require view';
+'require zapret-manager.common as zm';
+
+// steer: выбранные сервисы идут через бесплатный туннель Cloudflare WARP, остальное — как обычно.
+// Страница ставит и связывает всё сама: движок steer, AmneziaWG, ключи WARP, туннель и правила.
+
+var STEPS = [
+ { id: 'pkgs', label: 'steer' },
+ { id: 'awg', label: 'AmneziaWG' },
+ { id: 'keys', label: 'Ключи WARP' },
+ { id: 'tunnel', label: 'Туннель' },
+ { id: 'rules', label: 'Правила' },
+ { id: 'check', label: 'Проверка' }
+];
+
+var PHASE_TEXT = {
+ install: 'Устанавливаем', pkgs: 'Ставим steer', awg: 'Ставим AmneziaWG', keys: 'Получаем ключи WARP',
+ tunnel: 'Поднимаем туннель', warp: 'Настраиваем туннель', rules: 'Применяем правила',
+ check: 'Проверяем', remove: 'Удаляем'
+};
+
+var BLOCKERS = {
+ splify2: 'Установлен splify2 — туннели и списки настраиваются в нём.',
+ steer: 'Движок steer уже настроен вручную — Zapret Manager его не перезаписывает.'
+};
+
+// Значок сервиса: буква на фирменном цвете.
+var SVC_LOOK = {
+ youtube: [ 'YT', '#ff0033' ], discord: [ 'D', '#5865f2' ], instagram: [ 'IG', '#e1306c' ],
+ whatsapp: [ 'W', '#25d366' ], x: [ 'X', '#14171a' ], github: [ 'GH', '#24292f' ],
+ geoblock: [ 'AI', '#10a37f' ], telegram: [ 'T', '#229ed9' ]
+};
+var SVC_SUB = {
+ youtube: 'сайт, видео, приложение', discord: 'сайт, голос, обновления', instagram: 'лента, сторис, звонки',
+ whatsapp: 'сообщения и звонки', x: 'лента и видео', github: 'сайт и загрузки',
+ geoblock: 'ChatGPT, Claude, Gemini', telegram: 'приложение и сайт'
+};
+var AUTO_TEXT = { direct: 'открывается и так', zapret: 'открывается через Zapret', none: 'автообход: не открылся', warp: 'автообход: нужен WARP' };
+
+var ICON_ROUTE = '';
+
+function plainBadge(cls, text) {
+ return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]);
+}
+
+function row(label, node) {
+ return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]);
+}
+
+function fmtAge(sec) {
+ sec = parseInt(sec, 10);
+ if (isNaN(sec)) return 'не было';
+ if (sec < 60) return 'только что';
+ if (sec < 3600) return Math.floor(sec / 60) + ' мин назад';
+ return Math.floor(sec / 3600) + ' ч назад';
+}
+
+function hh(h) { return (h < 10 ? '0' : '') + h + ':00'; }
+
+return view.extend({
+ load: function() {
+ zm.injectCss();
+ return zm.steerStatus().catch(function() { return {}; });
+ },
+
+ render: function(data) {
+ data = data || {};
+ var wrap = E('div', { 'class': 'zm-wrap zm-ab' });
+ var busy = false, lastAction = '', pick = null, autoBusy = false, maxStep = -1, diagRes = null, diagBusy = false;
+
+ var heroCard = E('div', { 'class': 'zm-card zm-ab-hero' });
+ var logEl = E('pre', { 'class': 'zm-log' });
+ var logCard = E('div', { 'class': 'zm-card zm-ab-logcard', 'style': 'display:none' }, [ E('h3', {}, 'Журнал'), logEl ]);
+ var dnsCard = E('div', {});
+ var listCard = E('div', { 'class': 'zm-card' });
+ var checkCard = E('div', { 'class': 'zm-card' });
+ var warpCard = E('div', { 'class': 'zm-card' });
+ var autoCard = E('div', { 'class': 'zm-card' });
+
+ // ── ход операции ──
+
+ function refresh() {
+ return zm.steerStatus().then(function(res) { data = res || {}; renderAll(); });
+ }
+
+ function finish(ok, res) {
+ busy = false;
+ if (res) data = res;
+ var msg = 'Готово';
+ if (!ok) msg = 'Не получилось — подробности в журнале';
+ else if (lastAction === 'install') msg = 'steer установлен и работает';
+ else if (lastAction === 'remove') msg = 'steer удалён';
+ else if (lastAction === 'stop') msg = 'steer выключен — всё идёт напрямую';
+ else if (lastAction === 'lists' || lastAction === 'start') msg = 'Готово, выбор применён';
+ zm.toast(msg, ok ? 'info' : 'error');
+ lastAction = '';
+ diagRes = null;
+ renderAll();
+ if (ok && data.installed) runDiag(true);
+ }
+
+ function waitRouter() {
+ zm.steerStatus().then(function(res) {
+ data = res || {};
+ if (data.running) { follow(); return; }
+ finish(true, res);
+ }).catch(function() { setTimeout(waitRouter, 5000); });
+ }
+
+ function follow() {
+ var ticks = 0;
+ busy = true;
+ logCard.style.display = '';
+ renderAll();
+ zm.pollJob('steer', logEl, function(ok) {
+ // Установка AmneziaWG перезапускает сеть, и роутер на время замолкает — ждём его.
+ zm.steerStatus().then(function(res) {
+ if (res && res.running) { data = res; follow(); return; }
+ finish(ok, res);
+ }).catch(function() {
+ zm.toast('Роутер не отвечает — ждём', 'warning');
+ setTimeout(waitRouter, 5000);
+ });
+ }, function() {
+ if (++ticks % 3) return;
+ zm.steerStatus().then(function(res) { if (res) { data = res; renderHero(); } }).catch(function() {});
+ });
+ }
+
+ function act(action, arg, toastText) {
+ if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; }
+ zm.steerAction(action, arg || '').then(function(res) {
+ if (res.error) { zm.toast(res.error, 'error'); return; }
+ if (res.saved) { zm.toast('Выбор сохранён — применится при установке', 'info'); pick = null; refresh(); return; }
+ if (toastText) zm.toast(toastText, 'warning');
+ lastAction = action;
+ maxStep = -1;
+ data.running = true;
+ data.phase = action === 'install' ? 'pkgs' : action === 'remove' ? 'remove' : /^warp_/.test(action) ? 'warp' : 'rules';
+ follow();
+ }).catch(function() { zm.toast('Роутер не ответил', 'error'); });
+ }
+
+ // ── главная карточка ──
+
+ function stepIndex(p) {
+ for (var i = 0; i < STEPS.length; i++) if (STEPS[i].id === p) return i;
+ return -1;
+ }
+
+ function renderSteps() {
+ var cur = stepIndex(data.phase);
+ if (cur > maxStep) maxStep = cur;
+ return E('div', { 'class': 'zm-ab-steps' }, STEPS.map(function(s, i) {
+ var cls = i < maxStep ? ' zm-ab-done' : (i === maxStep ? ' zm-ab-now' : '');
+ return E('div', { 'class': 'zm-ab-step' + cls }, [
+ E('span', { 'class': 'zm-ab-step-dot' }, i < maxStep ? '✓' : String(i + 1)),
+ E('span', { 'class': 'zm-ab-step-label' }, s.label)
+ ]);
+ }));
+ }
+
+ function stat(label, value, cls) {
+ return E('div', { 'class': 'zm-st-stat' }, [
+ E('span', { 'class': 'zm-st-stat-label' }, label),
+ E('span', { 'class': 'zm-st-stat-value ' + (cls || '') }, value)
+ ]);
+ }
+
+ function tunnelState() {
+ var age = parseInt(data.warp_hs_age, 10), n = parseInt(data.channels, 10) || 0;
+ if (!data.warp_up) return (data.stopped || !n) ? [ 'выключен', 'zm-st-off' ] : [ 'не поднят', 'zm-st-bad' ];
+ if (!isNaN(age) && age < 300) return [ 'работает' + (data.warp_colo ? ' · ' + data.warp_colo : ''), 'zm-st-ok' ];
+ return [ 'нет связи', 'zm-st-warn' ];
+ }
+
+ function renderHero() {
+ heroCard.innerHTML = '';
+ var n = parseInt(data.channels, 10) || 0, status;
+ if (busy || data.running) status = plainBadge('zm-warn', PHASE_TEXT[data.phase] || 'Работаем');
+ else if (!data.installed) status = plainBadge('zm-off', 'не установлен');
+ else if (data.stopped) status = plainBadge('zm-off', 'выключен');
+ else if (!n) status = plainBadge('zm-off', 'сервисы не выбраны');
+ else if (data.steer_running && data.warp_up) status = plainBadge('zm-ok', 'работает');
+ else status = plainBadge('zm-bad', 'не работает');
+
+ heroCard.appendChild(E('div', { 'class': 'zm-ab-head' }, [
+ E('div', { 'class': 'zm-ab-icon' }, [ E(ICON_ROUTE) ]),
+ E('div', { 'class': 'zm-ab-title' }, [
+ E('h3', {}, 'steer'),
+ E('p', { 'class': 'zm-hint' }, 'Выбранные сервисы идут через бесплатный туннель Cloudflare WARP. Остальной интернет — как обычно.')
+ ]),
+ status
+ ]));
+
+ if (data.blocker) {
+ heroCard.appendChild(E('div', { 'class': 'zm-ab-note zm-ab-note-warn' }, BLOCKERS[data.blocker] || data.blocker));
+ return;
+ }
+
+ if (busy) {
+ if (lastAction === 'install') heroCard.appendChild(renderSteps());
+ heroCard.appendChild(E('div', { 'class': 'zm-actions' }, [
+ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() {
+ zm.steerAction('halt', '').then(function(res) {
+ if (res.error) zm.toast(res.error, 'error'); else zm.toast('Останавливаем — роутер закончит текущий шаг', 'warning');
+ });
+ } }, 'Остановить')
+ ]));
+ heroCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно закрыть страницу — всё доделается на роутере.'));
+ return;
+ }
+ if (data.redbtn_running) heroCard.appendChild(E('div', { 'class': 'zm-ab-note zm-ab-note-warn' }, 'Идёт автообход — кнопки заработают, когда он закончит.'));
+
+ if (!data.installed) {
+ heroCard.appendChild(E('div', { 'class': 'zm-st-promo' }, [
+ E('div', {}, [ E('b', {}, '1 кнопка'), E('span', {}, 'всё ставится и настраивается само') ]),
+ E('div', {}, [ E('b', {}, '2–4 минуты'), E('span', {}, 'steer, AmneziaWG, ключи WARP, туннель') ]),
+ E('div', {}, [ E('b', {}, 'Только выбранное'), E('span', {}, 'остальной трафик не трогается') ])
+ ]));
+ heroCard.appendChild(E('div', { 'class': 'zm-actions' }, [
+ E('button', { 'class': 'cbi-button cbi-button-positive zm-ab-go', 'click': function() {
+ act('install', '', 'Устанавливаем steer — это займёт пару минут');
+ } }, [ E(ICON_ROUTE), 'Установить и включить' ])
+ ]));
+ heroCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сразу через WARP пойдут сервисы, отмеченные ниже. Выбор можно поменять в любой момент.'));
+ return;
+ }
+
+ var ts = tunnelState();
+ heroCard.appendChild(E('div', { 'class': 'zm-st-stats' }, [
+ stat('Туннель WARP', ts[0], ts[1]),
+ stat('steer', data.stopped ? 'выключен' : data.steer_running ? 'запущен' : (n ? 'остановлен' : 'ждёт выбора'),
+ data.stopped || !n ? 'zm-st-off' : data.steer_running ? 'zm-st-ok' : 'zm-st-bad'),
+ stat('Через WARP', n ? n + ' ' + (n === 1 ? 'сервис' : n < 5 ? 'сервиса' : 'сервисов') : 'ничего', n ? 'zm-st-ok' : 'zm-st-off')
+ ]));
+
+ var actions = [];
+ if (data.stopped) actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('start', '', 'Включаем steer'); } }, 'Включить'));
+ else {
+ actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('apply', '', 'Перезапускаем туннель и правила'); } }, 'Перезапустить'));
+ actions.push(E('button', { 'class': 'cbi-button', 'click': function() { act('stop', '', 'Выключаем steer'); } }, 'Выключить'));
+ }
+ actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() {
+ if (!confirm('Удалить steer?\n\nБудут удалены steer, туннель WARP и всё, что для них ставилось. Сервисы, которые шли через WARP, пойдут напрямую.')) return;
+ act('remove', '', 'Удаляем steer');
+ } }, 'Удалить'));
+ heroCard.appendChild(E('div', { 'class': 'zm-actions' }, actions));
+ heroCard.appendChild(E('div', { 'class': 'zm-ab-foot' }, [
+ E('span', {}, 'Версия steer: ' + (data.version || '—')),
+ E('span', {}, 'Выключить — всё пойдёт напрямую, настройки сохранятся.')
+ ]));
+ }
+
+ // ── какие сервисы пускать через WARP ──
+
+ function currentPick() {
+ var m = {};
+ (data.services || []).forEach(function(s) { if (s.on) m[s.id] = true; });
+ return m;
+ }
+
+ function renderLists() {
+ listCard.innerHTML = '';
+ listCard.appendChild(E('h3', {}, 'Что пускать через WARP'));
+ listCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Включите нужные сервисы и нажмите «Применить». Автообход включает их сам, если Zapret не помог.'));
+ var cur = currentPick(), sel = pick || cur, changed = false;
+ var list = data.services || [];
+ list.forEach(function(s) { if (!!sel[s.id] !== !!cur[s.id]) changed = true; });
+ listCard.appendChild(E('div', { 'class': 'zm-svc-grid' }, list.map(function(s) {
+ var look = SVC_LOOK[s.id] || [ (s.name || '?').charAt(0), '#6e7681' ];
+ var on = !!sel[s.id];
+ return E('div', {
+ 'class': 'zm-svc' + (on ? ' zm-svc-on' : ''),
+ 'role': 'switch', 'aria-checked': on ? 'true' : 'false', 'tabindex': '0',
+ 'click': function() {
+ if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; }
+ pick = {};
+ for (var k in sel) if (sel[k]) pick[k] = true;
+ if (pick[s.id]) delete pick[s.id]; else pick[s.id] = true;
+ renderLists();
+ }
+ }, [
+ E('span', { 'class': 'zm-svc-ico', 'style': 'background:' + look[1] }, look[0]),
+ E('span', { 'class': 'zm-svc-text' }, [
+ E('span', { 'class': 'zm-svc-name' }, s.name),
+ E('span', { 'class': 'zm-svc-sub' }, AUTO_TEXT[s.auto] || SVC_SUB[s.id] || '')
+ ]),
+ E('span', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : '') }, [ E('span', {}) ])
+ ]);
+ })));
+ if (changed) {
+ listCard.appendChild(E('div', { 'class': 'zm-actions zm-st-apply' }, [
+ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() {
+ var ids = list.filter(function(s) { return sel[s.id]; }).map(function(s) { return s.id; });
+ pick = null;
+ act('lists', ids.join(','), 'Применяем выбор');
+ } }, 'Применить'),
+ E('button', { 'class': 'cbi-button', 'click': function() { pick = null; renderLists(); } }, 'Отмена'),
+ E('span', { 'class': 'zm-hint' }, 'Есть несохранённые изменения')
+ ]));
+ }
+ }
+
+ // ── проверка ──
+
+ function runDiag(quiet) {
+ if (diagBusy) return;
+ diagBusy = true;
+ renderCheck();
+ zm.steerAction('diag', '').then(function(res) {
+ diagBusy = false;
+ diagRes = res || {};
+ renderCheck();
+ if (!quiet) zm.toast('Проверка закончена', 'info');
+ }).catch(function() { diagBusy = false; renderCheck(); });
+ }
+
+ function renderCheck() {
+ checkCard.innerHTML = '';
+ checkCard.style.display = data.installed ? '' : 'none';
+ if (!data.installed) return;
+ checkCard.appendChild(E('h3', {}, 'Проверка'));
+ checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутер проверит туннель и спросит сам steer, всё ли на месте.'));
+ if (diagRes) {
+ var items = [];
+ if (diagRes.warp === 'on') items.push([ 'ok', 'Трафик идёт через WARP' + (diagRes.colo ? ' (сервер ' + diagRes.colo + ')' : ''), '' ]);
+ else if (diagRes.warp === 'off') items.push([ 'fail', 'Трафик через WARP не идёт', 'Нажмите «Перезапустить туннель» или «Сменить точку входа» ниже' ]);
+ var d = diagRes.diag;
+ if (d && d.checks) d.checks.forEach(function(c) {
+ if (c.verdict === 'ok' || c.verdict === 'warn' || c.verdict === 'fail') items.push([ c.verdict, c.what, c.why ]);
+ });
+ var bad = items.filter(function(i) { return i[0] === 'fail'; }).length, warn = items.filter(function(i) { return i[0] === 'warn'; }).length;
+ checkCard.appendChild(E('div', { 'class': 'zm-ab-note ' + (bad ? 'zm-st-note-bad' : warn ? 'zm-ab-note-warn' : 'zm-st-note-ok') },
+ bad ? 'Есть поломка — посмотрите красные пункты' : warn ? 'Работает, но есть замечания' : 'Всё в порядке'));
+ checkCard.appendChild(E('div', { 'class': 'zm-st-checks' }, items.map(function(i) {
+ return E('div', { 'class': 'zm-st-check zm-st-check-' + i[0] }, [
+ E('span', { 'class': 'zm-st-check-dot' }, i[0] === 'ok' ? '✓' : i[0] === 'warn' ? '!' : '✕'),
+ E('span', {}, [ E('span', { 'class': 'zm-st-check-what' }, i[1]), i[2] ? E('span', { 'class': 'zm-st-check-why' }, i[2]) : '' ])
+ ]);
+ })));
+ }
+ checkCard.appendChild(E('div', { 'class': 'zm-actions' }, [
+ E('button', { 'class': 'cbi-button', 'disabled': diagBusy || busy ? '' : null, 'click': function() { runDiag(false); } },
+ diagBusy ? 'Проверяем…' : 'Проверить')
+ ]));
+ }
+
+ // ── туннель WARP ──
+
+ function renderWarp() {
+ warpCard.innerHTML = '';
+ warpCard.style.display = data.installed ? '' : 'none';
+ if (!data.installed) return;
+ warpCard.appendChild(E('h3', {}, 'Туннель WARP'));
+ var ts = tunnelState();
+ var st = plainBadge(ts[1] === 'zm-st-ok' ? 'zm-ok' : ts[1] === 'zm-st-warn' ? 'zm-warn' : ts[1] === 'zm-st-bad' ? 'zm-bad' : 'zm-off', ts[0].split(' · ')[0]);
+ warpCard.appendChild(E('div', { 'class': 'bt-cols' }, [
+ E('div', { 'class': 'bt-col' }, [
+ row('Состояние', st),
+ row('Точка входа', E('span', {}, data.warp_host ? data.warp_host + ':' + data.warp_port : '—')),
+ row('Сервер Cloudflare', E('span', {}, data.warp_colo || '—'))
+ ]),
+ E('div', { 'class': 'bt-col' }, [
+ row('Последняя связь', E('span', {}, data.warp_up ? fmtAge(data.warp_hs_age) : '—')),
+ row('Получено', E('span', {}, zm.fmtSize(+data.warp_rx || 0))),
+ row('Отправлено', E('span', {}, zm.fmtSize(+data.warp_tx || 0)))
+ ])
+ ]));
+ warpCard.appendChild(E('div', { 'class': 'zm-actions' }, [
+ E('button', { 'class': 'cbi-button', 'click': function() { act('warp_restart', '', 'Перезапускаем туннель'); } }, 'Перезапустить туннель'),
+ E('button', { 'class': 'cbi-button', 'click': function() { act('warp_endpoint', '', 'Ищем быструю точку входа'); } }, 'Сменить точку входа'),
+ E('button', { 'class': 'cbi-button', 'click': function() {
+ if (!confirm('Пересоздать WARP?\n\nБудут получены новые ключи, туннель переподключится. Помогает, если Cloudflare перестал пускать старые ключи.')) return;
+ act('warp_recreate', '', 'Пересоздаём WARP');
+ } }, 'Новые ключи')
+ ]));
+ warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Не работает — «Перезапустить туннель». Медленно — «Сменить точку входа». Совсем не помогает — «Новые ключи».'));
+ }
+
+ // ── автоперезапуск ──
+
+ function renderAuto() {
+ autoCard.innerHTML = '';
+ autoCard.style.display = data.installed ? '' : 'none';
+ if (!data.installed) return;
+ autoCard.appendChild(E('h3', {}, 'Автоперезапуск'));
+ autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Туннель и steer перезапускаются по расписанию — помогает, если обход со временем «подвисает».'));
+ var cur = data.autorestart || '';
+ var am = cur === '' ? 'off' : (cur.indexOf('every:') === 0 ? 'every' + cur.split(':')[1] : 'daily');
+ var curHour = am === 'daily' ? parseInt(cur.split(':')[1], 10) : 4;
+ if (isNaN(curHour) || curHour < 0 || curHour > 23) curHour = 4;
+ var opts = [];
+ for (var h = 0; h < 24; h++) opts.push(E('option', { 'value': String(h), 'selected': h === curHour ? 'selected' : null }, hh(h)));
+ var hourSel = E('select', { 'class': 'cbi-input-select zm-hour-select' }, opts);
+ var dailyRow = E('div', { 'class': 'zm-actions', 'style': am === 'daily' ? '' : 'display:none' }, [
+ E('span', { 'class': 'zm-label' }, 'Время перезапуска'),
+ hourSel,
+ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { doAuto('daily:' + hourSel.value); } },
+ am === 'daily' ? 'Сохранить время' : 'Включить')
+ ]);
+ function tile(id, label, onclick) {
+ return E('div', { 'class': 'zm-tile' + (am === id ? ' zm-active' : ''), 'click': onclick }, label);
+ }
+ autoCard.appendChild(E('div', { 'class': 'zm-grid' }, [
+ tile('off', 'Выключен', function() { if (am !== 'off') doAuto('off'); }),
+ tile('every2', 'Каждые 2 часа', function() { if (am !== 'every2') doAuto('every:2'); }),
+ tile('every6', 'Каждые 6 часов', function() { if (am !== 'every6') doAuto('every:6'); }),
+ tile('every12', 'Каждые 12 часов', function() { if (am !== 'every12') doAuto('every:12'); }),
+ tile('daily', am === 'daily' ? 'Ежедневно в ' + hh(curHour) : 'Ежедневно в заданное время', function() {
+ dailyRow.style.display = '';
+ hourSel.focus();
+ })
+ ]));
+ autoCard.appendChild(dailyRow);
+ }
+
+ function doAuto(value) {
+ if (autoBusy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; }
+ autoBusy = true;
+ zm.steerAction('autorestart', value).then(function(res) {
+ autoBusy = false;
+ if (res.error) { zm.toast(res.error, 'error'); return; }
+ zm.toast(value === 'off' ? 'Автоперезапуск выключен' : 'Автоперезапуск настроен', 'info');
+ refresh();
+ }).catch(function() { autoBusy = false; });
+ }
+
+ // ── спор за DNS ──
+
+ function renderDns() {
+ dnsCard.innerHTML = '';
+ dnsCard.style.display = 'none';
+ if (!data.dns_conflict || busy) return;
+ dnsCard.style.display = '';
+ dnsCard.appendChild(E('div', { 'class': 'zm-card' }, [
+ E('h3', {}, 'Нужно исправить DNS'),
+ E('p', { 'class': 'zm-hint' }, 'DNS over HTTPS снова перехватывает запросы устройств, и сервисы через WARP не откроются. Шифрованный DNS после исправления продолжит работать.'),
+ E('div', { 'class': 'zm-actions' }, [
+ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() {
+ zm.steerAction('dns_fix', '').then(function(res) {
+ if (res.error) { zm.toast(res.error, 'error'); return; }
+ zm.toast('DNS исправлен', 'info');
+ refresh();
+ });
+ } }, 'Исправить')
+ ])
+ ]));
+ }
+
+ function renderAll() {
+ renderHero();
+ renderDns();
+ renderLists();
+ renderCheck();
+ renderWarp();
+ renderAuto();
+ }
+
+ renderAll();
+ wrap.appendChild(E('div', { 'class': 'zm-ab-panel' }, [ heroCard, logCard, dnsCard, listCard, checkCard, warpCard, autoCard ]));
+
+ if (data.running) { lastAction = data.phase === 'remove' ? 'remove' : [ 'pkgs', 'awg', 'keys', 'tunnel' ].indexOf(data.phase) >= 0 ? 'install' : 'apply'; follow(); }
+ else if (data.installed && !data.stopped && (parseInt(data.channels, 10) || 0) > 0) runDiag(true);
+ return wrap;
+ }
+});
+ZM_INSTALLER_EOF
+chmod 0644 '/www/luci-static/resources/view/zapret-manager/steer.js'
+
mkdir -p /usr/share/zm-redbtn/lists
cat > '/usr/share/zm-redbtn/services.conf' << 'ZM_INSTALLER_EOF'
-# Сервисы красной кнопки. Каталог и цели — из BigRedButton (gitlab.com/xyzmean/brb).
+# Сервисы автообхода. Каталог и цели — из BigRedButton (gitlab.com/xyzmean/brb).
#
-# ФОРМАТ: id|название|списки доменов|списки адресов|обязательные цели|дополнительные цели|признак отказа|наборы
-#
-# наборы — имена наборов .srs каталога splify2-lists (издатель itdoginfo), через запятую;
-# по ним сервис уводится в туннель. Не скачались — берутся списки из пакета
-# (второе и третье поля). ИИ-сервисы и Telegram в туннель не уходят вовсе.
+# ФОРМАТ: id|название|списки доменов|списки адресов|обязательные цели|дополнительные цели|признак отказа
#
# списки — файлы в /usr/share/zm-redbtn/lists, через запятую; по ним движок уводит сервис в
# туннель. Адресные списки только там, где сервис ходит по адресам без DNS
@@ -7304,14 +8260,14 @@ cat > '/usr/share/zm-redbtn/services.conf' << 'ZM_INSTALLER_EOF'
# дополнительные — из них должно открыться не меньше RB_BAR процентов.
# признак отказа — фразы в ответе, при которых сервис считается закрытым, хотя ответил
# (геоблок отвечает кодом 200 со страницей «недоступно в вашей стране»).
-youtube|YouTube|svc_youtube.lst||www.youtube.com,youtubei.googleapis.com,manifest.googlevideo.com|youtu.be,i.ytimg.com,i9.ytimg.com,yt3.ggpht.com,yt4.ggpht.com,jnn-pa.googleapis.com,signaler-pa.youtube.com,yt3.googleusercontent.com,rr4---sn-4g5e6nze.googlevideo.com,rr4---sn-5go7yner.googlevideo.com,rr5---sn-n8v7knez.googlevideo.com,rr2---sn-q4fl6ndl.googlevideo.com,rr1---sn-q4fl6n6y.googlevideo.com,rr14---sn-n8v7kn7r.googlevideo.com,rr4---sn-jvhnu5g-c35d.googlevideo.com,rr1---sn-gvnuxaxjvh-jx3z.googlevideo.com,rr12---sn-gvnuxaxjvh-bvwz.googlevideo.com,rr1---sn-ug5onuxaxjvh-n8v6.googlevideo.com||youtube
-discord|Discord|svc_discord.lst|discord.lst|discord.com,gateway.discord.gg,updates.discord.com|cdn.discordapp.com,media.discordapp.net||discord
-instagram|Instagram|svc_meta.lst|meta.lst|www.instagram.com|i.instagram.com,graph.instagram.com,scontent.cdninstagram.com||meta
-whatsapp|WhatsApp|svc_meta.lst|whatsapp.lst|web.whatsapp.com|static.whatsapp.net,mmg.whatsapp.net||meta
-x|X (Twitter)|svc_twitter.lst|twitter_x.lst|x.com|twitter.com,abs.twimg.com,video.twimg.com||twitter
-github|GitHub|own_github.lst||github.com,raw.githubusercontent.com,objects.githubusercontent.com|codeload.github.com,api.github.com,ghcr.io||
-geoblock|ИИ-сервисы|||chatgpt.com|claude.ai,api.openai.com,gemini.google.com|App unavailable,unsupported_country,not available in your country,not available in your region|
-telegram|Telegram|||web.telegram.org|t.me,core.telegram.org,telegra.ph||
+youtube|YouTube|svc_youtube.lst||www.youtube.com,youtubei.googleapis.com,manifest.googlevideo.com|youtu.be,i.ytimg.com,i9.ytimg.com,yt3.ggpht.com,yt4.ggpht.com,jnn-pa.googleapis.com,signaler-pa.youtube.com,yt3.googleusercontent.com,rr4---sn-4g5e6nze.googlevideo.com,rr4---sn-5go7yner.googlevideo.com,rr5---sn-n8v7knez.googlevideo.com,rr2---sn-q4fl6ndl.googlevideo.com,rr1---sn-q4fl6n6y.googlevideo.com,rr14---sn-n8v7kn7r.googlevideo.com,rr4---sn-jvhnu5g-c35d.googlevideo.com,rr1---sn-gvnuxaxjvh-jx3z.googlevideo.com,rr12---sn-gvnuxaxjvh-bvwz.googlevideo.com,rr1---sn-ug5onuxaxjvh-n8v6.googlevideo.com|
+discord|Discord|svc_discord.lst|discord.lst|discord.com,gateway.discord.gg,updates.discord.com|cdn.discordapp.com,media.discordapp.net|
+instagram|Instagram|svc_meta.lst|meta.lst|www.instagram.com|i.instagram.com,graph.instagram.com,scontent.cdninstagram.com|
+whatsapp|WhatsApp|svc_meta.lst|whatsapp.lst|web.whatsapp.com|static.whatsapp.net,mmg.whatsapp.net|
+x|X (Twitter)|svc_twitter.lst|twitter_x.lst|x.com|twitter.com,abs.twimg.com,video.twimg.com|
+github|GitHub|own_github.lst||github.com,raw.githubusercontent.com,objects.githubusercontent.com|codeload.github.com,api.github.com,ghcr.io|
+geoblock|ИИ-сервисы|geoblock.lst||chatgpt.com|claude.ai,api.openai.com,gemini.google.com|App unavailable,unsupported_country,not available in your country,not available in your region
+telegram|Telegram|svc_telegram.lst|telegram.lst|web.telegram.org|t.me,core.telegram.org,telegra.ph|
ZM_INSTALLER_EOF
cat > '/usr/share/zm-redbtn/lists/svc_youtube.lst' << 'ZM_INSTALLER_EOF'
ggpht.com
@@ -7333,6 +8289,37 @@ ytimg.com
ytimg.l.google.com
yting.com
ZM_INSTALLER_EOF
+cat > '/usr/share/zm-redbtn/lists/svc_telegram.lst' << 'ZM_INSTALLER_EOF'
+t.me
+telegram.me
+telegram.org
+telegram.dog
+telegra.ph
+telesco.pe
+tdesktop.com
+telegram-cdn.org
+cdn-telegram.org
+graph.org
+tg.dev
+fragment.com
+comments.app
+contest.com
+tx.me
+telegram.space
+ZM_INSTALLER_EOF
+cat > '/usr/share/zm-redbtn/lists/telegram.lst' << 'ZM_INSTALLER_EOF'
+# Сети Telegram (core.telegram.org/resources/cidr.txt), только IPv4: туннель WARP здесь без IPv6.
+91.105.192.0/23
+91.108.4.0/22
+91.108.8.0/22
+91.108.12.0/22
+91.108.16.0/22
+91.108.20.0/22
+91.108.56.0/22
+95.161.64.0/20
+149.154.160.0/20
+185.76.151.0/24
+ZM_INSTALLER_EOF
cat > '/usr/share/zm-redbtn/lists/svc_discord.lst' << 'ZM_INSTALLER_EOF'
dis.gd
discord-activities.com
@@ -7421,11 +8408,476 @@ uploads.github.com
user-images.githubusercontent.com
www.github.com
ZM_INSTALLER_EOF
+cat > '/usr/share/zm-redbtn/lists/geoblock.lst' << 'ZM_INSTALLER_EOF'
+4pda.to
+4pda.ws
+a-vrv.akamaized.net
+abercrombie.com
+adidas.com
+adobe.com
+adobe.io
+adobe.net
+affinity.studio
+ai-chat.bsg.brave.com
+ai.com
+aircanada.com
+akc.org
+all3dp.com
+alphacoders.com
+alza.hu
+amazfitwatchfaces.com
+amplitude.com
+analog.com
+andrevi.ch
+ansys.com
+anthropic.com
+anthropic.qualtrics.com
+api.jetbrains.ai
+api.themoviedb.org
+arc.net
+arduino.cc
+assets.heroku.com
+atlassian.com
+att.com
+augmentcode.com
+autodesk.com
+backend-v2.crixet.com
+bell-sw.com
+bestbuy.com
+bitdefender.com
+bitnami.com
+blinkshot.io
+bosch.com
+boschaftermarket.com
+boschautoparts.com
+brawlstarsgame.com
+broadcom.com
+broncosportforum.com
+btod.com
+buanzo.org
+buf.build
+builds.parsec.app
+buymeacoffee.com
+canva.com
+canva.dev
+capacitorjs.com
+carrefouruae.com
+cats.com
+cdn.web-platform.io
+cdromance.org
+cdw.com
+chainreactioncycles.com
+chaos.com
+chat.com
+chat.openai.com.cdn.cloudflare.net
+chatgpt.com
+cisco.com
+cisecurity.org
+citrix.com
+clamav.net
+clashofclans.com
+clashroyaleapp.com
+claude.ai
+claude.com
+clevelandclinic.org
+clickup.com
+clip.opus.pro
+code.gist.build
+codeium.com
+coingate.com
+community.sophos.com
+connect.ngrok-agent.com
+contabo.com
+copilot.microsoft.com
+corsair.com
+cpu-monkey.com
+credly.com
+crunchyroll.com
+cursor-cdn.com
+cursor.sh
+cursorapi.com
+cvedetails.com
+daemon-tools.cc
+dashboard.algolia.com
+dashboard.gitguardian.com
+data-cdn.mbamupdates.com
+data.cline.bot
+deepl.com
+deezer.com
+dell.com
+dellcdn.com
+designer.microsoft.com
+developer.nvidia.com
+devexpress.com
+diabrowser.com
+digitalcontent.sky
+disctech.com
+disneyplus.com
+docs.liquibase.com
+document360.com
+document360.io
+download3.omnissa.com
+downloads.intercomcdn.com
+ducati.com
+dyson.com
+easydmarc.com
+editorx.com
+elevenlabs.io
+eneba.com
+etsy.com
+exchanger.bits.media
+expandrive.com
+extremetech.com
+f1.com
+fast.com
+filebin.net
+files.manus.cdn
+fivetran.com
+flir.com
+flir.eu
+flourish.studio
+fluke.com
+flukenetworks.com
+flyertalk.com
+footballapi.pulselive.com
+force-user-content.com
+force.com
+formula1.com
+forum.netgate.com
+framer.app
+framer.com
+framercanvas.com
+framercdn.com
+framerstatic.com
+framerusercontent.com
+freeimages.com
+fxnetworks.com
+g2a.com
+gamestop.com
+gaming.amazon.com
+geforcenow.com
+genspark.ai
+geolocation.onetrust.com
+getoutline.com
+gfn.am
+ghostrc.game.idtech.services
+global.fncstatic.com
+glpals.com
+gofund.me
+gofundme.com
+gonift.com
+gpsonextra.net
+gpu-monkey.com
+gql.twitch.tv
+grafana.com
+graylog.org
+grizzlysms.com
+grok.com
+grok.x.com
+groq.com
+groupon.com
+guilded.gg
+habr.com
+hashicorp.com
+haydaygame.com
+hbomax.com
+hc-ping.com
+hchk.io
+healthline.com
+herokucdn.com
+hollisterco.com
+home-connect.com
+home.by.me
+hostinger.com
+hotels.com
+housebrand.com
+htmhell.dev
+httptoolkit.com
+hume.ai
+hybrid-analysis.com
+ibm.com
+iedb.org
+iherb.com
+ikea.com
+image.tmdb.org
+imgur.com
+indeed.com
+install.launcher.omniverse.nvidia.com
+intel.com
+intel.de
+intel.nl
+intelix.sophos.com
+intercom.io
+intuit.com
+intuitibits.com
+itninja.com
+jamf.com
+jetbrains.com
+jetbrains.space
+jetbrains.team
+joesandbox.com
+kaleido.ai
+keysight.com
+kinogo.la
+klarna.com
+kmail-lists.com
+lambdalabs.com
+langdock.com
+last.fm
+ldoceonline.com
+legalshield.com
+lgeapi.com
+lgthinq.com
+lidarr.audio
+lifehacker.com
+lightning.ai
+lookerstudio.google.com
+lyst.com
+mailerlite.com
+mailinator.com
+manus.im
+manybooks.net
+marvelsnap.com
+mattermost.com
+max.com
+medicalnewstoday.com
+meetup.com
+metopera.org
+middlewareinventory.com
+mintmobile.com
+miracleptr.wordpress.com
+mixcloud.com
+mongodb.com
+monoprice.com
+mouser.com
+mouser.fi
+mssg.me
+multisim.com
+myheritage.com
+myjetbrains.com
+myparallels.com
+myqrcode.com
+nba.com
+neo4j.com
+netacad.com
+netapp.com
+netflix.ca
+netflix.com
+netflix.net
+netflixinvestor.com
+netflixtechblog.com
+netlify.com
+new.abb.com
+newark.com
+news.google.com
+newsroom.porsche.com
+nfl.com
+nflxext.com
+nflximg.com
+nflximg.net
+nflxsearch.net
+nflxso.net
+nflxvideo.net
+ngrok.com
+ni.com
+nike.com
+nitropdf.com
+nordaccount.com
+nordcdn.com
+nordvpn.com
+notion-emojis.s3-us-west-2.amazonaws.com
+notion-static.com
+notion.com
+notion.new
+notion.site
+notion.so
+ntp.msn.com
+nxp.com
+oaistatic.com
+oaiusercontent.com
+ocstore.com
+octopus.do
+omnissa.com
+onfastspring.com
+onshape.com
+openai.com
+openh264.org
+openrouter.ai
+oracle.com
+oraclecloud.com
+paddle.com
+paddlestatus.com
+pandasecurity.com
+parallels.cn
+parallels.com
+parallels.net
+parallelsaccess.com
+paritydeals.com
+patreon.com
+patreonusercontent.com
+paywithmoon.com
+pcgamesn.com
+pcmag.com
+penguin.com
+penguinrandomhouse.com
+pexels.com
+philiascans.org
+pingdom.com
+pkgs.tailscale.com
+platform.activestate.com
+plugshare.com
+posthog.com
+premierleague.com
+primark.com
+primevideo.com
+proactivebackend-pa.googleapis.com
+production-openaicom-storage.azureedge.net
+profitwell.com
+prowlarr.com
+public.parsec.app
+qobuz.com
+qodana.cloud
+qoder.com
+qt.io
+qualcomm.com
+quicknode.com
+qwant.com
+reactflow.dev
+recraft.ai
+redis.io
+redislabs.com
+remna.st
+remove.bg
+research.net
+reve.art
+salesforce-experience.com
+salesforce-hub.com
+salesforce-scrt.com
+salesforce-setup.com
+salesforce-sites.com
+salesforce.com
+salesforceiq.com
+salesforceliveagent.com
+schneider-electric.com
+sdxcentral.com
+se.com
+seconddinnertech.com
+semrush.com
+sentry.dev
+sentry.io
+sephora.com
+servarr.com
+sfdcopens.com
+sharefile.com
+sharefile.io
+shinyhardware.co.uk
+shop.gameloft.com
+sigsauer.com
+singlekey-id.com
+site.com
+siteground.com
+sketchup.com
+sky.com
+skycdp.com
+smartbear.co
+smartbear.com
+smartdeploy.com
+snapgametech.com
+snapgene.com
+snort.org
+snyk.io
+solarwinds.com
+sonara.ai
+sora.com
+spacelift.io
+spiceworks.com
+spitfireaudio.com
+spotify.com
+squadbustersgame.com
+squareup.com
+strava.com
+streamable.com
+suggestqueries.google.com
+supercell.com
+support.anydesk.com
+support.xerox.com
+surveymonkey.com
+swagger.io
+swapd.co
+synoforum.com
+syslog-ng.com
+tableau.com
+talosintelligence.com
+teamviewer.com
+techbargains.com
+telemetr.io
+tempmail.plus
+terraform.io
+theaudiodb.com
+themoviedb.org
+ti.com
+tidal.com
+timberland.de
+tmdb-image-prod.b-cdn.net
+tmdb.com
+tmdb.org
+toolbox.app
+torrenteditor.com
+trae.ai
+trailblazer.me
+trailhead.com
+transferwise.com
+tria.ge
+truthsocial.com
+tsmc.com
+tutanota.com
+typing.com
+uaudio.com
+uizard.io
+unscreen.com
+upwork.com
+usher.ttvnw.net
+v.vrv.co
+vagrantcloud.com
+veeam.com
+verificationacademy.com
+vmware.com
+vod-fy.crunchyrollcdn.com
+volkswagen-classic-parts.com
+vyos.io
+w.atwiki.jp
+walmart.com
+watchguard.com
+watermarkremover.io
+wbagora.com
+wbgames.com
+wdfiles.com
+weather.com
+webnames.ca
+weebly.com
+wetransfer.com
+widgetapp.stream
+wikidot.com
+windsurf.com
+wise.com
+wpengine.com
+wunderground.com
+www3.corsair.com
+x-minus.pro
+x.ai
+xiaomi.eu
+xrite.com
+xsts.auth.xboxlive.com
+xtracloud.net
+yeggi.com
+youtrack.cloud
+zapier.com
+zedge.net
+zerossl.com
+ZM_INSTALLER_EOF
cat > '/usr/share/zm-redbtn/lists/discord.lst' << 'ZM_INSTALLER_EOF'
-104.16.0.0/12
138.128.137.32/28
138.128.140.240/28
-162.159.128.0/19
172.65.202.16/28
34.0.129.176/28
34.0.130.176/28
@@ -7969,82 +9421,7 @@ cat > '/usr/share/zm-redbtn/lists/twitter_x.lst' << 'ZM_INSTALLER_EOF'
202.160.128.0/22
208.91.196.0/23
ZM_INSTALLER_EOF
-rm -f /usr/share/zm-redbtn/lists/geoblock.lst
chmod 0644 /usr/share/zm-redbtn/services.conf /usr/share/zm-redbtn/lists/*.lst
-cat > '/etc/init.d/zm-geodns' << 'ZM_INSTALLER_EOF'
-#!/bin/sh /etc/rc.common
-# DNS для ИИ-сервисов красной кнопки Zapret Manager: свой экземпляр https-dns-proxy на
-# 127.0.0.1:5055 и файл dnsmasq, который отдаёт ему ТОЛЬКО домены из списка
-# /etc/zm-redbtn/geo-domains.lst. Остальной DNS роутера не меняется.
-START=99
-STOP=10
-USE_PROCD=1
-
-RES=/etc/zm-redbtn/geo.resolver
-DOM=/etc/zm-redbtn/geo-domains.lst
-PORT=5055
-
-confdir() {
- local d
- d=$(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | head -n 1)
- [ -n "$d" ] || d=/tmp/dnsmasq.d
- echo "$d"
-}
-
-dnsmasq_on() {
- local d f
- d="$(confdir)"; f="$d/zm-geo.conf"
- mkdir -p "$d"
- # По двадцать доменов в строке: dnsmasq понимает server=/a/b/c/адрес.
- awk -v p="$PORT" 'NF { l = l "/" $1; if (++n == 20) { print "server=" l "/127.0.0.1#" p; l = ""; n = 0 } }
- END { if (n) print "server=" l "/127.0.0.1#" p }' "$DOM" > "$f.tmp"
- if cmp -s "$f.tmp" "$f"; then rm -f "$f.tmp"; else mv "$f.tmp" "$f"; /etc/init.d/dnsmasq restart >/dev/null 2>&1; fi
-}
-
-dnsmasq_off() {
- local f
- f="$(confdir)/zm-geo.conf"
- [ -f "$f" ] || return 0
- rm -f "$f"
- /etc/init.d/dnsmasq restart >/dev/null 2>&1
-}
-
-start_service() {
- [ -s "$RES" ] && [ -s "$DOM" ] && [ -x /usr/sbin/https-dns-proxy ] || return 0
- . "$RES"
- procd_open_instance
- procd_set_param command /usr/sbin/https-dns-proxy -a 127.0.0.1 -p "$PORT" -4 \
- -b "$GEO_BOOT" -r "$GEO_URL" -u nobody -g nogroup
- procd_set_param respawn 3600 5 0
- procd_close_instance
- dnsmasq_on
-}
-
-stop_service() {
- dnsmasq_off
-}
-ZM_INSTALLER_EOF
-chmod 0755 /etc/init.d/zm-geodns
-cat > '/usr/share/zm-redbtn/doh.conf' << 'ZM_INSTALLER_EOF'
-# Резолверы для ИИ-сервисов. Пул geo — из BigRedButton (gitlab.com/xyzmean/brb) плюс dns.yo1nk.app.
-# Формат: id|название|ссылка DoH|bootstrap-серверы|роль. Берётся самый быстрый geo, чей прокси отвечает.
-yo1nk|dns.yo1nk.app|https://dns.yo1nk.app/dns-query|1.1.1.1,77.88.8.8|geo
-malw|dns.malw.link (снимает геоблок)|https://dns.malw.link/dns-query|1.1.1.1,8.8.8.8,77.88.8.8|geo
-malw_cf|dns.malw.link через Cloudflare|https://5u35p8m9i7.cloudflare-gateway.com/dns-query|1.1.1.1,8.8.8.8,77.88.8.8|geo
-comss|Comss.one|https://dns.comss.one/dns-query|1.1.1.1,77.88.8.8|geo
-comss_ru|Comss RU|https://dns.comss.ru/dns-query|1.1.1.1,77.88.8.8|geo
-mafioznik|Mafioznik|https://dns.mafioznik.com/dns-query|1.1.1.1,77.88.8.8|geo
-mafioznik_xyz|Mafioznik XYZ|https://dns.mafioznik.xyz/dns-query|1.1.1.1,77.88.8.8|geo
-astracat|AstraCat|https://dns.astrakat.ru/dns-query|1.1.1.1,77.88.8.8|geo
-astracat_8443|AstraCat :8443|https://dns.astrakat.ru:8443/dns-query|1.1.1.1,77.88.8.8|geo
-geohide|GeoHide :444|https://dns.geohide.ru:444/dns-query|1.1.1.1,77.88.8.8|geo
-geohide_8443|GeoHide :8443|https://dns.geohide.ru:8443/dns-query|1.1.1.1,77.88.8.8|geo
-xbox|Xbox DNS|https://xbox-dns.ru/dns-query|1.1.1.1,77.88.8.8|geo
-cloudflare|Cloudflare|https://cloudflare-dns.com/dns-query|1.1.1.1,1.0.0.1|plain
-google|Google|https://dns.google/dns-query|8.8.8.8,8.8.4.4|plain
-yandex|Яндекс (запасной, работает изнутри страны)|https://common.dot.dns.yandex.net/dns-query|77.88.8.8,77.88.8.1|fallback
-ZM_INSTALLER_EOF
-chmod 0644 /usr/share/zm-redbtn/doh.conf
mkdir -p /www/luci-static/resources/view/zapret-manager
chmod 0755 /www/luci-static/resources/view/zapret-manager
@@ -10087,15 +11464,99 @@ html.zm-theme-dark .zm-config-editor { border-color: rgba(255,255,255,.14); }
}
.cbi-page-actions { display: none !important; }
-.zm-redbtn, .zm-redbtn:focus {
- background: #d32f2f !important; border-color: #b71c1c !important; color: #fff !important;
- font-size: 1.1em; font-weight: 600; padding: .7em 2.2em; border-radius: 999px;
+.zm-ab-panel { display: flex; flex-direction: column; gap: 16px; }
+.zm-ab-head { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
+.zm-ab-head > .zm-badge { margin-left: auto; }
+.zm-ab-title { flex: 1 1 260px; min-width: 0; }
+.zm-ab-title h3 { margin: 2px 0 2px 0; font-size: 17px; }
+.zm-ab-title .zm-hint { margin-top: 0; }
+.zm-ab-icon {
+ width: 44px; height: 44px; border-radius: 13px; flex-shrink: 0;
+ display: grid; place-items: center; color: #fff;
+ background: linear-gradient(135deg, #5fd8ff 0%, #1aa3ff 50%, #0a7cff 100%);
+ box-shadow: 0 8px 18px -8px rgba(10,124,255,.8);
+}
+.zm-ab-hero .zm-grid { margin-top: 16px; }
+.zm-ab-go { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; }
+.zm-ab-go svg { width: 17px; height: 17px; }
+.zm-ab-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 12.5px; opacity: .8; border-top: 1px dashed rgba(0,0,0,.12); padding-top: 12px; margin-top: 4px; }
+html.zm-theme-dark .zm-ab-foot { border-top-color: rgba(255,255,255,.12); }
+.zm-ab-sum b { font-size: 15px; }
+.zm-ab-sum-ok b { color: #1a7f37; }
+.zm-ab-note { margin-top: 14px; border-radius: 10px; padding: 11px 14px; font-size: 13px; font-weight: 600; }
+.zm-ab-note-warn { background: rgba(191,135,0,.12); color: #9a6700; border: 1px solid rgba(191,135,0,.3); }
+
+.zm-ab-steps { display: flex; gap: 6px; margin: 18px 0 4px; counter-reset: s; overflow-x: auto; padding-bottom: 2px; }
+.zm-ab-step { flex: 1 1 0; min-width: 78px; display: flex; flex-direction: column; align-items: center; gap: 6px; position: relative; font-size: 12px; opacity: .55; }
+.zm-ab-step::before { content: ""; position: absolute; top: 14px; left: calc(-50% + 18px); right: calc(50% + 18px); height: 2px; background: rgba(110,118,129,.3); border-radius: 2px; }
+.zm-ab-step:first-child::before { display: none; }
+.zm-ab-step-dot { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 12.5px; background: rgba(110,118,129,.14); color: inherit; }
+.zm-ab-step-label { white-space: nowrap; font-weight: 600; }
+.zm-ab-step.zm-ab-done { opacity: 1; }
+.zm-ab-step.zm-ab-done .zm-ab-step-dot { background: #1a7f37; color: #fff; }
+.zm-ab-step.zm-ab-done::before, .zm-ab-step.zm-ab-now::before { background: #1a7f37; }
+.zm-ab-step.zm-ab-now { opacity: 1; }
+.zm-ab-step.zm-ab-now .zm-ab-step-dot { background: #1aa3ff; color: #fff; animation: zm-ab-pulse 1.6s ease-in-out infinite; }
+@keyframes zm-ab-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(26,163,255,.55); } 50% { box-shadow: 0 0 0 7px rgba(26,163,255,0); } }
+
+.zm-ab-svc { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 9px; margin-top: 10px; }
+.zm-ab-svc-item .zm-badge { flex-shrink: 0; }
+.zm-ab-svc-item {
+ display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0;
+ border: 1px solid rgba(0,0,0,.08); border-radius: 10px; padding: 10px 12px;
+ background: var(--background-color-low, #fafafa);
+}
+html.zm-theme-dark .zm-ab-svc-item { background: #22272e; border-color: rgba(255,255,255,.1); }
+.zm-ab-svc-item.zm-ab-svc-bad { border-color: rgba(207,34,46,.3); }
+.zm-ab-svc-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* steer */
+.zm-st-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 16px; }
+.zm-st-stat { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 11px 14px; border-radius: 11px; border: 1px solid rgba(0,0,0,.08); background: var(--background-color-low, #fafafa); }
+html.zm-theme-dark .zm-st-stat { background: #22272e; border-color: rgba(255,255,255,.1); }
+.zm-st-stat-label { font-size: 11.5px; opacity: .65; text-transform: uppercase; letter-spacing: .04em; }
+.zm-st-stat-value { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.zm-st-ok { color: #1a7f37; } .zm-st-warn { color: #9a6700; } .zm-st-bad { color: #cf222e; } .zm-st-off { opacity: .6; }
+.zm-st-promo { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 16px; }
+.zm-st-promo > div { display: flex; flex-direction: column; gap: 3px; padding: 12px 14px; border-radius: 11px; background: rgba(26,163,255,.07); border: 1px solid rgba(26,163,255,.2); }
+.zm-st-promo b { font-size: 14px; } .zm-st-promo span { font-size: 12px; opacity: .7; }
+.zm-svc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; margin-top: 12px; }
+.zm-svc { display: flex; align-items: center; gap: 11px; min-width: 0; padding: 10px 12px; border-radius: 12px; cursor: pointer; user-select: none;
+ border: 1px solid rgba(0,0,0,.1); background: var(--background-color-low, #fafafa); transition: border-color .15s, background .15s, transform .1s; }
+html.zm-theme-dark .zm-svc { background: #22272e; border-color: rgba(255,255,255,.1); }
+.zm-svc:hover { transform: translateY(-1px); border-color: #1aa3ff; }
+.zm-svc.zm-svc-on { border-color: rgba(26,163,255,.6); background: rgba(26,163,255,.08); }
+.zm-svc-ico { width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0; display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 12.5px; letter-spacing: -.02em; }
+.zm-svc-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
+.zm-svc-name { font-weight: 700; font-size: 13.5px; }
+.zm-svc-sub { font-size: 11.5px; opacity: .6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.zm-switch { width: 38px; height: 22px; border-radius: 999px; background: rgba(110,118,129,.35); position: relative; flex-shrink: 0; transition: background .15s; }
+.zm-switch > span { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: left .15s; }
+.zm-switch.zm-switch-on { background: #1aa3ff; } .zm-switch.zm-switch-on > span { left: 19px; }
+.zm-st-apply .zm-hint { margin: 0; }
+.zm-st-note-ok { background: rgba(26,127,55,.1); color: #1a7f37; border: 1px solid rgba(26,127,55,.25); }
+.zm-st-note-bad { background: rgba(207,34,46,.08); color: #cf222e; border: 1px solid rgba(207,34,46,.25); }
+.zm-st-checks { display: flex; flex-direction: column; gap: 7px; margin-top: 12px; }
+.zm-st-check { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; }
+.zm-st-check > span:last-child { display: flex; flex-direction: column; min-width: 0; }
+.zm-st-check-dot { width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0; display: grid; place-items: center; font-size: 11px; font-weight: 800; color: #fff; }
+.zm-st-check-ok .zm-st-check-dot { background: #1a7f37; } .zm-st-check-warn .zm-st-check-dot { background: #bf8700; } .zm-st-check-fail .zm-st-check-dot { background: #cf222e; }
+.zm-st-check-why { font-size: 12px; opacity: .65; }
+@media (max-width: 600px) {
+ .zm-st-stats, .zm-st-promo { grid-template-columns: 1fr; }
+ .zm-svc-grid { grid-template-columns: 1fr; }
+}
+.zm-ab-player video { display: block; width: 100%; max-height: 72vh; aspect-ratio: 16 / 9; object-fit: contain; border-radius: 12px; background: #000; }
+.zm-ab-player .zm-actions { margin-bottom: 0; }
+.zm-ab-logcard .zm-log { margin-top: 0; }
+@media (max-width: 600px) {
+ .zm-ab-title { flex-basis: calc(100% - 58px); }
+ .zm-ab-head > .zm-badge { margin-left: 58px; }
+ .zm-ab-step { min-width: 40px; }
+ .zm-ab-step:not(.zm-ab-now) .zm-ab-step-label { visibility: hidden; }
+ .zm-ab-step-label { font-size: 11.5px; }
+ .zm-ab-svc { grid-template-columns: 1fr; }
}
-.zm-redbtn:hover { background: #b71c1c !important; }
-.zm-redbtn-player video { display: block; width: 100%; max-height: 82vh; aspect-ratio: 16 / 9; object-fit: contain; border-radius: 12px; background: #000; }
-.zm-redbtn-player .zm-actions { margin-top: 10px; }
-.zm-redbtn-logcard .zm-log:not(.zm-show) { display: none; }
-.zm-redbtn-logcard:not(:has(.zm-log.zm-show)) { display: none; }
ZM_INSTALLER_EOF
chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css'
@@ -11931,6 +13392,10 @@ chmod 0644 '/www/luci-static/resources/view/zapret-manager/bytetube.js'
rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null || true
/etc/init.d/rpcd reload >/dev/null 2>&1 || /etc/init.d/rpcd restart >/dev/null 2>&1
+# Списки сервисов могли обновиться вместе с панелью — steer перечитывает их без обрыва DNS.
+if grep -qx 'steer-spec' /etc/zm-steer/owned 2>/dev/null && /etc/init.d/steer enabled 2>/dev/null; then
+ /etc/init.d/steer reload >/dev/null 2>&1
+fi
if command -v apk >/dev/null 2>&1; then PM="apk"; INSTALL="apk add"
else PM="opkg"; INSTALL="opkg install"; fi
@@ -12418,7 +13883,9 @@ var ICONS = {
lock: '',
arrow: '',
alert: '',
- rocket: '',
+ route: '',
+ wand: '',
+ rocket: '',
telegram: ''
};
@@ -12501,7 +13968,8 @@ function toggleTheme() {
var ROUTES = [
{ id: 'dashboard', title: 'Дашборд', sub: 'Состояние всех компонентов', icon: 'dashboard', group: 'Обзор' },
- { id: 'redbtn', title: 'Красная кнопка', sub: 'Автоподбор обхода по сервисам', icon: 'rocket', group: 'Обход блокировок', dot: 'redbtn' },
+ { id: 'redbtn', title: 'Автообход', sub: 'Роутер сам подберёт обход для каждого сервиса', icon: 'wand', group: 'Обход блокировок', dot: 'redbtn' },
+ { id: 'steer', title: 'steer', sub: 'Выбранные сервисы через туннель WARP', icon: 'route', group: 'Обход блокировок', dot: 'steer' },
{ id: 'strategy', title: 'Zapret', sub: 'Стратегии, тесты, YouTube, игры, Discord и исключения', icon: 'shield', group: 'Обход блокировок', dot: 'zapret' },
{ id: 'zapret2', title: 'Zapret2', sub: 'Установка и управление Zapret2', icon: 'bolt', group: 'Обход блокировок', dot: 'zapret2' },
{ id: 'bytetube', title: 'ByeTube', sub: 'YouTube через ByeDPI', icon: 'play', group: 'Обход блокировок', dot: 'bytetube' },
@@ -13623,6 +15091,49 @@ html[data-theme="dark"] #zmw-view .zm-tile.zm-active::before { color: #a594ff; }
#zmw-view .zm-tg-link-box { background: var(--console); color: #86efac; border-radius: 12px; font-family: var(--mono); font-size: 13.5px; }
#zmw-view .zm-tg-qr-box img { border-radius: 14px; box-shadow: var(--shadow); }
+/* Автообход */
+#zmw-view .zm-ab-panel { gap: 18px; }
+#zmw-view .zm-ab-panel > .zm-card { margin: 0 !important; }
+#zmw-view .zm-ab-hero { background: var(--surface); position: relative; overflow: hidden; }
+#zmw-view .zm-ab-hero::after {
+ content: ""; position: absolute; right: -80px; top: -80px; width: 240px; height: 240px; border-radius: 50%;
+ background: radial-gradient(circle, rgba(26,163,255,.16), transparent 70%); pointer-events: none;
+}
+#zmw-view .zm-ab-hero h3::before, #zmw-view .zm-ab-title h3::before { display: none; }
+#zmw-view .zm-ab-foot { border-top-color: var(--border); color: var(--muted); opacity: 1; }
+#zmw-view .zm-ab-sum b { color: var(--text); }
+#zmw-view .zm-ab-sum-ok b { color: var(--ok); }
+#zmw-view .zm-ab-note-warn { background: var(--warn-bg); color: var(--warn); border-color: rgba(245,158,11,.35); }
+#zmw-view .zm-ab-step { color: var(--muted); opacity: 1; }
+#zmw-view .zm-ab-step::before { background: var(--border-2); }
+#zmw-view .zm-ab-step-dot { background: var(--surface-2); border: 1px solid var(--border); }
+#zmw-view .zm-ab-step.zm-ab-done { color: var(--text-2); }
+#zmw-view .zm-ab-step.zm-ab-done .zm-ab-step-dot { background: var(--ok-dot); border-color: transparent; color: #fff; }
+#zmw-view .zm-ab-step.zm-ab-done::before, #zmw-view .zm-ab-step.zm-ab-now::before { background: var(--grad); }
+#zmw-view .zm-ab-step.zm-ab-now { color: var(--text); }
+#zmw-view .zm-ab-step.zm-ab-now .zm-ab-step-dot { background: var(--grad); border-color: transparent; color: #fff; }
+#zmw-view .zm-ab-svc-item, html.zm-theme-dark #zmw-view .zm-ab-svc-item { background: var(--surface-2); border-color: var(--border); border-radius: 13px; }
+#zmw-view .zm-ab-svc-item.zm-ab-svc-bad { border-color: rgba(239,68,68,.35); }
+#zmw-view .zm-ab-svc-name { color: var(--text); }
+#zmw-view .zm-ab-player video { border-radius: 14px; box-shadow: var(--shadow); }
+#zmw-view .zm-ab-logcard .zm-log { margin-top: 0; }
+
+#zmw-view .zm-st-stat, html.zm-theme-dark #zmw-view .zm-st-stat { background: var(--surface-2); border-color: var(--border); border-radius: 14px; }
+#zmw-view .zm-st-stat-label { color: var(--muted); opacity: 1; }
+#zmw-view .zm-st-stat-value { color: var(--text); }
+#zmw-view .zm-st-ok { color: var(--ok); } #zmw-view .zm-st-warn { color: var(--warn); } #zmw-view .zm-st-bad { color: var(--bad); } #zmw-view .zm-st-off { color: var(--muted); opacity: 1; }
+#zmw-view .zm-st-promo > div { background: var(--grad-soft); border-color: rgba(124,92,255,.25); border-radius: 14px; }
+#zmw-view .zm-st-promo b { color: var(--text); } #zmw-view .zm-st-promo span { color: var(--muted); opacity: 1; }
+#zmw-view .zm-svc, html.zm-theme-dark #zmw-view .zm-svc { background: var(--surface-2); border-color: var(--border); border-radius: 14px; }
+#zmw-view .zm-svc:hover { border-color: rgba(124,92,255,.55); box-shadow: 0 10px 20px -14px rgba(99,102,241,.7); }
+#zmw-view .zm-svc.zm-svc-on, html.zm-theme-dark #zmw-view .zm-svc.zm-svc-on { background: var(--grad-soft); border-color: rgba(124,92,255,.6); }
+#zmw-view .zm-svc-name { color: var(--text); } #zmw-view .zm-svc-sub { color: var(--muted); opacity: 1; }
+#zmw-view .zm-switch { background: var(--border-2); }
+#zmw-view .zm-switch.zm-switch-on { background: var(--grad); }
+#zmw-view .zm-st-note-ok { background: var(--ok-bg); color: var(--ok); border-color: rgba(16,185,129,.3); }
+#zmw-view .zm-st-note-bad { background: var(--bad-bg); color: var(--bad); border-color: rgba(239,68,68,.3); }
+#zmw-view .zm-st-check { color: var(--text); } #zmw-view .zm-st-check-why { color: var(--muted); opacity: 1; }
+
/* авторы */
#zmw-view .zm-credits-grid { gap: 12px; }
#zmw-view .zm-credit-tile, html.zm-theme-dark #zmw-view .zm-credit-tile {