diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index 857efc4..c81f5ba 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,12 +1,39 @@ #!/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_deep.pid ]; then + # Снимается ВСЁ ДЕРЕВО: замер идёт в дочерних оболочках, и убитый верхний процесс оставлял + # цикл, который продолжал запускать curl. pkill в busybox OpenWrt нет — ищем по ps. + # `|| true` обязательно: установщик идёт под set -e, и kill уже завершившегося процесса + # молча обрывал установку сразу после заголовка. + _zm_kill_tree() { + for _c in $(cat "/proc/$1/task/$1/children" 2>/dev/null); do _zm_kill_tree "$_c"; done + kill -9 "$1" 2>/dev/null || true + } + _zm_kill_tree "$(cat /tmp/zapret-manager-luci/redbtn_deep.pid 2>/dev/null)" + for _p in $(ps w | grep "[q]num=8397" | awk '{print $1}'); do kill "$_p" 2>/dev/null || true; done + nft delete table inet zm_rb_ztest >/dev/null 2>&1 || true +fi +for _zm_job in redbtn steer; do + # Жив процесс и в журнале нет __DONE__: номер завершившейся задачи мог достаться другому. + 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 && + ! grep -q '^__DONE__' /tmp/zapret-manager-luci/$_zm_job.log 2>/dev/null; then + echo -e "${YELLOW}Идёт автообход или операция Steer — дождитесь окончания и запустите установку снова${NC}" + exit 1 + fi +done + rm -rf \ /usr/lib/zapret-manager* \ /etc/zapret_manager_expert_mode* \ @@ -52,8 +79,14 @@ mkdir -p /opt/zapret-manager-luci chmod 0755 /opt/zapret-manager-luci cat > '/opt/zapret-manager-luci/backend.sh' << 'ZM_INSTALLER_EOF' +# rpcd запускает бэкенд с umask 077, и всё, что он пишет — списки zapret, файлы fake при +# установке, — получалось с правами 600. nfqws сбрасывает права до пользователя daemon и такие +# файлы уже не читает: стратегия с --hostlist не запускается, а обход после перезапуска +# молча перестаёт действовать. +umask 022 + 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" @@ -119,11 +152,21 @@ _ensure_deps() { } +# Задача жива, если жив её процесс И в журнале ещё нет __DONE__. Одного kill -0 мало: номер +# процесса завершившейся задачи система отдаёт следующему, и на тестовом роутере его получила +# плановая проверка гео-DNS из cron — подбор «шёл» спустя час после «Готово», а установщик +# отказывался ставиться. +_job_running() { # ИМЯ + local pid="$JOBS_DIR/$1.pid" + [ -f "$pid" ] && kill -0 "$(cat "$pid" 2>/dev/null)" 2>/dev/null || return 1 + ! grep -q '^__DONE__' "$JOBS_DIR/$1.log" 2>/dev/null +} + job_start() { local name="$1"; shift local log="$JOBS_DIR/$name.log" local pid="$JOBS_DIR/$name.pid" - if [ -f "$pid" ] && kill -0 "$(cat "$pid" 2>/dev/null)" 2>/dev/null; then + if _job_running "$name"; then printf '{"started":true,"job":"%s","already_running":true}\n' "$name" return 0 fi @@ -138,7 +181,7 @@ job_status() { local pid="$JOBS_DIR/$name.pid" local log="$JOBS_DIR/$name.log" local running="false" done="false" rc="" - if [ -f "$pid" ] && kill -0 "$(cat "$pid" 2>/dev/null)" 2>/dev/null; then running="true"; fi + _job_running "$name" && running="true" if [ -f "$log" ] && grep -q '^__DONE__' "$log"; then done="true" rc=$(grep '^__DONE__' "$log" | tail -1 | awk '{print $2}') @@ -154,6 +197,8 @@ log_tail() { } zapret_restart() { + # Файлы, записанные прежними версиями с правами 600 (см. umask выше), nfqws не прочтёт. + chmod -R a+rX /opt/zapret/ipset /opt/zapret/files 2>/dev/null [ -x /opt/zapret/sync_config.sh ] && /opt/zapret/sync_config.sh >/dev/null 2>&1 /etc/init.d/zapret restart >/dev/null 2>&1 } @@ -1313,6 +1358,8 @@ system_uninstall_panel() { local script="/tmp/zm_uninstall_panel.sh" cat > "$script" << 'ZM_UNINSTALL_EOF' sleep 1 +# Уборка автообхода — пока backend.sh на месте (см. redbtn_panel_gone). +/opt/zapret-manager-luci/backend.sh redbtn_panel_gone >/dev/null 2>&1 rm -rf /opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager \ /usr/share/luci/menu.d/luci-app-zapret-manager.json \ /usr/share/rpcd/acl.d/luci-app-zapret-manager.json \ @@ -1322,6 +1369,8 @@ rm -rf /opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager \ /www/luci-static/resources/view/bytetube \ /tmp/zapret-manager-luci /tmp/luci-indexcache* /tmp/luci-modulecache/* \ /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 @@ -1556,7 +1605,7 @@ nfqws_opt_set() { TG_MTPROTO_VER="0.10" -TGWS_VERSION="0.2.5" +TGWS_VERSION="0.3.1" TGWS_BASE_URL="https://gitlab.com/xyzmean/brb/-/raw/main/dist" TGWS_VERSION_URL="https://gitlab.com/xyzmean/brb/-/raw/main/VERSION" TG_GO_VER="1.4.1" @@ -2194,6 +2243,10 @@ test_action() { v|flowseal|v_flowseal|youtube|current) ;; *) echo '{"error":"неизвестный режим теста"}'; return 1 ;; esac + if _job_alive redbtn; then + echo '{"error":"идёт автообход — дождитесь его окончания"}' + return 1 + fi job_start strategy_test do_test_run "$mode" ;; stop) @@ -3049,7 +3102,7 @@ _bytetube_fetch() { # URL OUT health() { # Быстрая сводка без сетевых запросов: 0 — не установлено, 1 — работает, 2 — установлено, но не работает - local zr=0 zr2=0 bt=0 tg=0 mx=0 doh=0 hs=0 inst=0 bad=0 out="" + local zr=0 zr2=0 bt=0 tg=0 mx=0 doh=0 hs=0 inst=0 bad=0 out="" rb=0 if [ -f /etc/init.d/zapret ]; then zr=2; pgrep -f "/opt/zapret/" >/dev/null 2>&1 && zr=1; fi if [ -f /etc/init.d/zapret2 ]; then zr2=2; /etc/init.d/zapret2 status >/dev/null 2>&1 && zr2=1; fi if [ -x /usr/bin/bytetube ]; then @@ -3073,8 +3126,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 - printf '{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s}\n' \ - "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" + if [ -s /etc/zm-redbtn/last ]; then + rb=1 + grep -q '|none$' /etc/zm-redbtn/services 2>/dev/null && rb=2 + fi + # 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() { @@ -4702,6 +4765,2702 @@ doh_set() { } +# ───────────────────────── Общее для steer и автообхода ───────────────────────── + +# Каталог сервисов и их списки доменов/адресов (ставится установщиком панели). +RB_SHARE="/usr/share/zm-redbtn" + +_rb_say() { echo "==> $*"; } +_rb_warn() { echo "!! $*"; } +_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() { _job_running "$1"; } + +# pkill в busybox OpenWrt нет: процессы по подстроке командной строки — через ps. +_kill_match() { # ПОДСТРОКА + local p + for p in $(ps w | grep -F -- "$1" | grep -v -e grep -e "_kill_match" | awk '{print $1}'); do + [ "$p" = "$$" ] || kill "$p" 2>/dev/null + done +} + +# Наш объект в 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 + ubus list zapret-manager >/dev/null 2>&1 && return 0 + /etc/init.d/rpcd reload >/dev/null 2>&1 + sleep 3 + i=$((i + 1)) + done + ubus list zapret-manager >/dev/null 2>&1 +} + + + +# ───────────────────────── 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.8" +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 && { for i in zmwarp zmwarp2 zmwarp3; do ifup \$i; done; sleep 15; /etc/init.d/steer restart; }" +# ТРИ ТУННЕЛЯ В РАЗНЫХ КОЛОНИЯХ, как в brb. Один туннель — одна точка отказа: колония уходит +# на обслуживание, провайдер начинает резать адрес — и все сервисы в WARP встают разом. Три +# туннеля отказывают порознь; steer держит их одним выходом и ведёт через самый быстрый живой +# (prefer: latency), сторож движка переключает сам. Первый — прежний zmwarp: его показывает +# страница, его ключи и точка переживают обновление. У каждого своя регистрация: одни ключи +# на трёх устройствах Cloudflare считает одним клиентом. +ST_WARP_N=3 +ST_WARP_UP="$ST_DIR/warp.up" # поднятые туннели «интерфейс колония», лучший первым (_st_warp_order) +# Российские колонии Cloudflare: выход у них внутри страны, за теми же ТСПУ, что и без туннеля, — +# через них не снимается ни геоблок, ни остальные блокировки. Берутся только в запас. +ST_RU_COLOS="DME SVX LED KJA REN OVB KZN AER VVO" +# Что выбрать при первой установке, если ничего не выбрано: ИИ-сервисы закрывает геоблок, +# и кроме туннеля им ничего не поможет. +ST_DEFAULT_SEL="" + +_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 +} +_st_migrate +mkdir -p "$ST_RUN" 2>/dev/null + +# ── что мешает ── +# +# splify2 держит ту же спеку steer и сам ведёт туннели и списки: два хозяина одной спеки +# затирали бы друг друга. +_st_blocker() { + if [ -x /etc/init.d/splify2 ] || ubus list splify2 >/dev/null 2>&1; then echo splify2; return; fi + if _st_spec_foreign; then echo steer; return; fi + echo "" +} + +# Спека steer чужая: она есть, в ней есть каналы, и писали её не мы. +_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}'; } + +# 0, если версия A старше B (числа через точку). В busybox нет sort -V. +_st_ver_lt() { # A B + awk -v a="$1" -v b="$2" 'BEGIN { n = split(a, x, "."); m = split(b, y, "."); k = n > m ? n : m + for (i = 1; i <= k; i++) { if (x[i] + 0 < y[i] + 0) exit 0; if (x[i] + 0 > y[i] + 0) exit 1 } + exit 1 }' +} + +_st_install_steer() { + if command -v steer >/dev/null 2>&1; then + # Движок, который поставили мы, обновляется до нашей версии: иначе у всех, кому Steer + # поставился раньше, новый выпуск не приезжал бы никогда. Чужой (splify2 и т.п.) — не + # трогаем: версией движка управляет тот, кто его поставил. + if _st_owns "pkg steer" && _st_ver_lt "$(_st_steer_ver)" "$ST_STEER_VER"; then + _rb_say "Движок Steer $(_st_steer_ver) — обновляем до $ST_STEER_VER" + else + _rb_say "Движок Steer уже установлен: $(_st_steer_ver)" + return 0 + fi + fi + local arch tmp base url ver was_on="" + # Обновление не должно гасить работающий Steer: после установки пакета движок ниже + # выключается (это нужно новой установке), а при обновлении прежнее состояние возвращается. + command -v steer >/dev/null 2>&1 && /etc/init.d/steer enabled 2>/dev/null && was_on=1 + 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). + if [ -n "$was_on" ]; then + /etc/init.d/steer enable >/dev/null 2>&1 + /etc/init.d/steer restart >/dev/null 2>&1 + else + /etc/init.d/steer stop >/dev/null 2>&1 + /etc/init.d/steer disable >/dev/null 2>&1 + fi + _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)" ] +} + +_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 ${ST_WARP_IF#zmwarp}" + 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() { + local want i changed=0 + # Зона держит все туннели. Прежние версии заводили её на один zmwarp — список пишется + # заново, если отличается. + want="$(_st_wifs_all | tr '\n' ' ' | sed 's/ $//')" + if [ "$(uci -q get "firewall.$ST_WARP_ZONE")" = "zone" ] && [ "$(uci -q get "firewall.$ST_WARP_ZONE.network")" != "$want" ]; then + uci -q delete "firewall.$ST_WARP_ZONE.network" + for i in $want; do uci add_list "firewall.$ST_WARP_ZONE.network=$i"; done + uci commit firewall + /etc/init.d/firewall reload >/dev/null 2>&1 + fi + 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" + for i in $want; do uci add_list "firewall.$ST_WARP_ZONE.network=$i"; done + 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_is_ru() { case " $ST_RU_COLOS " in *" $1 "*) return 0 ;; esac; return 1; } + +# ── несколько туннелей ── +# +# Функции выше написаны на один туннель и читают ST_WARP_IF и ST_WARP_CONF. Туннель N — это +# те же функции с подставленными интерфейсом и файлом ключей: 1 — zmwarp и warp.conf, 2 и 3 — +# zmwarpN и warpN.conf. +_st_wif() { [ "$1" = 1 ] && echo zmwarp || echo "zmwarp$1"; } +_st_wconf() { [ "$1" = 1 ] && echo "$ST_DIR/warp.conf" || echo "$ST_DIR/warp$1.conf"; } +_st_with() { # N КОМАНДА... — выполнить команду в контексте туннеля N + local n="$1" oi="$ST_WARP_IF" oc="$ST_WARP_CONF" rc + shift + ST_WARP_IF="$(_st_wif "$n")"; ST_WARP_CONF="$(_st_wconf "$n")" + "$@"; rc=$? + ST_WARP_IF="$oi"; ST_WARP_CONF="$oc" + return $rc +} +_st_wifs_all() { local n=1; while [ "$n" -le "$ST_WARP_N" ]; do _st_wif "$n"; n=$((n + 1)); done; } +# Первый живой туннель — им пробуются сервисы и качаются списки. +_st_warp_first() { + local i + [ -s "$ST_WARP_UP" ] && while read -r i _; do [ -d "/sys/class/net/$i" ] && { echo "$i"; return 0; }; done < "$ST_WARP_UP" + echo "$ST_WARP_IF" +} + +# ── выбор точки: методом warpscout (как в brb) ── +# +# Годность точки решает ТОЛЬКО поднятый туннель. HTTP к краю Cloudflare меряет не то: он идёт +# по TCP на 80-й, туннель — по UDP на свой порт, провайдер режет их по-разному, а колония в +# ответе края — колония ВХОДА (на стенде кандидаты по HTTP вели в DUS/FRA/CPH, а изнутри +# туннеля все оказались в HEL). Поэтому: точка ставится на живой интерфейс, настоящее +# рукопожатие, потери и круг пингом, колония — изнутри туннеля. +ST_WARP_POOLS="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." +# Порты — только вне игрового фильтра zapret (88, 1024-2407, 2409-4499, 4502-19293, …): иначе +# пакеты туннеля разбирал бы общий обход. +ST_WARP_PORTS="2408 500 4500 987" +ST_WARP_RAND=10 # случайных адресов к якорям — за разными колониями +ST_WARP_HS_WAIT=8 # секунд ждать рукопожатия: на живом роутере оно бывало и 1,25 с + +# Поставить узлу точку на живом интерфейсе и дождаться рукопожатия. Узел сначала удаляется: +# WireGuard не начинает нового рукопожатия, пока жива прежняя сессия. +_st_warp_link() { # ИНТЕРФЕЙС КЛЮЧ_УЗЛА АДРЕС ПОРТ + local dev="$1" peer="$2" push w=0 hs + awg set "$dev" peer "$peer" remove 2>/dev/null + awg set "$dev" peer "$peer" endpoint "$3:$4" allowed-ips 0.0.0.0/0 persistent-keepalive 25 2>/dev/null || return 1 + ping -I "$dev" -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 & + push=$! + while [ "$w" -lt $((ST_WARP_HS_WAIT * 4)) ]; do + hs=$(awg show "$dev" latest-handshakes 2>/dev/null | awk '{print $2; exit}') + if [ "${hs:-0}" -gt 0 ] 2>/dev/null; then wait "$push" 2>/dev/null; return 0; fi + w=$((w + 1)) + sleep 0.25 2>/dev/null || sleep 1 + done + kill "$push" 2>/dev/null; wait "$push" 2>/dev/null + return 1 +} + +_st_warp_probe() { # ИНТЕРФЕЙС -> «потери% круг_мс» + local out loss rtt + out=$(ping -I "$1" -c 3 -W 2 1.1.1.1 2>/dev/null) + loss=$(printf '%s' "$out" | sed -n 's/.*[^0-9]\([0-9]*\)% packet loss.*/\1/p' | head -n1) + rtt=$(printf '%s' "$out" | sed -n 's|.*= [0-9.]*/\([0-9]*\)\.[0-9]*/.*|\1|p' | head -n1) + echo "${loss:-100} ${rtt:-99999}" +} + +# Колония ИЗНУТРИ туннеля — по заголовку CF-RAY (`<ид>-<город>`), который Cloudflare +# дописывает к любому своему ответу: один HTTP-запрос по адресу, без DNS и без TLS (TLS через +# туннель на части роутеров не проходит, см. brb/warp.sh). Запасной ход — trace по https. +_st_colo_of() { # ИНТЕРФЕЙС + local c + c=$(curl -sI --interface "$1" --max-time 8 -H 'Host: www.cloudflare.com' http://104.16.132.229/ 2>/dev/null | + sed -n 's/^[Cc][Ff]-[Rr][Aa][Yy]: *[0-9a-f]*-\([A-Z][A-Z][A-Z]\).*/\1/p' | head -n1) + [ -n "$c" ] || c=$(curl -s --interface "$1" --connect-timeout 5 --max-time 8 https://1.1.1.1/cdn-cgi/trace 2>/dev/null | + sed -n 's/^colo=//p' | head -n1) + [ -n "$c" ] && echo "$c" +} + +# Какие порты провайдер выпускает к WARP — один раз, дальше из файла. Ищутся два: разведка +# ведёт все адреса первым, второй — запасной при окончательной привязке точки (_st_warp_up). +_st_warp_ports() { # ИНТЕРФЕЙС КЛЮЧ_УЗЛА АДРЕС + local f="$ST_DIR/warp.ports" ok="" p + [ -s "$f" ] && { cat "$f"; return 0; } + for p in $ST_WARP_PORTS; do + _st_stopped && return 1 + _st_warp_link "$1" "$2" "$3" "$p" && ok="${ok:+$ok }$p" + [ "$(echo "$ok" | wc -w)" -ge 2 ] && break + done + [ -n "$ok" ] || return 1 + echo "$ok" > "$f" + echo "$ok" +} + +# Разведка для одного туннеля. Печатает «адрес порт колония ключ» лучшей точки. Занятая +# колония — запас (другой адрес в той же колонии лучше никакого), российская — последний запас. +# +# Ключ — качество точки одним числом, меньше — лучше: разряд запаса (0 — наружная колония, +# своя или занятая соседом; 1 — колония не узналась; 2 — российская; 3 — HTTPS не проходит), +# за ним потери и круг. По нему _st_warp_order ставит туннели в warp.up лучшим первым. Занятая +# колония в разряде не отличается от свободной: «занята соседом» важно, когда точку выбирают +# для одного туннеля, а сравнивать туннели между собой — это уже только связь. +_st_warp_scan() { # ИНТЕРФЕЙС КЛЮЧ_УЗЛА ЗАНЯТЫЕ_КОЛОНИИ ЗАНЯТЫЕ_АДРЕСА + local dev="$1" peer="$2" busy=" $3 " busyip=" $4 " r="$ST_RUN/scan.$1" cand ip ports port loss rtt colo pick f cls + mkdir -p "$ST_RUN" + : > "$r"; : > "$r.same"; : > "$r.nc"; : > "$r.ru"; : > "$r.notls" + # Якоря .1 первыми: на них указывает сам engage.cloudflareclient.com; случайные — за + # разными колониями. + cand=$(awk -v p="$ST_WARP_POOLS" -v n="$ST_WARP_RAND" 'BEGIN { srand(); c = split(p, a, " "); + for (i = 1; i <= c; i++) print a[i] "1"; for (i = 0; i < n; i++) print a[int(rand() * c) + 1] int(rand() * 254) + 2 }') + ports="" + # Порты ищутся на первых трёх якорях: если не ответил ни один порт ни у одного из них, дело + # не в адресах — провайдер режет UDP к WARP, и перебор всех 24 адресов стоил бы 13 минут. + local tried=0 + for ip in $cand; do + [ -n "$ports" ] && break + [ "$tried" -ge 3 ] && break + tried=$((tried + 1)) + ports="$(_st_warp_ports "$dev" "$peer" "$ip")" + done + [ -n "$ports" ] || { echo "!! WARP: провайдер не выпускает ни один порт ($ST_WARP_PORTS)" >&2; return 1; } + port="${ports%% *}" + for ip in $cand; do + _st_stopped && return 1 + _rb_stopped 2>/dev/null && return 1 + case "$busyip" in *" $ip "*) continue ;; esac + _st_warp_link "$dev" "$peer" "$ip" "$port" || { echo " $ip:$port — рукопожатия нет" >&2; continue; } + set -- $(_st_warp_probe "$dev"); loss="$1"; rtt="$2" + [ "$loss" -ge 100 ] 2>/dev/null && { echo " $ip:$port — туннель молчит" >&2; continue; } + colo="$(_st_colo_of "$dev")" + if [ -z "$colo" ]; then + echo "$loss $rtt $ip $port ?" >> "$r.nc"; echo " $ip:$port — потери $loss%, $rtt мс, колония не узналась" >&2; continue + fi + # HTTPS через туннель — обязательно. CF-RAY приходит по голому HTTP, а на части сетей + # через туннель проходят только мелкие пакеты и порт 80, TLS молчит (brb снял это на + # живом роутере). Такая точка «работает» по всем замерам, а устройства через неё сайты + # не открывают, — поэтому она уходит в последний запас. + if ! curl -s -o /dev/null --interface "$dev" --connect-timeout 4 --max-time 8 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null; then + echo "$loss $rtt $ip $port $colo" >> "$r.notls"; echo " $ip:$port — колония $colo, но HTTPS через туннель не проходит" >&2; continue + fi + echo " $ip:$port — потери $loss%, $rtt мс, колония $colo" >&2 + if _st_warp_is_ru "$colo"; then echo "$loss $rtt $ip $port $colo" >> "$r.ru"; continue; fi + case "$busy" in *" $colo "*) echo "$loss $rtt $ip $port $colo" >> "$r.same"; continue ;; esac + echo "$loss $rtt $ip $port $colo" >> "$r" + [ "$(grep -c . "$r")" -ge 3 ] && break + done + # Лучшая — одним числовым ключом: потери в старших разрядах, круг в младших (busybox sort + # не понимает модификаторов у ключей, см. brb/warp.sh). + for f in "$r" "$r.same" "$r.nc" "$r.ru" "$r.notls"; do + [ -s "$f" ] || continue + case "$f" in *.nc) cls=1 ;; *.ru) cls=2 ;; *.notls) cls=3 ;; *) cls=0 ;; esac + pick=$(awk -v c="$cls" '{ k = c * 100000000 + $1 * 100000 + $2; printf "%d\t%s %s %s %d\n", k, $3, $4, $5, k }' "$f" | + sort -n | head -n1 | cut -f2) + case "$f" in + *.same) echo " другой колонии нет — та же, но через другой адрес" >&2 ;; + *.ru) echo " наружных колоний нет — беру российскую: блокировки через неё не снимаются" >&2 ;; + *.notls) echo "!! ни через одну точку не проходит HTTPS — беру лучшую из оставшихся" >&2 ;; + esac + echo "$pick" + return 0 + done + return 1 +} + +_st_warp_alive_if() { # ИНТЕРФЕЙС — рукопожатие не старше трёх минут + local hs + hs=$(awg show "$1" latest-handshakes 2>/dev/null | awk '{print $2; exit}') + [ "${hs:-0}" -gt 0 ] 2>/dev/null && [ $(( $(date +%s) - hs )) -lt 180 ] +} +_st_warp_ready_if() { ifstatus "$1" 2>/dev/null | grep -q '"up": true' && awg show "$1" peers 2>/dev/null | grep -q .; } + +# Порядок туннелей в warp.up — лучший первым. Порядок здесь не для красоты: его читают все, +# кому из нескольких туннелей нужен один или кто перебирает их по очереди. +# - _st_warp_first: через первый живой пробуются сервисы, качаются списки, идёт самопроверка. +# - Мост Telegram (warp.lst, см. _st_tgws_warp): берёт первый живой туннель из списка и к +# следующему переходит, только когда тот не дал даже TCP, — порядок для него и есть выбор. +# - Движок (devices в spec, prefer: latency): туннели он меряет сам, но замеры в пределах +# допуска (50 мс) считает равными и из равных берёт стоящий выше, — порядок решает ничьи, а +# когда замерить не вышло ни одного, выбор идёт просто по порядку. +# +# Ключ — от разведки (см. _st_warp_scan). У туннеля, оставленного живым, свежего замера нет, и +# дальше два случая. +# - Разведки не было ни у одного — туннели остаются на прежних местах. Переставлять их по +# трём пингам значило бы на каждом «Применить» и каждом прогоне автообхода тасовать список +# по дрожанию задержки, а новый порядок — это другой spec и перезапуск движка с обрывом +# соединений ради ничего. +# - Хоть один разведан заново — список всё равно меняется, и оставленным меряется то же, что +# мерила разведка (потери и круг тремя пингами изнутри живого туннеля, сессию это не +# трогает), с разрядом запаса 0: колония у них узналась и она не российская, иначе туннель +# ушёл бы в разведку. Без замера новичка не с чем было бы сравнить. +# При равных ключах держится порядок строк, то есть номеров туннелей. +_st_warp_order() { # ФАЙЛ «интерфейс колония ключ|-» -> тот же файл «интерфейс колония» + local f="$1" i c k pos=0 fresh=0 + grep -q ' [0-9][0-9]*$' "$f" && fresh=1 + : > "$f.key" + while read -r i c k; do + [ -n "$i" ] || continue + pos=$((pos + 1)) + if [ "$fresh" = 0 ]; then + # Прежнее место; туннеля, которого в прошлом списке не было, — в хвост по номеру. + k=$(awk -v i="$i" '$1 == i { print NR; exit }' "$ST_WARP_UP" 2>/dev/null) + [ -n "$k" ] || k=$((100 + pos)) + elif [ "$k" = - ]; then + set -- $(_st_warp_probe "$i") + k=$(($1 * 100000 + $2)) + fi + # Ключ и номер строки — нулями до одной ширины, чтобы простой sort сравнил их как числа + # (busybox sort не понимает модификаторов у ключей, см. brb/warp.sh). + printf '%012d %03d %s %s\n' "$k" "$pos" "$i" "$c" >> "$f.key" + done < "$f" + sort "$f.key" | awk '{ print $3, $4 }' > "$f" + rm -f "$f.key" +} + +# Поднять туннели. repick — не держаться прежних точек, подобрать заново. +# +# Сначала ключи и интерфейсы ВСЕХ туннелей одним reload, и только потом разведка на живых +# интерфейсах через `awg set`: запись в uci с ifup посреди разведки делала `network reload`, и +# netifd перезапускал соседние туннели (на стенде так пропадал уже подобранный первый). +_st_warp_up() { # [repick] + local repick="$1" n i peer got busy="" busyip="" ru=0 c ready="" w need=0 colos="" alt p + _st_install_awg || return 1 + # Какие порты провайдер выпускает, меняется: при подборе заново спрашиваем снова. + [ "$repick" = repick ] && rm -f "$ST_DIR/warp.ports" + _rb_say "Поднимаем туннели WARP в разных колониях" + n=1 + while [ "$n" -le "$ST_WARP_N" ]; do + if _st_with "$n" _st_warp_register; then + ready="$ready $n" + i="$(_st_wif "$n")" + if [ "$(uci -q get "network.$i.proto")" != amneziawg ]; then + _st_with "$n" _st_warp_iface_write 162.159.192.1 2408 + need=1 + fi + else + [ "$n" = 1 ] && { echo "ОШИБКА: не удалось получить ключи WARP"; return 1; } + _rb_warn "WARP $n: не удалось получить ключи — туннелей будет меньше" + fi + n=$((n + 1)) + done + _st_warp_zone + for n in $ready; do + i="$(_st_wif "$n")" + [ "$(uci -q get "network.$i.auto")" = 0 ] && { uci -q delete "network.$i.auto"; need=1; } + done + [ "$need" = 1 ] && { uci -q commit network; ubus call network reload >/dev/null 2>&1; } + for n in $ready; do ubus call "network.interface.$(_st_wif "$n")" up >/dev/null 2>&1; done + for n in $ready; do + i="$(_st_wif "$n")"; w=0 + while [ "$w" -lt 20 ] && ! _st_warp_ready_if "$i"; do w=$((w + 1)); sleep 1; done + done + : > "$ST_WARP_UP.tmp" + for n in $ready; do + _st_stopped && break + i="$(_st_wif "$n")" + peer="$(_st_with "$n" _st_warp_field PublicKey)" + _st_warp_ready_if "$i" || { _rb_warn "WARP $n: устройство не поднялось"; continue; } + got="" + # Живой туннель с прошлого раза не трогаем — даже если колония совпала с соседом: её + # выбрала разведка, когда другой не нашлось, и пересканировать его на каждом «Применить» + # значило бы рвать соединения ради того же результата. Развести заново — «Сменить точки входа». + # КРОМЕ РОССИЙСКОЙ: её разведка берёт последним запасом, когда не нашлось ничего, и + # держаться за неё только потому, что туннель жив, — значит оставить геоблок навсегда + # (на тестовом роутере так и вышло: HEL, DME, HEL). Такой туннель разводится заново. + if [ "$repick" != repick ] && _st_warp_alive_if "$i" && c="$(_st_colo_of "$i")" && ! _st_warp_is_ru "$c"; then + # Ключа качества нет («-»): свежего замера у оставленного туннеля нет, его место в + # warp.up решает _st_warp_order. + got="$(uci -q get "network.${i}_peer.endpoint_host") $(uci -q get "network.${i}_peer.endpoint_port") $c -" + _rb_say "WARP $n работает: колония $c" + else + echo " WARP $n: разведка" + if got="$(_st_warp_scan "$i" "$peer" "$busy" "$busyip")"; then + set -- $got + # Живая сессия осталась с ПОСЛЕДНИМ опробованным адресом. Голый `awg set + # endpoint` нового рукопожатия не начинает — нужен тот же сброс узла, что в + # разведке, иначе туннель полминуты молчит и идёт не в ту колонию. + if ! _st_warp_link "$i" "$peer" "$1" "$2"; then + # Запасной порт — второй найденный в warp.ports. Разведка ведёт все адреса + # ОДНИМ портом, первым выпущенным, и точка, ответившая на нём минуту назад, + # может замолчать именно на нём: провайдер режет UDP к WARP по портам, и не + # всегда с первой минуты. Адрес тот же, значит и колония та же — она за + # адресом, не за портом; ключ качества остаётся от разведки. + alt="" + for p in $(cat "$ST_DIR/warp.ports" 2>/dev/null); do + [ "$p" = "$2" ] && continue + _st_stopped && break + _st_warp_link "$i" "$peer" "$1" "$p" && { alt="$p"; break; } + done + if [ -z "$alt" ]; then + _rb_warn "WARP $n: выбранная точка $1:$2 не ответила повторно" + continue + fi + _rb_say "WARP $n: точка $1:$2 не ответила повторно, беру запасной порт $alt" + got="$1 $alt $3 $4" + set -- $got + fi + _rb_say "WARP $n работает: $1:$2, колония $3" + else + _rb_warn "WARP $n не поднялся" + continue + fi + fi + set -- $got + busy="$busy $3"; busyip="$busyip $1" + _st_warp_is_ru "$3" && ru=1 + echo "$i $3 ${4:--}" >> "$ST_WARP_UP.tmp" + # В uci — для следующей загрузки. Без reload: живой интерфейс уже на этой точке. + uci set "network.${i}_peer.endpoint_host=$1" + uci set "network.${i}_peer.endpoint_port=$2" + done + # Остановлено — ничего не записываем и ничего не гасим: живые туннели должны пережить «стоп». + if _st_stopped || { type _rb_stopped >/dev/null 2>&1 && _rb_stopped; }; then + uci revert network >/dev/null 2>&1 + rm -f "$ST_WARP_UP.tmp" + return 1 + fi + uci commit network + _st_warp_order "$ST_WARP_UP.tmp" + mv "$ST_WARP_UP.tmp" "$ST_WARP_UP" + colos="$(awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $2 }' "$ST_WARP_UP")" + # Не поднявшиеся — погасить: висящий без рукопожатия туннель движок принял бы за живой. + for n in $ready; do + i="$(_st_wif "$n")" + grep -q "^$i " "$ST_WARP_UP" || ifdown "$i" >/dev/null 2>&1 + done + if [ ! -s "$ST_WARP_UP" ]; then + rm -f "$ST_DIR/warp.colo" + echo "ОШИБКА: туннель WARP не поднялся ни через одну точку входа" + return 1 + fi + echo "$colos" > "$ST_DIR/warp.colo" + _st_tgws_warp + [ "$ru" = 1 ] && _rb_warn "Часть туннелей WARP идёт через российские колонии: заблокированное через них не откроется" + return 0 +} + +# Туннели для моста Telegram (stgws): он ходит через WARP сам — к настоящим дата-центрам или +# веб-сокетом — и бросает туннель, когда тот ложится (см. «WARP: путь поверх туннеля» в +# tgws.c). Российские колонии в список не идут: выход у них внутри страны. Мост перечитывает +# файл сам, перезапускать его не нужно. +ST_TGWS_WARP="/etc/stgws/warp.lst" +_st_tgws_warp() { + local i c + [ -d /etc/stgws ] || return 0 + : > "$ST_TGWS_WARP.tmp" + while read -r i c; do + [ -n "$i" ] || continue + _st_warp_is_ru "$c" || echo "$i" >> "$ST_TGWS_WARP.tmp" + done < "$ST_WARP_UP" 2>/dev/null + mv "$ST_TGWS_WARP.tmp" "$ST_TGWS_WARP" +} + +# ── списки для туннеля ── +# +# Списки сервисов берутся наборами .srs из каталога splify2-lists (github.com/xyzmean/splify2-lists): +# его lists.json называет для каждого набора ссылку на релиз издателя С ЗАФИКСИРОВАННЫМ тегом, +# так что два роутера с одним каталогом уводят в туннель одно и то же. Набор раскладывает сам +# движок (`steer srs-read`) на домены, подсети и сужение по протоколу и портам. +# +# Качается сначала напрямую, потом ЧЕРЕЗ ТУННЕЛЬ: списки нужны ровно тем сервисам, которые +# уходят в WARP, значит туннель к этому моменту уже поднят, а GitHub у аудитории кнопки часто +# закрыт. Не скачалось вовсе — берётся список из пакета: хуже свежего, но лучше никакого. +ST_LISTS_MANIFEST="https://github.com/xyzmean/splify2-lists/releases/latest/download/lists.json" +ST_SRS_FALLBACK="https://github.com/itdoginfo/allow-domains/releases/latest/download" + +_st_fetch() { # URL ФАЙЛ — напрямую, потом через туннель + curl -fsSL --connect-timeout 6 --max-time 60 -o "$2" "$1" 2>/dev/null && [ -s "$2" ] && return 0 + local i + i="$(_st_warp_first)" + [ -d "/sys/class/net/$i" ] && + curl -fsSL --interface "$i" --connect-timeout 6 --max-time 60 -o "$2" "$1" 2>/dev/null && [ -s "$2" ] +} + +_st_srs_url() { # НАБОР -> ссылка из каталога или «последний релиз» издателя + local m="$ST_RUN/lists.json" u="" + [ -s "$m" ] || _st_fetch "$ST_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:-$ST_SRS_FALLBACK/$1.srs}" +} + +# Набор -> $ST_DIR/lists/НАБОР.dom / .pfx / .meta. Код 0 — разложен. +_st_srs_get() { # НАБОР + local d="$ST_DIR/lists" f + mkdir -p "$d" + [ -f "$ST_RUN/srs.$1.done" ] && return 0 + f="$ST_RUN/$1.srs" + _st_fetch "$(_st_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 "$ST_RUN/srs.$1.done" +} + +# Каналы сервиса в спеке: печатает JSON-объекты через запятую (без внешних скобок). +_st_svc_channels() { # ID + local id="$1" name sets set dom="" pfx="" narrow="" f proto ports ch="" sep="" took="" + name="$(esc "$(_rb_svc_field "$id" 2)")" + sets="$(_rb_svc_field "$id" 8 | tr ',' ' ')" + for set in $sets; do + # Один набор на несколько сервисов (Instagram и WhatsApp — оба meta) — в спеку один раз. + # Пометка файлом: функцию зовут в подоболочке, переменная оттуда не возвращается. + # Ставится только после того, как сервис взял ВСЕ свои наборы: иначе при сбое второго + # набора сервис откатывался на списки пакета, а соседу первый набор уже не доставался. + [ -f "$ST_RUN/used.$set" ] && continue + if _st_srs_get "$set"; then + took="$took $set" + f="$ST_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 + for set in $took; do [ -n "$sets" ] && touch "$ST_RUN/used.$set"; done + # Ни одного набора или набор не скачался — списки из пакета. + if [ -z "$sets" ]; then + dom="$(_st_json_list "$(_rb_svc_field "$id" 3 | tr ',' ' ')")" + pfx="$(_st_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="$ST_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 ── + +_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 c chans="" schema=1 devs + # Каталог и отметки «набор разложен» живут до перезагрузки — на каждую сборку спрашиваем + # свежие, иначе «Применить» неделями ставило бы одни и те же списки. + rm -f "$ST_RUN"/used.* "$ST_RUN"/srs.*.done "$ST_RUN/lists.json" + mkdir -p "$ST_RUN" + for id in "$@"; do + c="$(_st_svc_channels "$id")" + [ -n "$c" ] && chans="$chans${chans:+,}$c" + done + [ -n "$chans" ] || return 1 + case "$chans" in *'"ports"'*|*'"proto"'*) schema=2 ;; esac + devs="$(awk '{printf "%s\"%s\"", (NR > 1 ? "," : ""), $1}' "$ST_WARP_UP" 2>/dev/null)" + [ -n "$devs" ] || devs="\"$ST_WARP_IF\"" + printf '{"schema":%s,"lan_devices":["br-lan"],"outputs":{"zm_warp":{"kind":"interface","devices":[%s],"prefer":"latency","on_fail":"direct"}},"channels":[%s]}\n' \ + "$schema" "$devs" "$chans" +} + +_st_spec_apply() { # ID... + local tmp="$ST_RUN/spec.json" out + _st_spec_build "$@" > "$tmp" || { echo "ОШИБКА: для выбранных сервисов нет ни одного списка"; 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 + [ -f "$ST_OFF" ] && return 0 + /etc/init.d/steer enabled 2>/dev/null || return 0 + # Набор живых туннелей мог смениться — спека пересобирается (она же сама перезапускает + # движок, если что-то изменилось). Сборка не вышла — хотя бы перезапуск с прежней. + local sel + sel="$(_st_sel | tr '\n' ' ')" + if [ -n "$(echo $sel)" ] && _st_spec_apply $sel; then return 0; fi + /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 + [ -f "$ST_OFF" ] && return 1 + # Служба самого пакета, а не любой процесс https-dns-proxy: свой экземпляр для ИИ-сервисов + # (zm-geodns) порт 53 не трогает, и принимать его за перехват было бы ложной тревогой. + /etc/init.d/https-dns-proxy running >/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 + # Служба самого пакета, а не любой процесс https-dns-proxy: свой экземпляр для ИИ-сервисов + # (zm-geodns) порт 53 не трогает, и принимать его за перехват было бы ложной тревогой. + /etc/init.d/https-dns-proxy running >/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 + # Перезапуск — только работающей службы: остановленную (так её оставляет автообход, если + # поставил пакет сам) restart поднял бы и отдал бы ей dnsmasq. + /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 +} + +do_steer_dns_fix() { + _st_doh_unforce + # Выключенный steer не поднимаем: restart у procd запускает и выключенную службу. + [ -f "$ST_OFF" ] && return 0 + /etc/init.d/steer enabled 2>/dev/null && /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 + local i + # auto=0 — чтобы выключенные туннели не поднимались снова после перезагрузки роутера. + for i in $(_st_wifs_all); do + _st_owns "net $i" || continue + ifdown "$i" >/dev/null 2>&1 + uci -q set "network.$i.auto=0" + done + uci -q commit network +} + +# Применить выбор: туннель и правила, или, если ничего не выбрано, всё выключить. +_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_first)" --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 + rm -f "$ST_TGWS_WARP" + _rb_say "Готово, Steer и туннель выключены — всё идёт напрямую" +} + +_st_need_warp() { + [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: движок Steer сейчас настраивает не Zapret Manager"; return 1; } + _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_warp_up). + _st_warp_up || return 1 + _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 n c host port + _rb_say "Пересоздаём WARP с новыми ключами" + n=1 + while [ "$n" -le "$ST_WARP_N" ]; do + c="$(_st_wconf "$n")" + [ -f "$c" ] && mv "$c" "$c.old" + if ! _st_with "$n" _st_warp_register; then + [ -f "$c.old" ] && mv "$c.old" "$c" + [ "$n" = 1 ] && { echo "ОШИБКА: не удалось получить новые ключи — оставляем прежние"; return 1; } + _rb_warn "WARP $n: новых ключей нет — оставляем прежние" + else + rm -f "$c.old" + host="$(uci -q get "network.$(_st_wif "$n")_peer.endpoint_host")" + port="$(uci -q get "network.$(_st_wif "$n")_peer.endpoint_port")" + _st_with "$n" _st_warp_iface_write "${host:-162.159.192.1}" "${port:-2408}" + fi + n=$((n + 1)) + done + ubus call network reload >/dev/null 2>&1 + _st_warp_up repick || return 1 + _st_kick + _rb_say "Готово, WARP пересоздан" +} + +do_steer_remove() { + _st_phase remove + _rb_say "Удаляем Steer и туннель WARP" + # Встал splify2 — движок и спека теперь его: их не трогаем, снимаем только своё (туннели, + # зону, cron). + local foreign=0 + [ "$(_st_blocker)" = splify2 ] && foreign=1 + if [ "$foreign" = 1 ]; then + sed -i '/^steer-spec$/d; /^pkg steer$/d' "$ST_OWNED" 2>/dev/null + _rb_warn "Установлен splify2 — движок Steer и его правила оставляем ему" + else + _st_spec_clear + /etc/init.d/steer stop >/dev/null 2>&1 + fi + local wi netrl=0 + for wi in $(_st_wifs_all); do + _st_owns "net $wi" || continue + ifdown "$wi" >/dev/null 2>&1 + uci -q delete "network.$wi" + uci -q delete "network.${wi}_peer" + netrl=1 + done + [ "$netrl" = 1 ] && { uci commit network; /etc/init.d/network reload >/dev/null 2>&1; } + 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" + rm -f "$ST_TGWS_WARP" + _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 + # Колонии живых туннелей через запятую, лучший первым; «работает» — если жив хоть один. + if [ -s "$ST_WARP_UP" ]; then + colo=$(while read -r w k; do [ -d "/sys/class/net/$w" ] && printf '%s\n' "$k"; done < "$ST_WARP_UP" | tr '\n' ',' | sed 's/,$//; s/,/, /g') + [ -n "$colo" ] && warp_up=true + elif [ -f "$ST_DIR/warp.colo" ]; then + colo=$(cat "$ST_DIR/warp.colo") + [ -d "/sys/class/net/$ST_WARP_IF" ] && warp_up=true + fi + 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 + # Свежайшее рукопожатие и суммарный трафик по всем живым туннелям. + for w in $(_st_wifs_all); do + [ -d "/sys/class/net/$w" ] || continue + k=$(awg show "$w" latest-handshakes 2>/dev/null | awk '{print $2; exit}') + [ "${k:-0}" -gt "${hs:-0}" ] 2>/dev/null && hs="$k" + set -- $(awg show "$w" transfer 2>/dev/null | head -n1) + rx=$((rx + ${2:-0})); tx=$((tx + ${3:-0})) + done + [ -n "$hs" ] && [ "$hs" != 0 ] && age=$(( $(date +%s) - hs )) + 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,"tunnels":%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)" "$(_st_tunnels_json)" "$svc" +} + +# Туннели по одному — JSON-массив для страницы steer: у каждого своя точка, колония (из +# последней разведки), связь и трафик. Только те, что заведены. +_st_tunnels_json() { + local n=1 i up hs age rx tx host port colo out="" sep="" + while [ "$n" -le "$ST_WARP_N" ]; do + i="$(_st_wif "$n")" + if _st_owns "net $i"; then + up=false; age=""; rx=0; tx=0 + [ -d "/sys/class/net/$i" ] && up=true + host="$(uci -q get "network.${i}_peer.endpoint_host")" + port="$(uci -q get "network.${i}_peer.endpoint_port")" + colo="$(awk -v i="$i" '$1 == i {print $2; exit}' "$ST_WARP_UP" 2>/dev/null)" + if [ "$up" = true ] && command -v awg >/dev/null 2>&1; then + hs=$(awg show "$i" latest-handshakes 2>/dev/null | awk '{print $2; exit}') + [ "${hs:-0}" -gt 0 ] 2>/dev/null && age=$(( $(date +%s) - hs )) + set -- $(awg show "$i" transfer 2>/dev/null | head -n1) + rx="${2:-0}"; tx="${3:-0}" + fi + out="$out$sep{\"n\":$n,\"if\":\"$i\",\"up\":$up,\"colo\":\"$(esc "$colo")\",\"host\":\"$(esc "$host")\",\"port\":\"$(esc "$port")\",\"hs_age\":\"$age\",\"rx\":$rx,\"tx\":$tx}" + sep="," + fi + n=$((n + 1)) + done + printf '[%s]' "$out" +} + +# Выбор сервисов с страницы: итоговый набор через запятую. +_st_sel_set() { + local want id + want=" $(echo "$1" | tr ',' ' ') " + for id in $want; do + case "$id" in *[!a-z0-9_-]*) echo '{"error":"неизвестный сервис"}'; return 1 ;; esac + [ -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) + # Каждый туннель — отдельно: «идёт ли трафик» у одного ничего не говорит о других. + # Сначала HTTPS-трассировка (она же подтверждает warp=on); не прошла — голый HTTP с + # CF-RAY: если он отвечает, данные идут, но TLS через туннель режется — это другая + # поломка и другое действие. + local trace warp="none" colo="" tun="" tsep="" wi wn wv wc + if _st_installed && [ -n "$(_st_sel)" ] && [ ! -f "$ST_OFF" ]; then + warp=off + wn=1 + while [ "$wn" -le "$ST_WARP_N" ]; do + wi="$(_st_wif "$wn")" + if _st_owns "net $wi" && [ -d "/sys/class/net/$wi" ]; then + trace="$(curl -s --interface "$wi" --connect-timeout 4 --max-time 8 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" + wc="$(echo "$trace" | sed -n 's/^colo=//p')" + case "$trace" in + *warp=on*|*warp=plus*) wv=on; warp=on; [ -n "$colo" ] || colo="$wc" ;; + *) wc="$(_st_colo_of "$wi")"; [ -n "$wc" ] && wv=notls || wv=off ;; + esac + tun="$tun$tsep{\"n\":$wn,\"warp\":\"$wv\",\"colo\":\"$(esc "$wc")\"}" + tsep="," + fi + wn=$((wn + 1)) + done + 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","tunnels":[%s],"diag":%s}\n' "$warp" "$(esc "$colo")" "$tun" "${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_SYS_DNS="" +# Проб разом: каждый curl — три-четыре мегабайта памяти, и на роутере со 128 МБ, где рядом +# живут Steer, мост Telegram и dnsmasq со списком ИИ-доменов, восемь разом доводили до OOM. +RB_PARALLEL=8 +[ "$(awk '/^MemTotal/ {print $2}' /proc/meminfo 2>/dev/null)" -lt 262144 ] 2>/dev/null && RB_PARALLEL=4 +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) и в теле нет признака отказа. +_rb_probe_nft_up() { + nft delete table inet zm_rb_probe >/dev/null 2>&1 + nft -f - >/dev/null 2>&1 <<-RBNFT + table inet zm_rb_probe { + chain out { + type route hook output priority mangle - 5; policy accept; + tcp sport $RB_LANE_DIRECT meta mark set meta mark | 0x40000000 + udp sport $RB_LANE_DIRECT meta mark set meta mark | 0x40000000 + } + } + RBNFT +} +_rb_probe_nft_down() { + nft delete table inet zm_rb_probe >/dev/null 2>&1 + # Изоляция подбора zapret — тоже здесь: снимается тем же trap при любом выходе. + [ -s "$RB_RUN/zt.nfqws.pid" ] && kill "$(cat "$RB_RUN/zt.nfqws.pid")" 2>/dev/null + rm -f "$RB_RUN/zt.nfqws.pid" + nft delete table inet zm_rb_ztest >/dev/null 2>&1 +} + +_rb_doh_pick() { + local u + for u in $RB_PROBE_DOHS; do + 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 +} + +_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" + # Свой DNS — для всех, кроме ИИ-сервисов: их открывает именно системный (geo-резолвер). + [ -n "$RB_PROBE_DOH" ] && [ -z "$RB_SYS_DNS" ] && extra="--doh-url $RB_PROBE_DOH" + case "$mode" in + direct) extra="$extra --local-port $RB_LANE_DIRECT" ;; + warp) extra="$extra --interface $(_st_warp_first)" ;; + 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) + rc=$? + code="${res%% *}"; size="${res##* }" + # Тайм-аут посреди тела (28) считается ответом, только если пришло больше 32 КБ: сервер без + # поддержки --range отдаёт страницу целиком, и на медленном канале она не успевает. Меньшее — + # отказ: блокировка «16-20 КБ» выглядит именно так — код 200, пара десятков килобайт и тишина. + [ "$rc" = 28 ] && [ "${size:-0}" -gt 32768 ] 2>/dev/null && rc=0 + if [ "$rc" = 0 ] && [ -n "$code" ] && [ "$code" != "000" ] && [ "$code" != "451" ]; then + if [ -n "$deny" ] && [ -s "$body" ] && printf '%s\n' "$deny" | tr ',' '\n' | grep -v '^$' | grep -qiFf - "$body"; then + : + else + echo "$host" >> "$okf" + fi + fi + rm -f "$body" +} + +# Проверить набор хостов параллельно, печатает хосты, которые открылись. +_rb_check_hosts() { # "хост хост..." РЕЖИМ ОТКАЗ + local hosts="$1" mode="$2" deny="$3" okf run=0 h + okf="$RB_RUN/ok.$$.$(date +%s)$RANDOM" + : > "$okf" + for h in $hosts; do + _rb_check_one "$h" "$mode" "$deny" "$okf" & + run=$((run + 1)) + if [ "$run" -ge "$RB_PARALLEL" ]; then wait; run=0; fi + done + wait + cat "$okf" + rm -f "$okf" +} + +# Вердикт по сервису: открылись ВСЕ обязательные цели и не меньше RB_BAR % дополнительных. +# Одного youtube.com мало — страница открывается, а видео с googlevideo нет. +_rb_check_service() { # ID РЕЖИМ -> код 0, если сервис работает + local id="$1" mode="$2" must extra deny ok h n_extra=0 n_ok=0 + must=$(_rb_svc_field "$id" 5 | tr ',' ' ') + extra=$(_rb_svc_field "$id" 6 | tr ',' ' ') + deny=$(_rb_svc_field "$id" 7) + ok=$(_rb_check_hosts "$must $extra" "$mode" "$deny") + for h in $must; do printf '%s\n' "$ok" | grep -qxF "$h" || return 1; done + for h in $extra; do + n_extra=$((n_extra + 1)) + printf '%s\n' "$ok" | grep -qxF "$h" && n_ok=$((n_ok + 1)) + done + [ "$n_extra" -eq 0 ] && return 0 + [ $((n_ok * 100)) -ge $((n_extra * RB_BAR)) ] +} + +# Проверить все сервисы (кроме Telegram), печатает «id ok|fail» по строке. +# С ПОДПИСЬЮ — ещё и печатает каждый сервис в журнал сразу, как он проверен: проверка всех идёт +# около минуты, и журнал, молчащий минуту, выглядит зависшим. +_rb_check_all() { # РЕЖИМ [ПОДПИСЬ] + local id + for id in $(_rb_svc_ids); do + # Telegram и ИИ-сервисы проверяются своими шагами. + case "$id" in telegram|geoblock) continue ;; esac + _rb_stopped && return 0 + if _rb_check_service "$id" "$1"; then + echo "$id ok" + [ -n "${2:-}" ] && echo "[ OK ] $(_rb_svc_field "$id" 2) — $2" >&2 + else + echo "$id fail" + [ -n "${2:-}" ] && echo "[FAIL] $(_rb_svc_field "$id" 2)" >&2 + fi + done +} + +# Ход тестера менеджера — в журнал по строке: какую стратегию меряем и сколько она открыла. +_rb_test_progress() { + local line + while IFS= read -r line; do + case "$line" in + '==> ['*) echo " ${line#==> }" ;; + '==> Результат: '*) echo " открылось ${line#==> Результат: }" | sed 's#/# из #' ;; + '==> Контрольный тест'*) echo " Проверяем, что открывается без Zapret" ;; + '==> Собираем стратегии'*) echo " Собираем стратегии" ;; + '==> Собираем список доменов'*) echo " Собираем адреса для проверки" ;; + '==> Найдено стратегий: '*) echo " Стратегий в подборе: ${line#==> Найдено стратегий: }" ;; + esac + done +} + +_rb_count_ok() { printf '%s\n' "$1" | grep -c ' ok$'; } +_rb_failed() { printf '%s\n' "$1" | awk '$2=="fail"{print $1}'; } + +# ── Telegram ── +# +# Сайт открывается ещё не значит, что работает приложение: оно ходит к дата-центрам по MTProto, +# и режут именно его. Поэтому каждому дата-центру шлётся настоящий первый запрос протокола — +# req_pq_multi в транспорте abridged, — и ждётся ответ. Молчание хотя бы одного — ставим tgws: +# он сам решает по каждому дата-центру, вести его напрямую или через веб-сокет. +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 + local le hex + le=$(printf '%08x' "$(date +%s)" | sed 's/\(..\)\(..\)\(..\)\(..\)/\4\3\2\1/') + # 0xef — abridged; 0x0a — длина/4; auth_key_id=0; msg_id=время<<32; длина 20; конструктор; nonce. + hex="ef0a0000000000000000""00000000${le}14000000f18e7ebe$(head -c16 /dev/urandom | hexdump -ve '1/1 "%02x"')" + printf '%s' "$hex" | sed 's/../&\n/g' | while read -r b; do [ -n "$b" ] && printf '\\%03o' "0x$b"; done +} + +_rb_tg_dc_ok() { + local req dc pids="" n=0 ok=0 + req="$(_rb_mtproto_req)" + for dc in $RB_TG_DCS; do + n=$((n + 1)) + # nc в busybox OpenWrt без ключей тайм-аута: ограничиваем снаружи. + ( { printf "$req"; sleep 3; } | nc "$dc" 443 2>/dev/null | head -c 64 > "$RB_RUN/tg.$n" ) & + pids="$pids $!" + done + sleep 5 + for dc in $pids; do _test_kill_pid_tree "$dc"; done + wait $pids 2>/dev/null + for dc in $(seq 1 "$n"); do + [ -s "$RB_RUN/tg.$dc" ] && ok=$((ok + 1)) + rm -f "$RB_RUN/tg.$dc" + done + [ "$ok" -eq "$n" ] +} + +_rb_tg_ok() { + _rb_check_service telegram as_is || return 1 + _rb_tg_dc_ok +} + + +# ── zapret: подбор ── + +# Применить победителя теста функциями менеджера. +_rb_zapret_apply() { # ИМЯ + local res + case "$1" in + v[0-9]*) res=$(strategy_set_v "$1") ;; + *) res=$(strategy_set_flowseal "$1") ;; + esac + case "$res" in *'"ok":true'*) return 0 ;; esac + return 1 +} + +# ── подбор zapret В ИЗОЛЯЦИИ ── +# +# Кандидаты и цели — менеджерские (_test_build_candidates, _test_prepare_urls, _test_yt_urls), +# способ проверки — нет. Тестер ZM переписывает /etc/config/zapret каждой стратегией и +# перезапускает обход: все минуты подбора клиентам сети достаётся то одна случайная +# стратегия, то другая. Здесь /etc/config/zapret и общий обход не трогаются вовсе: клиенты +# живут на прежней стратегии (или без обхода, если его не было) до конца подбора, а каждая +# стратегия мерится СВОИМ экземпляром nfqws на СВОЕЙ очереди, куда попадает только +# собственный трафик проверки. Устройство — то же, что у тестера splify2 +# (splify2-zapret-test), он объясняет каждое правило; здесь коротко: +# - наш трафик — по диапазону исходящих портов (curl --local-port), ниже эфемерных; +# - метка 0x40000000 после очереди — чтобы общий zapret его пропустил; +# - свой обработчик с --dpi-desync-fwmark=0x60000000, своя predefrag; +# - контроль «без Zapret» — тот же путь без обработчика (у очереди bypass). +# Победитель уходит клиентам один раз, в конце, функциями менеджера (strategy_set_*). +RB_ZT_QUEUE=8397 +RB_ZT_TABLE=zm_rb_ztest +RB_ZT_LO=20000 +RB_ZT_HI=20999 +RB_ZT_PID="$RB_RUN/zt.nfqws.pid" +RB_ZT_NFQWS=/opt/zapret/nfq/nfqws + +_rb_zt_rules_up() { + nft delete table inet "$RB_ZT_TABLE" >/dev/null 2>&1 + nft -f - <<-RBZT + table inet $RB_ZT_TABLE { + chain out { + type filter hook output priority mangle + 5; policy accept; + meta mark and 0x20000000 == 0x20000000 counter return + tcp sport $RB_ZT_LO-$RB_ZT_HI tcp dport { 80, 443, 2053, 2083, 2087, 2096, 8443 } ct original packets 1-9 counter queue num $RB_ZT_QUEUE bypass + } + chain skip { + type filter hook output priority mangle + 6; policy accept; + tcp sport $RB_ZT_LO-$RB_ZT_HI meta mark set mark or 0x40000000 + } + chain in { + type filter hook prerouting priority mangle; policy accept; + tcp dport $RB_ZT_LO-$RB_ZT_HI tcp sport { 80, 443, 2053, 2083, 2087, 2096, 8443 } ct reply packets 1-3 counter queue num $RB_ZT_QUEUE bypass + } + chain predefrag { + type filter hook output priority -401; policy accept; + meta mark and 0x40000000 != 0x00000000 jump predefrag_nfqws + } + chain predefrag_nfqws { + meta mark and 0x20000000 != 0x00000000 notrack + ip frag-off and 0x1fff != 0x0 notrack + exthdr frag exists notrack + tcp flags ! syn,rst,ack notrack + } + } + RBZT +} + +_rb_zt_stop_nfqws() { + [ -s "$RB_ZT_PID" ] && kill "$(cat "$RB_ZT_PID")" 2>/dev/null + rm -f "$RB_ZT_PID" +} + +_rb_zt_down() { _rb_zt_stop_nfqws; nft delete table inet "$RB_ZT_TABLE" >/dev/null 2>&1; } + +# Проверить набор «имя|ссылка» с портов изоляции. Печатает «удачи всего удачи_сервисов»: цели с +# именем «svc:…» — адреса сервисов, которые сейчас не открываются. В общий счёт они не входят и +# считаются отдельно: из стратегий с равным общим счётом лучше та, что открыла их больше. +_rb_zt_check() { # ФАЙЛ_ЦЕЛЕЙ + # Ждём ТОЛЬКО свои пробы: в этой же оболочке фоном живёт nfqws проверяемой стратегии, и + # голый `wait` ждал бы и его. + local okf="$RB_RUN/zt.ok" run=0 e total svc pids="" + : > "$okf" + total=$(grep -v '^svc:' "$1" | grep -c '|') + while IFS= read -r e; do + [ -n "$e" ] || continue + _rb_stopped && break + # Открытой цель считается так же, как в _rb_check_one: ответ с кодом, кроме 451 и 000. + # Без -f curl завершается успехом на любом коде, и заглушка провайдера «недоступно по + # закону» засчитывалась стратегии как открытый сайт — у контроля без zapret тоже. + ( + c=$(curl -sL --local-port "$RB_ZT_LO-$RB_ZT_HI" ${RB_PROBE_DOH:+--doh-url "$RB_PROBE_DOH"} \ + --connect-timeout 4 --max-time 6 --speed-time 3 --speed-limit 1 --range 0-65535 \ + -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) curl/8.0" -o /dev/null -w '%{http_code}' "${e#*|}" 2>/dev/null) && + [ -n "$c" ] && [ "$c" != 000 ] && [ "$c" != 451 ] && echo "${e%%|*}" >> "$okf" + ) & + pids="$pids $!" + run=$((run + 1)) + if [ "$run" -ge "$RB_PARALLEL" ]; then wait $pids 2>/dev/null; pids=""; run=0; fi + done < "$1" + [ -n "$pids" ] && wait $pids 2>/dev/null + svc=$(grep -c '^svc:' "$okf") + echo "$(grep -vc '^svc:' "$okf") $total $svc" + rm -f "$okf" +} + +# Дополнительный замер близких к лучшей — по узлам CDN из списка dpi-detector (Runnin4ik) и +# ТЕМ ЖЕ СПОСОБОМ, что у него (core/tcp16_scanner.py). Российский DPI рвёт соединение к CDN и +# хостингам, когда через него ушло 16-20 КБ, и страница, открывшаяся по короткой проверке, на +# этом месте замирает. Поэтому по ОДНОМУ соединению идут десять запросов HEAD с заголовком +# X-Pad по 4 КБ — около 40 КБ от нас, — и узел засчитывается, только если соединение пережило +# все десять. Качать к себе, как делал brb-deep, бесполезно: по голому адресу такие узлы +# столько не отдают, и у всех стратегий выходило одинаковое «3 из 37». Имя для SNI и Host — из +# списка, у кого оно есть, у остальных example.com, как у dpi-detector; сертификат не +# проверяется — важно, дожило ли соединение, а не чей узел. +RB_DEEP_URLS="https://raw.githubusercontent.com/Runnin4ik/dpi-detector/main/tcp16.json https://cdn.jsdelivr.net/gh/Runnin4ik/dpi-detector@main/tcp16.json" +RB_DEEP_REQS=10 +# Больше близких не проверяем: на роутере со 128 МБ это пять-шесть минут на стратегию. +RB_DEEP_MAX=6 + +# Цели: «id|адрес|порт|имя», весь список — все сто с лишним узлов, как у dpi-detector. Замер +# идёт в фоне, когда обход уже работает, и лишние минуты тут дешевле выборки, в которую какой-то +# провайдер попал одним узлом. +_rb_zt_deep_targets() { # ФАЙЛ + local u src="$RB_RUN/zt.tcp16.json" + for u in $RB_DEEP_URLS; do + curl -fsSL ${RB_PROBE_DOH:+--doh-url "$RB_PROBE_DOH"} --connect-timeout 6 --max-time 20 -o "$src" "$u" 2>/dev/null && [ -s "$src" ] && break + rm -f "$src" + done + [ -s "$src" ] || return 1 + awk 'function f(k, m) { if (match($0, "\"" k "\":[[:space:]]*\"?[^\",}]*")) { m = substr($0, RSTART, RLENGTH); sub(/^"[^"]*":[[:space:]]*"?/, "", m); return m } return "" } + /"ip"/ { print f("id") "|" f("ip") "|" f("port") "|" f("sni") }' "$src" > "$1" + rm -f "$src" + [ -s "$1" ] +} + +_rb_zt_deep() { # ФАЙЛ_УЗЛОВ -> «выдержавших всего» + local okf="$RB_RUN/zt.dok" run=0 id ip port sni total pids="" pad k urls + : > "$okf" + total=$(grep -c '|' "$1") + pad=$(head -c 4000 /dev/urandom | base64 2>/dev/null | tr -dc 'A-Za-z0-9' | head -c 4000) + [ -n "$pad" ] || pad=$(awk 'BEGIN { srand(); for (i = 0; i < 4000; i++) printf "%c", 97 + int(rand() * 26) }') + while IFS='|' read -r id ip port sni; do + [ -n "$ip" ] || continue + _rb_stopped && break + ( + if [ "$port" = 80 ]; then u="http://$ip/"; res="" + else sni="${sni:-example.com}"; u="https://$sni:$port/"; res="--resolve $sni:$port:$ip"; fi + urls=""; k=0 + # -o у каждого адреса свой: без него заголовки второго и дальше шли в вывод. + while [ "$k" -lt "$RB_DEEP_REQS" ]; do urls="$urls -o /dev/null $u"; k=$((k + 1)); done + # Срок у curl — на КАЖДЫЙ из десяти запросов, поэтому: --fail-early — первый обрыв + # кончает замер узла (дальше всё равно не засчитать), пять секунд на запрос (ответ на + # HEAD — сотня байт). С прежними двенадцатью секундами на + # запрос стратегия, рвущая соединения, мерилась по часу. + n=$(curl -sk -I --fail-early $res --local-port "$RB_ZT_LO-$RB_ZT_HI" --connect-timeout 5 --max-time 5 \ + -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" \ + -H "X-Pad: $pad" -w '%{http_code}\n' $urls 2>/dev/null | grep -c '^[1-5][0-9][0-9]$') + [ "${n:-0}" -ge "$RB_DEEP_REQS" ] 2>/dev/null && echo "$id" >> "$okf" + ) & + pids="$pids $!" + run=$((run + 1)) + if [ "$run" -ge "$RB_PARALLEL" ]; then wait $pids 2>/dev/null; pids=""; run=0; fi + done < "$1" + [ -n "$pids" ] && wait $pids 2>/dev/null + echo "$(wc -l < "$okf" | tr -d ' ') $total" + rm -f "$okf" +} + +# Блок стратегии по имени из кандидатов последнего прогона. +_rb_zt_block() { # ИМЯ ФАЙЛ [КАНДИДАТЫ] + awk -v n="#$1" '/^#/ { p = ($0 == n) } p' "${3:-$RB_RUN/zt.cand}" > "$2" + [ -s "$2" ] +} + +# Одна стратегия: поднять обработчик с её ключами, промерить, снять. -1 — не поднялась. +_rb_zt_one() { # ФАЙЛ_БЛОКА ФАЙЛ_ЦЕЛЕЙ [ПРОВЕРКА] + # Аргументы — сразу в переменные: ниже `set --` собирает ключи стратегии в позиционные + # параметры и затирает $1 и $2. + local blk="$1" tgt="$2" chk="${3:-_rb_zt_check}" l opts="$RB_RUN/zt.opts" pid + grep -v '^[[:space:]]*#' "$blk" | grep -v '^[[:space:]]*$' > "$opts" + [ -s "$opts" ] || { echo "-1 0"; return; } + set -- + while IFS= read -r l || [ -n "$l" ]; do set -- "$@" "$l"; done < "$opts" + # Ключи — до запуска: негодная стратегия иначе даёт мгновенно умерший процесс и «ноль из + # полусотни», неотличимый от «не работает». + "$RB_ZT_NFQWS" --dry-run --qnum="$RB_ZT_QUEUE" "$@" >/dev/null 2>&1 || { echo "-1 0"; return; } + "$RB_ZT_NFQWS" --qnum="$RB_ZT_QUEUE" --dpi-desync-fwmark=0x60000000 "$@" >/dev/null 2>&1 & + pid=$! + echo "$pid" > "$RB_ZT_PID" + sleep 1 + kill -0 "$pid" 2>/dev/null || { rm -f "$RB_ZT_PID"; echo "-1 0"; return; } + "$chk" "$tgt" + kill "$pid" 2>/dev/null + wait "$pid" 2>/dev/null + rm -f "$RB_ZT_PID" +} + +# Цели «имя|адрес» без схемы — к https://. Внешний набор (TEST_DOMAINS_JSON, suite.v2.json +# hyperion-cs) даёт голые имена хостов, и curl ходил к ним по HTTP на 80-й: мерился не TLS, на +# котором и стоит блокировка по SNI, а открытый HTTP, который провайдер режет иначе или не режет +# вовсе. Чинится здесь, у нас: _test_prepare_urls общая с тестером менеджера. +_rb_urls_https() { # ФАЙЛ + awk 'BEGIN { FS = OFS = "|" } NF >= 2 && $2 !~ /^[A-Za-z][A-Za-z0-9+.-]*:\/\// { $2 = "https://" $2 "/" } { print }' "$1" > "$1.tmp" && + mv "$1.tmp" "$1" +} + +# Файл исключений zapret — общий с работающим zapret всей сети, и подбор только читает его: +# стратегии кандидатов ссылаются на него через --hostlist-exclude. _refresh_exclude_file +# (менеджера) качает версию из репозитория поверх, и при недоступном GitHub файл оставался +# пустым — вместе с исключениями, которые человек дописал сам. Поэтому качаем, только когда +# файла нет или он пуст, а если и тогда не скачалось — пустой файл: без него nfqws не стартует. +_rb_exclude_ensure() { + local f=/opt/zapret/ipset/zapret-hosts-user-exclude.txt + [ -s "$f" ] && return 0 + _refresh_exclude_file + [ -f "$f" ] || : > "$f" +} + +# Прогон режима менеджера (v | v_flowseal | youtube). Пишет результаты в формате тестера ZM +# («имя → удачи/всего», первой строкой контроль) и печатает путь к файлу. С файлом целей +# сервисов («svc:хост|ссылка») у строки есть хвост « · сервисы N»: сколько адресов +# неоткрывшихся сервисов стратегия открыла. Сортировка — по общему счёту, хвост решает при равном. +_rb_ztest() { # РЕЖИМ [ЦЕЛИ_СЕРВИСОВ] + local mode="$1" svcf="${2:-}" cand="$RB_RUN/zt.cand" urls="$RB_RUN/zt.urls" res="$RB_RUN/zt.res.$1" lines total cur=0 start next r ok tot sv name + [ -x "$RB_ZT_NFQWS" ] || return 1 + # Остатки прошлого подбора, убитого без уборки (kill -9, перезагрузка панели): чужой nfqws + # на нашей очереди не дал бы запуститься ни одной стратегии. + _rb_zt_down + _kill_match "qnum=$RB_ZT_QUEUE" + _add_gp_domains + _rb_exclude_ensure + chmod -R a+rX /opt/zapret/ipset /opt/zapret/files 2>/dev/null + echo " Собираем стратегии" >&2 + _test_build_candidates "$mode" "$cand" + [ -s "$cand" ] || return 1 + echo " Собираем адреса для проверки" >&2 + if [ "$mode" = youtube ]; then _test_yt_urls > "$urls"; else _test_prepare_urls "$urls"; fi + _rb_urls_https "$urls" + [ -n "$svcf" ] && [ -s "$svcf" ] && cat "$svcf" >> "$urls" + total=$(grep -c '^#' "$cand") + echo " Стратегий в подборе: $total" >&2 + # Правила не встали (нет kmod-nft-queue) — каждая стратегия мерилась бы как контроль. + _rb_zt_rules_up && nft list table inet "$RB_ZT_TABLE" >/dev/null 2>&1 || + { echo "!! Не удалось поставить правила проверки — нужен пакет kmod-nft-queue" >&2; return 1; } + echo " Проверяем, что открывается без Zapret" >&2 + r="$(_rb_zt_check "$urls")" + set -- $r; ok=$1 tot=$2 sv=$3 + echo " открылось $ok из $tot" >&2 + echo "Контрольный тест (Zapret выключен) → $ok/$tot · сервисы $sv" > "$res" + lines=$(grep -n '^#' "$cand" | cut -d: -f1) + for start in $lines; do + _rb_stopped && break + cur=$((cur + 1)) + next=$(printf '%s\n' "$lines" | awk -v s="$start" '$1 > s {print; exit}') + if [ -n "$next" ]; then sed -n "${start},$((next - 1))p" "$cand" > "$RB_RUN/zt.block" + else sed -n "${start},\$p" "$cand" > "$RB_RUN/zt.block"; fi + name=$(head -n1 "$RB_RUN/zt.block"); name="${name#\#}" + echo " [$cur/$total] $name" >&2 + r="$(_rb_zt_one "$RB_RUN/zt.block" "$urls")" + set -- $r; ok=$1 tot=$2 sv=${3:-0} + if [ "$ok" = -1 ]; then echo " не запустилась" >&2; continue; fi + echo " открылось $ok из $tot" >&2 + echo "$name → $ok/$tot · сервисы $sv" >> "$res" + done + _rb_zt_down + # Лучшие первыми: сначала по общему счёту, при равном — по адресам неоткрывшихся сервисов. Ключ — + # одно число: sort в busybox без модификаторов ключа, а «→» многобайтный для sort -t. + { head -n1 "$res"; tail -n +2 "$res" | awk '{ n = $0; sub(/.* → /, "", n); split(n, a, "/"); v = $0; sub(/.* · сервисы /, "", v); print (a[1] * 10000 + v) "\t" $0 }' | + sort -rn | cut -f2-; } > "$res.s" && mv "$res.s" "$res" + echo "$res" +} + +# ── равные лучшие: в фоне, после подбора ── +# +# Бонус, а не часть подбора: к этому моменту победитель применён и у человека всё работает. +# Здесь только выясняется, нет ли среди равных такой, у которой поток не рвётся, — и если есть, +# стратегия меняется молча. Всё в той же изоляции, что и подбор; прерывание безопасно, потому +# что настройка меняется одним движением в конце. Новый подбор или переустановка фон просто +# снимают. +RB_DEEP_NOTE="$RB_DIR/deep.note" + +_rb_deep_stop() { + _job_alive redbtn_deep || return 0 + _test_kill_pid_tree "$(cat "$JOBS_DIR/redbtn_deep.pid" 2>/dev/null)" + _rb_zt_down + _kill_match "qnum=$RB_ZT_QUEUE" + rm -f "$JOBS_DIR/redbtn_deep.pid" +} + +do_redbtn_deep() { # ПРИМЕНЁННАЯ + local cur="$1" win sum before after + [ -s "$RB_RUN/deep.res" ] && [ -s "$RB_RUN/deep.cand" ] || return 0 + sum="$(md5sum "$CONF" | cut -d' ' -f1)" + RB_PROBE_DOH="$(_rb_doh_pick)" + win="$(_rb_zt_deep_pick "$RB_RUN/deep.res" "$cur")" + rm -f "$RB_RUN/deep.res" "$RB_RUN/deep.cand" + [ -n "$win" ] && [ "$win" != "$cur" ] || { echo "Оставляем $cur"; return 0; } + # За это время настройку могли сменить руками или тестером — тогда чужое не трогаем. + [ "$(md5sum "$CONF" | cut -d' ' -f1)" = "$sum" ] || { echo "Настройку Zapret успели сменить — не трогаем"; return 0; } + _rb_test_running && return 0 + _rb_running && return 0 + cp "$CONF" "$RB_RUN/deep.bak" + before="$(_rb_count_ok "$(_rb_check_all as_is)")" + _rb_zapret_apply "$win" || { rm -f "$RB_RUN/deep.bak"; return 0; } + after="$(_rb_count_ok "$(_rb_check_all as_is)")" + if [ "$after" -lt "$before" ]; then + cp "$RB_RUN/deep.bak" "$CONF"; zapret_restart + echo "С $win открывается меньше сервисов — вернули $cur" + else + echo "Переключили на $win" + printf '%s\n' "Лучшая стратегия: $win" > "$RB_DEEP_NOTE" + fi + rm -f "$RB_RUN/deep.bak" +} + +_rb_result_svc() { printf '%s\n' "$1" | sed -n 's/.* · сервисы \([0-9]*\)$/\1/p'; } + +# Цели неоткрывшихся сервисов для подбора: обязательные и дополнительные адреса каждого. +_rb_svc_targets() { # "id id..." ФАЙЛ + local id h + : > "$2" + for id in $1; do + for h in $(_rb_svc_field "$id" 5 | tr ',' ' ') $(_rb_svc_field "$id" 6 | tr ',' ' '); do + printf 'svc:%s|https://%s/\n' "$h" "$h" >> "$2" + done + done +} + +# Близкие к лучшей: общий счёт не ниже 90 % от её счёта. Разница в пару целей на полсотни — +# это шум короткой проверки, а стратегия чуть ниже по общему счёту может открывать сервисы, +# которые у лучшей не открылись. Первой идёт лучшая (она применена), за ней остальные — сначала +# открывшие больше адресов неоткрывшихся сервисов; всего не больше RB_DEEP_MAX. +_rb_zt_ties() { # ФАЙЛ_РЕЗУЛЬТАТОВ -> имена, по строке + tail -n +2 "$1" | awk ' + { n = $0; sub(/ → .*/, "", n); g = $0; sub(/^.* → /, "", g); split(g, a, "/"); v = $0; sub(/^.* · сервисы /, "", v) + if (NR == 1) { top = a[1]; print "0\t" n; next } + if (a[1] * 10 >= top * 9) print (100000 - v) "\t" n }' | + sort -n | cut -f2- | head -n "$RB_DEEP_MAX" +} + +# Близкие к лучшей (см. _rb_zt_ties). Если их больше одной, у каждой меряется дополнительный +# счёт — узлы CDN, до которых дошёл поток, — и итоговый счёт = основной + дополнительный. +# Печатает имя победителя по итоговому (или первой, если проверить не удалось). Первая — +# применённая: другая побеждает, только набрав строго больше. +_rb_zt_deep_pick() { # ФАЙЛ_РЕЗУЛЬТАТОВ [ПРИМЕНЁННАЯ] + local res="$1" cur="${2:-}" best key ties n name blk="$RB_RUN/zt.dblock" tgt="$RB_RUN/zt.deep" r sc main tot top=-1 win + best=$(tail -n +2 "$res" | head -n1) + win="${best%% → *}" + ties="$(_rb_zt_ties "$res")" + n=$(printf '%s\n' "$ties" | grep -c .) + [ "$n" -gt 1 ] || { echo "$win"; return 0; } + echo "Сравниваем близкие стратегии: $n" >&2 + _rb_zt_deep_targets "$tgt" || { echo " список для сравнения не скачался — оставляем применённую" >&2; echo "$win"; return 0; } + _rb_zt_rules_up || { echo "$win"; return 0; } + printf '%s\n' "$ties" > "$RB_RUN/zt.ties" + while IFS= read -r name; do + _rb_stopped && break + _rb_zt_block "$name" "$blk" "$RB_RUN/deep.cand" || continue + echo " $name" >&2 + r="$(_rb_zt_one "$blk" "$tgt" _rb_zt_deep)" + sc="${r%% *}" + [ "$sc" = -1 ] && { echo " не запустилась" >&2; continue; } + main=$(awk -v n="$name" 'NR > 1 { m = $0; sub(/ → .*/, "", m); if (m == n) { sub(/^.* → /, ""); split($0, a, "/"); print a[1]; exit } }' "$res") + tot=$(( ${main:-0} + sc )) + echo " основной ${main:-0} + дополнительный $sc из ${r#* } = $tot" >&2 + # При равном итоге остаётся применённая: переключение ради той же суммы — лишний + # перезапуск обхода (на тестовом роутере v4 и v7 сошлись на 137, и сравнение + # переключало на v7 только потому, что та шла в списке первой). + if [ "$tot" -gt "$top" ] || { [ "$tot" -eq "$top" ] && [ "$name" = "$cur" ]; }; then + top="$tot"; win="$name" + fi + done < "$RB_RUN/zt.ties" + _rb_zt_down + rm -f "$RB_RUN/zt.ties" "$blk" "$tgt" + echo "$win" +} + +_rb_result_num() { printf '%s\n' "$1" | sed -n 's/.* → \([0-9]*\)\/.*/\1/p'; } + +# Печатает «имя удачи» лучшей стратегии, если она открыла больше, чем без Zapret: больше общих +# целей или, при равных, больше адресов неоткрывшихся сервисов. +_rb_zapret_pick() { # РЕЖИМ_ТЕСТА [ЦЕЛИ_СЕРВИСОВ] + local res best ctrl bok bsv csv name + res="$(_rb_ztest "$1" "${2:-}")" || return 1 + best=$(tail -n +2 "$res" | head -n1) + [ -n "$best" ] || return 1 + bok=$(_rb_result_num "$best"); bsv=$(_rb_result_svc "$best") + ctrl=$(_rb_result_num "$(head -n1 "$res")"); csv=$(_rb_result_svc "$(head -n1 "$res")") + [ -n "$bok" ] || return 1 + if [ -n "$ctrl" ]; then + [ "$bok" -lt "$ctrl" ] && return 1 + [ "$bok" -eq "$ctrl" ] && [ "${bsv:-0}" -le "${csv:-0}" ] && return 1 + fi + cp "$res" "$RB_RUN/deep.res"; cp "$RB_RUN/zt.cand" "$RB_RUN/deep.cand" + echo "${best%% → *} $bok" +} + +_rb_youtube_pick() { + local res best name + res="$(_rb_ztest youtube)" || return 1 + best=$(tail -n +2 "$res" | head -n1) + [ -n "$best" ] || return 1 + name="${best%% → *}" + # Как у общего подбора: не лучше, чем без Zapret, — не применяем. + local bok ctrl + bok=$(_rb_result_num "$best"); ctrl=$(_rb_result_num "$(head -n1 "$res")") + [ -n "$bok" ] && [ -n "$ctrl" ] && [ "$bok" -le "$ctrl" ] && return 1 + case "$name" in Yv[0-9]*) echo "$name" ;; *) return 1 ;; esac +} + +_rb_result_write() { # ID СОСТОЯНИЕ + mkdir -p "$RB_DIR" + touch "$RB_RESULTS" + sed -i "/^$1|/d" "$RB_RESULTS" + echo "$1|$2" >> "$RB_RESULTS" +} + + +# ── ИИ-сервисы: только DNS ── +# +# Геоблок не лечится ни стратегией zapret, ни туннелем: сервис смотрит, откуда пришли, и +# открывает его только резолвер, выдающий адрес своего прокси. Поэтому проба здесь идёт +# СИСТЕМНЫМ DNS — тем, что лечит, — а подбор zapret и WARP этот сервис не запускает. +# +# РЕЗОЛВЕР — ТОЛЬКО ДЛЯ ДОМЕНОВ СПИСКА, а не для всей сети. Список — тот, что публикует +# dns.malw.link (файл hosts его репозитория: какие имена этот резолвер разблокирует); адреса к +# ним отдаёт выбранный резолвер, а не файл, поэтому они не стареют вместе со списком. dnsmasq +# получает свой файл `server=/домен/127.0.0.1#5359`, остальное разрешается как раньше. +# +# Резолвер крутится СВОИМ экземпляром https-dns-proxy (служба zm-geodns), а не секцией в +# /etc/config/https-dns-proxy: та принадлежит человеку и странице DoH, и её dnsmasq_config_update +# '*' увела бы в георезолвер вообще всё. Пакет, если его ставит кнопка, свою службу тут же +# выключает — иначе он при установке сам забирает dnsmasq и порт 53 (проверено на 25.12.5). +# Порт вне серии https-dns-proxy (5053, 5054, 5055… — по экземпляру на резолвер человека). +RB_GEO_PORT=5359 +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" +# СТОРОЖ — своя задача redbtn_geo со своим журналом, а не redbtn. Под именем redbtn он затирал +# журнал последнего автообхода, которым человек разбирает, что подбор сделал, и страница +# показывала «идёт автообход», которого человек не запускал. Автообход и кнопка «Подобрать DNS +# заново» ждут сторожа (_rb_geo_watch_wait), а не убивают: посреди подбора он перезапускает +# dnsmasq, и оборванный на полпути перезапуск оставил бы сеть без DNS. Сам сторож поверх +# автообхода не стартует. +RB_GEO_JOB=redbtn_geo +# Последний неудачный подбор DNS — в /tmp: после перезагрузки одна лишняя попытка дешевле +# записи во флеш каждые два часа. +RB_GEO_FAIL="$RB_RUN/geo.fail" +# Резолвера нет, потому что последний подбор не нашёл годного: пробовать снова не каждые 30 +# минут, а раз в два часа — дюжина проб и скачивание списка доменов на каждом тике cron. +RB_GEO_RETRY=7200 +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_PARALLEL разом: каждая проба — curl на те же три-четыре мегабайта, а пул уже +# двенадцать резолверов, и на роутере со 128 МБ дюжина разом — тот же OOM, что у проб сервисов. +# Ждём только свои пробы, как в _rb_zt_check. +_rb_geo_pick() { + local line out="$RB_RUN/geo.pick" run=0 pids="" + : > "$out" + while IFS= read -r line; do + case "$line" in ''|\#*) continue ;; esac + [ "$(echo "$line" | cut -d'|' -f5)" = geo ] || continue + ( _rb_geo_try "$line" >> "$out" ) & + pids="$pids $!" + run=$((run + 1)) + if [ "$run" -ge "$RB_PARALLEL" ]; then wait $pids 2>/dev/null; pids=""; run=0; fi + done < "$RB_SHARE/doh.conf" + [ -n "$pids" ] && wait $pids 2>/dev/null + [ -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 _st_fetch "$u" "$f" && grep -q '^[0-9]' "$f" && { ok=1; break; }; done + if [ "$ok" = 1 ]; then + # Имена из чужого файла идут в конфиг dnsmasq — пропускаем только настоящие имена хостов. + # `#`, `*`, `/`, хвост \r от CRLF дали бы строку, уводящую в резолвер весь DNS роутера, + # или конфиг, с которым dnsmasq не стартует вовсе. + awk '/^[0-9]/ { d = tolower($2); sub(/\r$/, "", d); + if (d ~ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/) print d }' "$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 +} + +# Снять DNS для ИИ-сервисов целиком: служба, файл dnsmasq, сторож, пакет, если его ставили мы. +_rb_geo_off() { + _rb_geo_disable + rm -f /etc/init.d/zm-geodns.disabled "$RB_GEO_DOMAINS" "$RB_GEO_FAIL" + _rb_geo_watch_off + sed -i '/^svc zm-geodns$/d' "$RB_OWNED" 2>/dev/null + # Пакет удаляем, только если им не пользуется страница DoH: её служба включена — значит, + # человек настроил DoH поверх того, что поставили мы, и пакет теперь его. + if _rb_owns "pkg https-dns-proxy"; then + /etc/init.d/https-dns-proxy enabled 2>/dev/null || $DELETE https-dns-proxy >/dev/null 2>&1 + sed -i '/^pkg https-dns-proxy$/d' "$RB_OWNED" + fi + sed -i '/^geoblock|/d' "$RB_RESULTS" 2>/dev/null +} + +_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" + rm -f "$RB_GEO_FAIL" + _rb_geo_watch_on + else + [ -s "$RB_GEO_RESOLVER" ] && _rb_geo_disable + date +%s > "$RB_GEO_FAIL" + _rb_result_write geoblock none + echo "[FAIL] $(_rb_svc_field geoblock 2) — не открывается" + fi +} + +# Подобрать DNS заново — общее у кнопки «Подобрать DNS заново» и сторожа. +_rb_geo_redo() { + # Не нашлось ни одного годного — прежний резолвер снимается, если и он уже не открывает ИИ- + # сервисы (так сторож и зовёт подбор): с умершим прокси домены списка не разрешались бы вовсе. + # Рабочий остаётся — кнопку могли нажать и при живом. Дальше сторож пробует раз в RB_GEO_RETRY. + if ! _rb_geo_enable; then + [ -s "$RB_GEO_RESOLVER" ] && ! _rb_geo_ok && _rb_geo_disable + date +%s > "$RB_GEO_FAIL" + _rb_result_write geoblock none + return 1 + fi + if _rb_geo_ok; then + _rb_result_write geoblock doh + rm -f "$RB_GEO_FAIL" + _rb_geo_watch_on + _rb_say "Готово, ИИ-сервисы открываются" + else + # Резолвер, не открывший ИИ-сервисы, выключаем: иначе через него шли бы все домены + # списка, а открывалось раньше что-то из них и без него. + _rb_geo_disable + date +%s > "$RB_GEO_FAIL" + _rb_result_write geoblock none + echo "!! ИИ-сервисы не открываются" + return 1 + fi +} + +# Кнопка «Подобрать DNS заново» — задачей автообхода: человек нажал её на странице и смотрит +# её журнал там же. Сторож идёт своей задачей, см. redbtn_geo_watch. +do_redbtn_geo_doh() { + _rb_phase geo + rm -f "$RB_STOP_FLAG" + _rb_geo_watch_wait + _rb_geo_redo + _rb_phase done +} + +_rb_geo_watch_on() { + grep -q redbtn_geo_watch "$CRON_FILE" 2>/dev/null && return 0 + 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 +} + +_rb_geo_watch_off() { + grep -q redbtn_geo_watch "$CRON_FILE" 2>/dev/null || return 0 + sed -i '/redbtn_geo_watch/d' "$CRON_FILE" 2>/dev/null && /etc/init.d/cron restart >/dev/null 2>&1 +} + +_rb_geo_watch_wait() { # дождаться сторожа, если он сейчас подбирает (до пяти минут) + local w=0 + _job_alive "$RB_GEO_JOB" || return 0 + _rb_say "Ждём, пока закончится подбор DNS для ИИ-сервисов" + while [ "$w" -lt 300 ] && _job_alive "$RB_GEO_JOB"; do sleep 1; w=$((w + 1)); done +} + +# DNS для ИИ-сервисов выключил человек, а не наша неудача. Кнопка «Выключить» убирает и +# строку cron (_rb_geo_off), так что сюда доходят другие пути: пакет https-dns-proxy удалён, +# или резолвер на месте, а службу zm-geodns выключили в «Автозапуске» (сами мы её выключаем +# только вместе с удалением резолвера, см. _rb_geo_disable). +_rb_geo_user_off() { + [ -x /usr/sbin/https-dns-proxy ] || return 0 + [ -s "$RB_GEO_RESOLVER" ] && ! /etc/init.d/zm-geodns enabled 2>/dev/null && return 0 + return 1 +} + +# Раз в 30 минут из cron. Строка cron появляется, когда DNS для ИИ-сервисов однажды подобрался, +# и остаётся, пока его не выключили: и когда прокси резолвера умер, и когда подбор заново не +# нашёл годного и резолвер снят. Во втором случае прежний сторож молчал до конца — ему нечего +# было проверять, — теперь пробует снова раз в RB_GEO_RETRY. +redbtn_geo_watch() { + if _rb_geo_user_off; then + logger -t zm-redbtn "DNS для ИИ-сервисов выключен — сторож снимается" + _rb_geo_watch_off + return 0 + fi + _rb_running && return 0 + _job_alive "$RB_GEO_JOB" && return 0 + _rb_geo_ok && { rm -f "$RB_GEO_FAIL"; return 0; } + if [ ! -s "$RB_GEO_RESOLVER" ] && [ -s "$RB_GEO_FAIL" ] && + [ $(( $(date +%s) - $(cat "$RB_GEO_FAIL") )) -lt "$RB_GEO_RETRY" ] 2>/dev/null; then + return 0 + fi + logger -t zm-redbtn "ИИ-сервисы не открываются — подбираю DNS заново" + job_start "$RB_GEO_JOB" _rb_geo_redo >/dev/null 2>&1 +} + +# Удаление панели (system_uninstall_panel зовёт до того, как сотрёт backend.sh). Steer, его +# туннели и мост Telegram переживают удаление — их службы и строка cron самодостаточны (см. +# ST_CRON_CMD), и список туннелей моста /etc/stgws/warp.lst остаётся верным, пока живут они. +# DNS для ИИ-сервисов — нет: без сторожа умерший прокси резолвера так и остался бы в dnsmasq, и +# домены списка перестали бы разрешаться вовсе, а строка cron каждые 30 минут звала бы +# несуществующий backend.sh. Поэтому он снимается целиком, со службой. +redbtn_panel_gone() { + local f r=0 + _rb_geo_watch_wait + _rb_geo_off + rm -f /etc/init.d/zm-geodns /etc/rc.d/*zm-geodns + # Файл dnsmasq снимает stop службы; этот проход — на случай, если служба не отработала. + for f in /tmp/dnsmasq.d/zm-geo.conf /tmp/dnsmasq.cfg*.d/zm-geo.conf; do + [ -f "$f" ] && { rm -f "$f"; r=1; } + done + [ "$r" = 1 ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 + # «pkg tgws» писали прежние версии, и никто его не читал; оставшись, он держал бы непустым + # owned, по которому удаление панели оставляет /usr/share/zm-redbtn. + sed -i '/^pkg tgws$/d' "$RB_OWNED" 2>/dev/null + [ -s "$RB_OWNED" ] || rm -f "$RB_OWNED" +} + +# ── основной проход ── + +do_redbtn_run() { + local mode="${1:-quick}" blk before after failed zfailed id zbak="$RB_RUN/zapret.before" pick name warp_ids="" deep_cur="" warp_done="" + # Все три флага: «Стоп» ставит и флаг страницы steer, и без его снятия следующий подбор + # сразу выходил из подъёма туннелей. + rm -f "$RB_STOP_FLAG" "$ST_STOP_FLAG" "$TEST_STOP_FLAG" + _rb_deep_stop + mkdir -p "$RB_DIR" "$RB_RUN" + rm -f "$RB_RUN/deep.res" "$RB_RUN/deep.cand" "$RB_DEEP_NOTE" + _ensure_deps + blk="$(_rb_blocker)" + case "$blk" in + zapret2) echo "ОШИБКА: установлен Zapret2 — сначала удалите его"; return 1 ;; + esac + _rb_test_running && { echo "ОШИБКА: идёт тест стратегий — дождитесь его окончания"; return 1; } + _job_alive steer && { echo "ОШИБКА: на странице Steer идёт операция — дождитесь её окончания"; return 1; } + # Сторож DNS для ИИ-сервисов мог начать подбор за минуту до кнопки: два подбора разом + # писали бы один geo.resolver и дважды перезапускали dnsmasq. Фаза — сразу первая: по + # оставшейся от кнопки «geo» страница приняла бы ожидание за «Подобрать DNS заново». + _rb_phase zapret_install + _rb_geo_watch_wait + : > "$RB_RESULTS" + # Нажатая кнопка — согласие на всё, что подбор умеет. Выключенное руками (Steer целиком или + # сервис, снятый на его странице) снова участвует: иначе подбор честно находил бы, что + # сервис открывается через WARP, и не включал бы его. + if _st_installed; then + # Движок, поставленный нами, — до нашей версии (см. _st_install_steer). + _st_owns "pkg steer" && _st_ver_lt "$(_st_steer_ver)" "$ST_STEER_VER" && _st_install_steer + [ -f "$ST_OFF" ] && { rm -f "$ST_OFF"; _rb_say "Steer был выключен — включаем"; } + [ -s "$ST_SKIP" ] && { rm -f "$ST_SKIP"; _rb_say "Сервисы, снятые на странице Steer, снова участвуют в подборе"; } + fi + + _rb_phase zapret_install + if [ ! -f /etc/init.d/zapret ]; then + _rb_say "Устанавливаем Zapret" + do_install_zapret_full || { echo "ОШИБКА: Zapret не установился"; return 1; } + fi + /etc/init.d/zapret enable >/dev/null 2>&1 + pgrep -f "/opt/zapret/" >/dev/null 2>&1 || zapret_restart + + # INT/TERM — с выходом: в ash после обработчика выполнение продолжилось бы уже без правил. + trap '_rb_probe_nft_down' EXIT + trap '_rb_probe_nft_down; exit 143' INT TERM + _rb_probe_nft_up + RB_PROBE_DOH="$(_rb_doh_pick)" || _rb_warn "Шифрованный DNS для проверок не ответил — проверяем через DNS роутера" + + _rb_phase check + _rb_say "Проверяем сервисы при текущей настройке" + before="$(_rb_check_all as_is открывается)" + _rb_stopped && { _rb_say "Остановлено"; return 0; } + + # Подбор стратегии — только если что-то не открывается: рабочую настройку не трогаем. + # ИИ-сервисы не в счёт: их закрывает геоблок, а его стратегия Zapret не снимает — ради них + # одних гонять тестер десять минут бессмысленно, им сразу WARP. + # Полный подбор человек выбирает сам — обычно после быстрого, когда что-то ушло в WARP или не + # открылось вовсе. Он идёт всегда: иначе после быстрого подбора, где всё уже открывается + # (в том числе через WARP), стратегии Flowseal не проверялись бы ни разу. + failed="$(_rb_failed "$before")" + zfailed="$(printf '%s\n' "$failed" | grep -vx 'geoblock')" + if [ -n "$zfailed" ] || [ "$mode" = full ]; then + _rb_phase zapret + cp "$CONF" "$zbak" + local tmode=v svcf="$RB_RUN/zt.svc" + [ "$mode" = full ] && tmode=v_flowseal + _rb_svc_targets "$zfailed" "$svcf" + _rb_say "Подбираем стратегию Zapret — сеть работает на прежней настройке до конца подбора" + # Подбор идёт в изоляции: клиенты до конца на прежней настройке. Неполный победитель + # остановленного подбора не применяется. + if pick="$(_rb_zapret_pick "$tmode" "$svcf")" && ! _rb_stopped; then + name="${pick% *}" + _rb_say "Лучшая стратегия: $name (${pick##* } целей)" + if _rb_zapret_apply "$name"; then + _rb_say "Проверяем сервисы с новой стратегией" + after="$(_rb_check_all as_is открывается)" + # Одна проба шумит: сервис, ответивший медленно, выглядит закрытым. Прежде чем + # откатывать, перемериваем ещё раз. + [ "$(_rb_count_ok "$after")" -lt "$(_rb_count_ok "$before")" ] && after="$(_rb_check_all as_is)" + # Остановили посреди перепроверки — неполная перепроверка ничего не доказывает: + # возвращаем прежнюю настройку молча, без «открывается меньше». + if _rb_stopped; then + cp "$zbak" "$CONF"; zapret_restart + _rb_say "Остановлено — прежняя настройка Zapret возвращена" + return 0 + fi + if [ "$(_rb_count_ok "$after")" -lt "$(_rb_count_ok "$before")" ]; then + _rb_warn "С новой стратегией открывается меньше сервисов — возвращаем прежнюю настройку" + cp "$zbak" "$CONF" + zapret_restart + else + before="$after" + _rb_say "Стратегия $name применена" + deep_cur="$name" + fi + fi + elif ! _rb_stopped; then + _rb_warn "Ни одна стратегия не открыла больше, чем без Zapret — настройку не меняем" + fi + _rb_stopped && { _rb_say "Остановлено"; return 0; } + + # YouTube отдельно: у менеджера для него свой набор стратегий (Yv). + if printf '%s\n' "$before" | grep -qx 'youtube fail'; then + _rb_say "Подбираем стратегию для YouTube" + cp "$CONF" "$zbak" + if name="$(_rb_youtube_pick)" && ! _rb_stopped; then + case "$(strategy_set_youtube "$name")" in + *'"ok":true'*) + if _rb_check_service youtube as_is; then + before="$(printf '%s\n' "$before" | sed 's/^youtube fail$/youtube ok/')" + _rb_say "Для YouTube применена стратегия $name" + else + cp "$zbak" "$CONF"; zapret_restart + fi ;; + esac + fi + fi + rm -f "$zbak" + fi + _rb_stopped && { _rb_say "Остановлено"; return 0; } + + # Что открылось — напрямую или благодаря zapret. + _rb_phase check + local direct + _rb_say "Проверяем, что открывается без обхода" + direct="$(_rb_check_all direct)" + for id in $(printf '%s\n' "$before" | awk '$2=="ok"{print $1}'); do + _rb_stopped && { _rb_say "Остановлено"; return 0; } + # «Через Zapret» — только если и повторная проба без обхода не прошла. + if printf '%s\n' "$direct" | grep -qx "$id ok" || _rb_check_service "$id" direct; then + _rb_result_write "$id" direct + else + _rb_result_write "$id" zapret + fi + done + + # WARP — для того, что не открыл zapret, и только если установлен steer. ИИ-сервисы сюда + # не идут: геоблок снимает DNS, а не туннель (см. «ИИ-сервисы: только DNS»). + failed="$(_rb_failed "$before" | grep -vx geoblock)" + if [ -n "$failed" ] && _st_ready; then + _rb_phase warp + _rb_say "Не открылось: $(_rb_svc_names "$failed") — пробуем через WARP" + warp_done=1 + if _st_warp_up; then + for id in $failed; do + # Снятых на странице Steer (ST_SKIP) здесь не бывает: подбор забывает их в начале + # прохода, а сменить выбор, пока он идёт, страница не даёт. + if ! _rb_routable "$id"; then + _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 + echo "[FAIL] $(_rb_svc_field "$id" 2) — не открывается и через WARP" + _rb_result_write "$id" none + fi + done + if [ -n "$warp_ids" ]; then + _rb_phase steer + # Туннели только что подняты и проверены — второй раз не поднимаем. + _st_apply tunnel_ready || _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; } + + # Туннели нужны не только сервисам: мост Telegram ходит через WARP сам (см. _st_tgws_warp). + # Если шаг WARP в этот раз не понадобился, туннели всё равно проверяются: живые в своих + # колониях остаются как есть, российские разводятся заново, список для моста переписывается. + if [ "$warp_done" != 1 ] && _st_ready && [ -s "$ST_WARP_UP" ]; then + _rb_phase warp + _st_warp_up || _rb_warn "Туннели WARP не поднялись — Telegram идёт без них" + fi + _rb_stopped && { _rb_say "Остановлено"; return 0; } + + _rb_phase geo + _rb_geo_step + _rb_stopped && { _rb_say "Остановлено"; return 0; } + + _rb_phase telegram + _rb_say "Проверяем Telegram" + 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 + _rb_result_write telegram direct + echo "[ OK ] Telegram — открывается" + else + if do_tgws_install; then + _rb_rpcd_ensure + _rb_result_write telegram tgws + echo "[ OK ] Telegram — через веб-сокет" + else + _rb_result_write telegram none + echo "[FAIL] Telegram" + fi + fi + + _rb_phase done + date +%s > "$RB_LAST" + if [ -n "$warp_ids" ] && _st_dns_conflict; then + _rb_warn "DNS over HTTPS перехватывает DNS сети — сервисы через WARP работать не будут, нажмите «Исправить» на странице Steer" + fi + _rb_say "Готово, обход подобран" + # Близкие к лучшей сравниваются уже после — обход к этому моменту работает. + if [ -n "$deep_cur" ] && [ -s "$RB_RUN/deep.res" ] && [ "$(_rb_zt_ties "$RB_RUN/deep.res" | grep -c .)" -gt 1 ]; then + # Правила проб снимаются ловушкой на выходе, а с ними и таблица изоляции — снимаем + # сейчас, чтобы ловушка не выдернула её из-под фоновой проверки. + _rb_probe_nft_down + trap - EXIT INT TERM + job_start redbtn_deep do_redbtn_deep "$deep_cur" >/dev/null + _rb_say "Лучшая стратегия определится позже — идёт сравнение близких" + fi +} + + +# ── видео на время подбора ── +# +# Каталог — файл 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 + [ -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 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)" + [ -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)" + # Выбор на странице 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 + local geo="" + [ -s "$RB_GEO_RESOLVER" ] && geo=$(sed -n "s/^GEO_TITLE='\(.*\)'$/\1/p" "$RB_GEO_RESOLVER") + local deep=false note="" + _job_alive redbtn_deep && deep=true + [ -s "$RB_DEEP_NOTE" ] && note="$(head -n1 "$RB_DEEP_NOTE")" + printf '{"running":%s,"deep":%s,"deep_note":"%s","phase":"%s","last":"%s","blocker":"%s","steer":%s,"steer_installed":%s,"steer_busy":%s,"video":"%s","geo_dns":"%s","configured":%s,"services":[%s]}\n' \ + "$running" "$deep" "$(esc "$note")" "$(esc "$phase")" "$last" "$blk" "$steer" "$(_st_installed && echo true || echo false)" "$(_job_alive steer && echo true || echo false)" "$(esc "$video")" "$(esc "$geo")" \ + "$([ -s "$RB_LAST" ] && echo true || echo false)" "$svc" +} + +redbtn_action() { + local action="$1" mode="$2" + case "$action" in + 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" "$ST_STOP_FLAG" + printf '{"ok":true}\n' + ;; + video_list) redbtn_video_list ;; + video_set) redbtn_video_set "$mode" ;; + geo_doh) + _rb_running && { echo '{"error":"дождитесь окончания подбора"}'; return 1; } + job_start redbtn do_redbtn_geo_doh + ;; + geo_off) + _rb_running && { echo '{"error":"дождитесь окончания подбора"}'; return 1; } + # Сторож посреди подбора включил бы то, что сейчас выключают, а ждать его здесь + # нельзя — это синхронный вызов страницы. + _job_alive "$RB_GEO_JOB" && { echo '{"error":"идёт подбор DNS для ИИ-сервисов — повторите через минуту"}'; return 1; } + _rb_geo_off + printf '{"ok":true}\n' + ;; + *) echo '{"error":"неизвестное действие"}' ;; + esac +} + cmd="$1"; shift case "$cmd" in @@ -4776,6 +7535,12 @@ case "$cmd" in mixomo_warp_config_set) mixomo_warp_config_set "$1" ;; bytetube_installed) bytetube_installed ;; health) health ;; + redbtn_status) redbtn_status ;; + redbtn_action) redbtn_action "$1" "$2" ;; + redbtn_geo_watch) redbtn_geo_watch ;; + redbtn_panel_gone) redbtn_panel_gone ;; + steer_status) steer_status ;; + steer_action) steer_action "$1" "$2" ;; bytetube_action) bytetube_action "$1" ;; *) echo '{"error":"неизвестная команда"}'; exit 1 ;; esac @@ -4864,6 +7629,10 @@ list_methods() { json_add_object "mixomo_warp_config_set"; json_add_string "content" "string"; json_close_object json_add_object "bytetube_installed"; json_close_object 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 } @@ -4946,6 +7715,10 @@ call_method() { mixomo_warp_config_set) json_get_var content content; "$BACKEND" mixomo_warp_config_set "$content" ;; bytetube_installed) "$BACKEND" bytetube_installed ;; 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 @@ -4974,7 +7747,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" + "zapret_latest_version", "bytetube_installed", "health", "redbtn_status", "steer_status" ], "system": [ "info" ] }, @@ -4999,7 +7772,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" + "bytetube_action", "redbtn_action", "steer_action" ] }, "uci": [ "bytetube" ] @@ -5026,6 +7799,16 @@ cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF "order": 10, "action": { "type": "view", "path": "zapret-manager/dashboard" } }, + "admin/services/zapret-manager/redbtn": { + "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, @@ -5079,6 +7862,10 @@ cat > '/www/luci-static/resources/zapret-manager/common.js' << 'ZM_INSTALLER_EOF 'require ui'; 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: {} }); @@ -5414,6 +8201,10 @@ return baseclass.extend({ mixomoWarpConfigSet: callMixomoWarpConfigSet, bytetubeInstalled: callBytetubeInstalled, health: callHealth, + redbtnStatus: callRedbtnStatus, + redbtnAction: callRedbtnAction, + steerStatus: callSteerStatus, + steerAction: callSteerAction, boardInfo: callBoardInfo, parseSize: parseSize, fmtSize: fmtSize, @@ -5489,6 +8280,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)))); @@ -5719,6 +8516,1870 @@ return view.extend({ ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/doh.js' +cat > '/www/luci-static/resources/view/zapret-manager/redbtn.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'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 STEPS = [ + { id: 'zapret_install', label: 'Подготовка' }, + { id: 'check', label: 'Проверка' }, + { id: 'zapret', label: 'Zapret' }, + { id: 'warp', label: 'WARP' }, + { id: 'steer', label: 'Правила' }, + { id: 'geo', label: 'ИИ-сервисы' }, + { id: 'telegram', label: 'Telegram' } +]; + +var PHASE_TEXT = { + zapret_install: 'Готовим Zapret', + check: 'Проверяем сервисы', + zapret: 'Подбираем стратегию Zapret', + warp: 'Поднимаем туннель WARP', + steer: 'Применяем правила', + geo: 'Подбираем DNS для ИИ-сервисов', + telegram: 'Проверяем Telegram', +}; +var RUN_PHASES = [ 'zapret_install', 'check', 'zapret', 'warp', 'steer', 'geo', 'telegram' ]; + +var BLOCKERS = { + zapret2: 'Установлен Zapret2 — удалите его на странице Zapret2.' +}; + +var MODES = [ + { id: 'quick', label: 'Быстрый подбор', hint: 'Стратегии v — обычно 5–15 минут.' }, + { id: 'full', label: 'Полный подбор', hint: 'Стратегии v и Flowseal — дольше, до получаса, зато вариантов больше.' } +]; + +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/AutoBypass/', + 'https://raw.githubusercontent.com/xyzmean/Zapret-Manager-Exp/main/files/AutoBypass/' +]; +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' }), + s ? s.text : 'не проверен' + ]); +} + +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 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().catch(function() { return {}; }); + }, + + render: function(data) { + 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 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 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, preview, prefOverride) { + var src = videoSource(prefOverride != null ? prefOverride : videoPref()); + if (!src) return; + if (videoEl) videoHide(); + previewing = !!preview; + videoEl = E('video', { + '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()); + // Сохраняем выбор только по нажатию: volumechange приходит и от нашего же muted = true + // (открыли страницу посреди подбора, браузер не дал играть со звуком), и тогда звук + // выключался бы навсегда без участия человека. + videoEl.addEventListener('volumechange', function() { soundLabel(); }); + // Не открылся ни один источник — прячем плеер и говорим об этом одной строкой. + 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; soundSave(!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; + var p = videoEl.play(); + if (p && p.catch) p.catch(function() { + if (!videoEl) return; + videoEl.muted = true; + videoEl.play().catch(function() {}); + }); + }, 0); + } + + function videoHide() { + 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 || {}; + renderAll(); + }); + } + + 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); return; } + finish(true, res); + }).catch(function() { setTimeout(waitRouter, 5000); }); + } + + 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'); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + + // ── главная карточка ── + + function stepIndex(phase) { + for (var i = 0; i < STEPS.length; i++) if (STEPS[i].id === phase) 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 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) { + 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 && data.deep) heroCard.appendChild(E('div', { 'class': 'zm-ab-note zm-st-note-ok' }, 'Лучшая стратегия определится позже — идёт сравнение близких.')); + else if (!busy && data.deep_note) heroCard.appendChild(E('div', { 'class': 'zm-ab-note zm-st-note-ok' }, data.deep_note)); + + 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; + } + + 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 = [ 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', {}, 'Сервисы')); + 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) + ]); + }))); + // ИИ-сервисы открывает DNS, а не туннель: какой выбран и что с ним можно сделать. + var geo = (data.services || []).filter(function(s) { return s.id === 'geoblock'; })[0]; + if (data.geo_dns) + svcCard.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'DNS для ИИ-сервисов'), + E('span', {}, data.geo_dns) + ])); + if (!busy && (data.geo_dns || (geo && geo.state === 'none'))) { + var geoActs = [ + E('button', { 'class': 'cbi-button' + (data.geo_dns ? '' : ' cbi-button-positive'), 'click': function() { + act('geo_doh', '', 'Подбираем DNS для ИИ-сервисов'); + } }, 'Подобрать DNS заново') + ]; + if (data.geo_dns) geoActs.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { + zm.redbtnAction('geo_off', '').then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast('DNS для ИИ-сервисов выключен', 'info'); + return zm.redbtnStatus().then(function(r) { data = r || data; renderAll(); }); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } }, 'Выключить')); + svcCard.appendChild(E('div', { 'class': 'zm-actions' }, geoActs)); + } + } + + // Не всё открылось, а 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' && s.id !== 'geoblock'; }); + if (busy || data.steer || data.steer_installed || !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(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } + + 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 не подойдут.')); + } + + 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() { + renderTabBar(); + renderHero(); + renderSteerHint(); + renderServices(); + renderVideoSet(); + } + + panels.main.appendChild(heroCard); + panels.main.appendChild(steerCard); + panels.main.appendChild(svcCard); + panels.video.appendChild(videoSetCard); + + renderAll(); + placeRunCards(); + wrap.appendChild(tabBar); + TABS.forEach(function(t) { wrap.appendChild(panels[t.id]); }); + + if (data.running) { + // Идёт ли полный подбор или только «Подобрать DNS заново» — видно по фазе: у второго + // она geo, и ни шагов, ни ролика ему не нужно. + lastAction = data.phase === 'geo' ? 'geo_doh' : 'start'; + follow(lastAction === 'start'); + } + 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 tunnelLive(t) { + var a = parseInt(t.hs_age, 10); + return t.up && !isNaN(a) && a < 300; +} + +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'); + var done = lastAction; + lastAction = ''; + diagRes = null; + renderAll(); + // После «Выключить» и «Удалить» проверять нечего: красный пункт сразу после + // намеренного выключения только пугает. + if (ok && data.installed && !data.stopped && done !== 'stop' && done !== 'remove') 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(data.installed ? 'Выбор сохранён — применится при включении' : 'Выбор сохранён — применится при установке', 'info'); pick = null; refresh(); return; } + if (action === 'lists') pick = null; + 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' ]; + var t = data.tunnels || [], live = t.filter(tunnelLive).length; + if (t.length > 1 && live) return [ 'работает ' + live + ' из ' + t.length + (data.warp_colo ? ' · ' + data.warp_colo : ''), live < t.length ? 'zm-st-warn' : 'zm-st-ok' ]; + 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 = (data.services || []).filter(function(s) { return s.on; }).length, status; + if (!n && (parseInt(data.channels, 10) || 0) > 0) n = 1; + 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 && tunnelState()[1] !== 'zm-st-warn') status = plainBadge('zm-ok', 'работает'); + else if (data.steer_running && data.warp_up) status = plainBadge('zm-warn', 'нет связи'); + 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'); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } }, 'Остановить') + ])); + 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.style.display = data.blocker ? 'none' : ''; + if (data.blocker) return; + 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; }); + // Выбор сбрасывается только после того, как бэкенд его принял (см. act). + 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 && !data.blocker ? '' : 'none'; + if (!data.installed || data.blocker) return; + checkCard.appendChild(E('h3', {}, 'Проверка')); + checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутер проверит туннель и спросит сам Steer, всё ли на месте.')); + if (diagRes) { + var items = []; + var tn = diagRes.tunnels || []; + if (tn.length) tn.forEach(function(t) { + var who = tn.length > 1 ? 'Туннель ' + t.n + ': ' : ''; + if (t.warp === 'on') items.push([ 'ok', who + 'трафик идёт через WARP' + (t.colo ? ' (сервер ' + t.colo + ')' : ''), '' ]); + else if (t.warp === 'notls') items.push([ 'warn', who + 'соединение есть, но HTTPS через туннель не проходит', 'Нажмите «Сменить точки входа» ниже' ]); + else items.push([ tn.length > 1 && diagRes.warp === 'on' ? 'warn' : 'fail', who + 'трафик через WARP не идёт', 'Нажмите «Перезапустить туннели» или «Сменить точки входа» ниже' ]); + }); + else 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 && !data.blocker ? '' : 'none'; + if (!data.installed || data.blocker) return; + var tl = data.tunnels || []; + warpCard.appendChild(E('h3', {}, tl.length > 1 ? 'Туннели WARP' : 'Туннель WARP')); + if (tl.length > 1) { + warpCard.appendChild(E('p', { 'class': 'zm-hint' }, + 'Трафик идёт через самый быстрый живой туннель; упал один — Steer сам переключится на другой.')); + warpCard.appendChild(E('div', { 'class': 'zm-st-tunnels' }, tl.map(function(t) { + var live = tunnelLive(t); + return E('div', { 'class': 'zm-st-tunnel' }, [ + E('div', { 'class': 'zm-st-tunnel-head' }, [ + E('span', { 'class': 'zm-st-tunnel-name' }, 'WARP ' + t.n + (t.colo ? ' · ' + t.colo : '')), + plainBadge(live ? 'zm-ok' : t.up ? 'zm-warn' : 'zm-bad', live ? 'работает' : t.up ? 'нет связи' : 'не поднят') + ]), + row('Точка входа', E('span', {}, t.host ? t.host + ':' + t.port : '—')), + row('Последняя связь', E('span', {}, t.up ? fmtAge(t.hs_age) : '—')), + row('Получено / отправлено', E('span', {}, zm.fmtSize(+t.rx || 0) + ' / ' + zm.fmtSize(+t.tx || 0))) + ]); + }))); + } else { + 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 && !data.blocker ? '' : 'none'; + if (!data.installed || data.blocker) 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; zm.toast('Роутер не ответил', 'error'); }); + } + + // ── спор за 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(); + }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); + } }, 'Исправить') + ]) + ])); + } + + 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' : [ 'install', '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). +# +# ФОРМАТ: id|название|списки доменов|списки адресов|обязательные цели|дополнительные цели|признак отказа|наборы +# +# списки — файлы в /usr/share/zm-redbtn/lists, через запятую; по ним движок уводит сервис в +# туннель. Адресные списки только там, где сервис ходит по адресам без DNS +# (голос Discord, мессенджеры Meta); у YouTube и ИИ-сервисов адресов Google и +# облаков слишком много — туда ушло бы чужое. +# обязательные — цели, которые ОБЯЗАНЫ открыться все: у YouTube это видеоузел, у Discord — +# шлюз и узел обновлений (без него приложение висит на проверке обновлений). +# дополнительные — из них должно открыться не меньше RB_BAR процентов. +# наборы — наборы .srs каталога splify2-lists (издатель itdoginfo), через запятую; по ним +# сервис уводится в туннель. Не скачались — списки из пакета (поля 3 и 4). +# У ИИ-сервисов списков туннеля нет: геоблок снимает DNS (см. zm-geodns), а не WARP. +# признак отказа — фразы в ответе, при которых сервис считается закрытым, хотя ответил +# (геоблок отвечает кодом 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|svc_telegram.lst|telegram.lst|web.telegram.org|t.me,core.telegram.org,telegra.ph||telegram +ZM_INSTALLER_EOF +cat > '/usr/share/zm-redbtn/lists/svc_youtube.lst' << 'ZM_INSTALLER_EOF' +ggpht.com +googlevideo.com +jnn-pa.googleapis.com +returnyoutubedislikeapi.com +wide-youtube.l.google.com +youtu.be +youtube-nocookie.com +youtube-ui.l.google.com +youtube.com +youtubeembeddedplayer.googleapis.com +youtubei.googleapis.com +youtubekids.com +yt-video-upload.l.google.com +yt.be +yt3.googleusercontent.com +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 +discord-attachments-uploads-prd.storage.googleapis.com +discord.co +discord.com +discord.design +discord.dev +discord.gg +discord.gift +discord.gifts +discord.media +discord.new +discord.store +discord.tools +discordactivities.com +discordapp.com +discordapp.net +discordmerch.com +discordpartygames.com +discordsays.com +ZM_INSTALLER_EOF +cat > '/usr/share/zm-redbtn/lists/svc_meta.lst' << 'ZM_INSTALLER_EOF' +cdninstagram.com +circlecrewpinkcrowd.com +facebook.com +facebook.net +fb.com +fbcdn.net +fbsbx.com +ig.me +instagram.com +internalfb.com +meta.com +oculus.com +threads.com +threads.net +wa.me +whatsapp.biz +whatsapp.com +whatsapp.net +ZM_INSTALLER_EOF +cat > '/usr/share/zm-redbtn/lists/svc_twitter.lst' << 'ZM_INSTALLER_EOF' +ads-twitter.com +cms-twdigitalassets.com +periscope.tv +pscp.tv +t.co +tellapart.com +tweetdeck.com +twimg.com +twitpic.com +twitter.biz +twitter.com +twitter.jp +twittercommunity.com +twitterflightschool.com +twitterinc.com +twitteroauth.com +twitterstat.us +twtrdns.net +twttr.com +twttr.net +twvid.com +vine.co +x.com +ZM_INSTALLER_EOF +cat > '/usr/share/zm-redbtn/lists/own_github.lst' << 'ZM_INSTALLER_EOF' +api.github.com +avatars.githubusercontent.com +camo.githubusercontent.com +codeload.github.com +ghcr.io +gist.githubusercontent.com +github.com +github.dev +github.io +githubassets.com +githubusercontent.com +npm.pkg.github.com +objects.githubusercontent.com +pkg.github.com +raw.githubusercontent.com +release-assets.githubusercontent.com +uploads.github.com +user-images.githubusercontent.com +www.github.com +ZM_INSTALLER_EOF +# Списка geoblock.lst больше нет: у ИИ-сервисов нет списков туннеля (поля 3 и 4 в services.conf +# пусты), геоблок снимает DNS (zm-geodns), и файл, который ставили прежние версии, никто не читал. +rm -f '/usr/share/zm-redbtn/lists/geoblock.lst' +cat > '/usr/share/zm-redbtn/lists/discord.lst' << 'ZM_INSTALLER_EOF' +138.128.137.32/28 +138.128.140.240/28 +172.65.202.16/28 +34.0.129.176/28 +34.0.130.176/28 +34.0.130.64/28 +34.0.132.128/28 +34.0.134.48/28 +34.0.140.48/28 +34.0.141.96/28 +35.207.209.32/28 +35.212.102.48/28 +35.212.111.16/28 +35.212.111.32/28 +35.212.120.112/28 +35.212.12.144/28 +35.212.4.128/28 +35.212.88.0/28 +35.213.0.0/28 +35.213.0.176/28 +35.213.101.208/28 +35.213.10.16/28 +35.213.102.160/28 +35.213.102.224/28 +35.213.10.224/28 +35.213.102.32/28 +35.213.10.32/28 +35.213.105.144/28 +35.213.105.32/28 +35.213.106.176/28 +35.213.106.192/28 +35.213.106.224/28 +35.213.107.144/28 +35.213.109.144/28 +35.213.109.16/28 +35.213.110.32/28 +35.213.111.16/28 +35.213.11.16/28 +35.213.111.80/28 +35.213.115.16/28 +35.213.115.48/28 +35.213.115.96/28 +35.213.120.128/28 +35.213.120.32/28 +35.213.122.128/28 +35.213.122.208/28 +35.213.12.224/27 +35.213.122.96/28 +35.213.124.80/28 +35.213.125.32/28 +35.213.126.176/28 +35.213.127.208/28 +35.213.127.48/28 +35.213.128.176/28 +35.213.128.64/28 +35.213.129.0/28 +35.213.130.16/28 +35.213.130.208/28 +35.213.131.128/28 +35.213.13.16/28 +35.213.131.64/28 +35.213.132.48/28 +35.213.132.64/28 +35.213.133.176/28 +35.213.135.48/28 +35.213.136.192/28 +35.213.136.32/28 +35.213.137.48/28 +35.213.139.144/28 +35.213.139.64/28 +35.213.141.160/28 +35.213.14.208/28 +35.213.142.208/28 +35.213.142.224/27 +35.213.143.176/28 +35.213.143.32/28 +35.213.145.112/28 +35.213.145.144/28 +35.213.146.192/28 +35.213.147.176/28 +35.213.149.32/28 +35.213.149.96/27 +35.213.150.160/28 +35.213.150.32/28 +35.213.152.0/28 +35.213.152.144/28 +35.213.153.160/27 +35.213.157.0/28 +35.213.158.96/27 +35.213.160.80/28 +35.213.162.112/28 +35.213.162.48/28 +35.213.163.224/28 +35.213.163.80/28 +35.213.164.160/28 +35.213.164.224/28 +35.213.165.32/28 +35.213.165.96/28 +35.213.16.64/28 +35.213.167.160/27 +35.213.168.176/28 +35.213.168.64/28 +35.213.169.176/28 +35.213.170.0/28 +35.213.172.144/28 +35.213.17.224/28 +35.213.173.0/28 +35.213.173.128/28 +35.213.174.224/28 +35.213.175.176/28 +35.213.176.112/28 +35.213.176.48/28 +35.213.177.112/28 +35.213.180.0/28 +35.213.181.112/28 +35.213.181.32/28 +35.213.181.64/28 +35.213.182.208/28 +35.213.182.96/28 +35.213.183.176/28 +35.213.184.80/28 +35.213.185.240/28 +35.213.185.32/28 +35.213.186.64/28 +35.213.188.176/28 +35.213.188.192/28 +35.213.191.80/28 +35.213.194.64/28 +35.213.195.176/28 +35.213.196.32/28 +35.213.198.32/28 +35.213.199.192/28 +35.213.199.96/28 +35.213.202.112/28 +35.213.2.0/28 +35.213.204.32/28 +35.213.205.160/28 +35.213.210.224/28 +35.213.213.224/28 +35.213.214.16/28 +35.213.217.112/28 +35.213.217.192/28 +35.213.222.208/28 +35.213.223.176/28 +35.213.225.16/28 +35.213.227.224/28 +35.213.229.16/28 +35.213.231.112/28 +35.213.231.224/28 +35.213.23.160/28 +35.213.233.144/28 +35.213.233.64/28 +35.213.238.128/28 +35.213.246.112/28 +35.213.246.32/28 +35.213.247.224/28 +35.213.247.96/28 +35.213.25.160/28 +35.213.252.208/28 +35.213.252.32/28 +35.213.26.48/28 +35.213.27.240/28 +35.213.32.192/28 +35.213.32.32/28 +35.213.32.80/28 +35.213.33.64/28 +35.213.34.192/28 +35.213.37.80/28 +35.213.38.176/28 +35.213.39.240/28 +35.213.4.176/28 +35.213.42.224/27 +35.213.42.64/28 +35.213.43.64/28 +35.213.45.112/28 +35.213.45.128/28 +35.213.45.80/28 +35.213.46.128/28 +35.213.49.16/28 +35.213.50.192/28 +35.213.50.32/28 +35.213.51.208/28 +35.213.52.0/28 +35.213.52.112/28 +35.213.53.128/28 +35.213.53.48/28 +35.213.54.208/28 +35.213.54.64/28 +35.213.56.48/28 +35.213.56.64/28 +35.213.59.208/28 +35.213.59.96/28 +35.213.6.112/28 +35.213.61.48/28 +35.213.65.112/28 +35.213.65.160/28 +35.213.67.0/28 +35.213.67.176/28 +35.213.68.192/28 +35.213.68.224/27 +35.213.70.144/28 +35.213.7.160/28 +35.213.7.192/28 +35.213.72.160/28 +35.213.72.208/28 +35.213.73.240/28 +35.213.74.128/28 +35.213.78.16/28 +35.213.78.176/28 +35.213.78.224/28 +35.213.79.128/28 +35.213.80.0/28 +35.213.80.80/28 +35.213.8.160/28 +35.213.83.176/28 +35.213.83.240/28 +35.213.84.240/28 +35.213.85.144/28 +35.213.85.96/28 +35.213.88.144/28 +35.213.89.80/28 +35.213.90.144/28 +35.213.90.192/28 +35.213.90.48/28 +35.213.91.192/28 +35.213.92.176/28 +35.213.92.240/28 +35.213.92.64/28 +35.213.93.240/28 +35.213.94.64/28 +35.213.95.144/28 +35.213.96.80/28 +35.213.98.112/28 +35.213.98.128/28 +35.213.99.112/28 +35.214.137.128/28 +35.214.140.176/28 +35.214.142.112/28 +35.214.151.176/28 +35.214.163.16/28 +35.214.169.192/28 +35.214.171.16/28 +35.214.181.160/28 +35.214.194.208/28 +35.214.198.0/28 +35.214.205.144/28 +35.214.209.64/28 +35.214.213.16/28 +35.214.216.144/28 +35.214.225.32/28 +35.214.227.32/28 +35.214.245.16/28 +35.214.250.16/28 +35.215.108.96/28 +35.215.115.112/28 +35.215.126.32/28 +35.215.127.32/28 +35.215.128.16/28 +35.215.128.80/28 +35.215.134.224/28 +35.215.135.160/28 +35.215.135.16/28 +35.215.136.16/28 +35.215.137.224/28 +35.215.138.0/28 +35.215.138.176/28 +35.215.138.192/28 +35.215.139.128/28 +35.215.140.0/28 +35.215.140.48/28 +35.215.141.80/28 +35.215.142.0/28 +35.215.145.64/28 +35.215.149.176/28 +35.215.151.112/28 +35.215.152.144/28 +35.215.154.240/28 +35.215.156.160/28 +35.215.160.208/28 +35.215.161.112/28 +35.215.161.224/28 +35.215.163.96/28 +35.215.166.160/28 +35.215.166.240/28 +35.215.168.160/28 +35.215.170.0/28 +35.215.170.64/28 +35.215.171.48/28 +35.215.172.48/28 +35.215.173.192/28 +35.215.174.16/28 +35.215.174.208/28 +35.215.175.32/28 +35.215.178.16/28 +35.215.180.144/28 +35.215.182.176/28 +35.215.184.240/28 +35.215.185.64/28 +35.215.186.32/28 +35.215.186.96/28 +35.215.188.112/28 +35.215.188.192/28 +35.215.189.112/28 +35.215.190.48/28 +35.215.190.96/28 +35.215.192.208/28 +35.215.193.224/28 +35.215.193.80/28 +35.215.194.192/28 +35.215.195.96/28 +35.215.196.0/28 +35.215.196.48/28 +35.215.196.64/28 +35.215.198.224/28 +35.215.200.16/28 +35.215.202.128/28 +35.215.204.128/28 +35.215.205.16/28 +35.215.206.160/28 +35.215.206.32/28 +35.215.207.112/28 +35.215.208.208/28 +35.215.210.240/28 +35.215.211.0/28 +35.215.213.176/28 +35.215.215.32/28 +35.215.216.96/28 +35.215.217.16/28 +35.215.217.80/28 +35.215.218.48/28 +35.215.218.80/28 +35.215.221.160/28 +35.215.221.16/28 +35.215.222.224/28 +35.215.224.16/28 +35.215.225.224/28 +35.215.226.32/28 +35.215.227.80/28 +35.215.228.192/28 +35.215.229.64/28 +35.215.231.80/28 +35.215.232.16/28 +35.215.232.208/28 +35.215.233.48/28 +35.215.235.176/28 +35.215.235.240/28 +35.215.235.96/28 +35.215.238.16/28 +35.215.238.80/28 +35.215.240.160/28 +35.215.240.192/28 +35.215.241.208/28 +35.215.243.192/28 +35.215.244.240/28 +35.215.245.128/28 +35.215.245.32/28 +35.215.247.80/28 +35.215.248.160/28 +35.215.249.80/28 +35.215.250.128/28 +35.215.251.128/28 +35.215.251.224/28 +35.215.254.112/28 +35.215.255.80/28 +35.215.72.80/28 +35.215.73.64/28 +35.215.83.0/28 +5.200.14.240/28 +66.22.196.0/26 +66.22.196.128/27 +66.22.196.224/28 +66.22.196.64/27 +66.22.197.0/28 +66.22.197.128/27 +66.22.197.208/28 +66.22.197.32/27 +66.22.197.64/27 +66.22.197.96/28 +66.22.198.0/26 +66.22.198.128/26 +66.22.198.80/28 +66.22.198.96/28 +66.22.199.128/28 +66.22.199.16/28 +66.22.199.176/28 +66.22.199.192/26 +66.22.199.32/27 +66.22.199.64/27 +66.22.199.96/28 +66.22.200.0/28 +66.22.200.32/28 +66.22.200.64/27 +66.22.202.0/28 +66.22.202.32/28 +66.22.202.64/27 +66.22.202.96/28 +66.22.204.160/27 +66.22.204.16/28 +66.22.204.192/28 +66.22.204.64/28 +66.22.204.96/28 +66.22.206.0/27 +66.22.206.160/27 +66.22.206.32/28 +66.22.206.96/28 +66.22.208.0/27 +66.22.208.32/28 +66.22.210.0/27 +66.22.210.32/28 +66.22.216.0/25 +66.22.216.128/26 +66.22.216.192/28 +66.22.217.0/25 +66.22.217.128/26 +66.22.217.192/28 +66.22.218.0/26 +66.22.218.64/27 +66.22.218.96/28 +66.22.219.0/26 +66.22.219.64/27 +66.22.219.96/28 +66.22.220.0/28 +66.22.220.144/28 +66.22.220.160/28 +66.22.220.48/28 +66.22.220.80/28 +66.22.221.0/28 +66.22.221.128/27 +66.22.221.160/28 +66.22.221.48/28 +66.22.221.64/27 +66.22.221.96/28 +66.22.224.0/26 +66.22.225.0/26 +66.22.226.0/27 +66.22.226.80/28 +66.22.226.96/27 +66.22.227.0/27 +66.22.231.0/25 +66.22.231.128/26 +66.22.231.192/27 +66.22.232.0/28 +66.22.232.128/28 +66.22.239.0/28 +66.22.239.128/28 +66.22.240.0/28 +66.22.240.128/28 +66.22.245.0/26 +66.22.245.128/26 +66.22.246.0/26 +66.22.246.128/26 +66.22.246.192/27 +66.22.246.224/28 +66.22.246.64/27 +66.22.247.0/27 +66.22.247.128/27 +66.22.248.0/25 +66.22.248.128/26 +66.22.248.192/27 +ZM_INSTALLER_EOF +cat > '/usr/share/zm-redbtn/lists/meta.lst' << 'ZM_INSTALLER_EOF' +31.13.24.0/21 +31.13.64.0/18 +45.64.40.0/22 +57.141.0.0/24 +57.141.2.0/23 +57.141.4.0/23 +57.141.6.0/24 +57.141.8.0/24 +57.141.10.0/24 +57.141.12.0/23 +57.141.14.0/24 +57.141.16.0/22 +57.141.20.0/24 +57.141.22.0/24 +57.141.24.0/24 +57.144.0.0/14 +66.220.144.0/20 +69.63.176.0/20 +69.171.224.0/19 +74.119.76.0/22 +102.132.96.0/20 +103.4.96.0/22 +129.134.0.0/17 +157.240.0.0/17 +157.240.192.0/18 +163.70.128.0/17 +163.77.132.0/23 +163.77.136.0/23 +173.252.64.0/18 +179.60.192.0/22 +185.60.216.0/22 +185.89.216.0/22 +204.15.20.0/22 +ZM_INSTALLER_EOF +cat > '/usr/share/zm-redbtn/lists/whatsapp.lst' << 'ZM_INSTALLER_EOF' +31.13.24.0/21 +31.13.64.0/18 +45.64.40.0/22 +57.141.0.0/24 +57.141.2.0/23 +57.141.4.0/23 +57.141.6.0/24 +57.141.8.0/24 +57.141.10.0/24 +57.141.12.0/23 +57.141.14.0/24 +57.141.16.0/22 +57.141.20.0/24 +57.141.22.0/24 +57.141.24.0/24 +57.144.0.0/14 +66.220.144.0/20 +69.63.176.0/20 +69.171.224.0/19 +74.119.76.0/22 +102.132.96.0/20 +103.4.96.0/22 +129.134.0.0/17 +157.240.0.0/17 +157.240.192.0/18 +163.70.128.0/17 +163.77.132.0/23 +163.77.136.0/23 +173.252.64.0/18 +179.60.192.0/22 +185.60.216.0/22 +185.89.216.0/22 +204.15.20.0/22 +ZM_INSTALLER_EOF +cat > '/usr/share/zm-redbtn/lists/twitter_x.lst' << 'ZM_INSTALLER_EOF' +64.63.0.0/18 +103.252.112.0/22 +104.244.41.0/24 +104.244.42.0/24 +104.244.44.0/22 +184.105.99.0/24 +188.64.224.0/21 +192.133.76.0/22 +199.16.156.0/22 +199.59.148.0/22 +199.96.56.0/23 +202.160.128.0/22 +208.91.196.0/23 +ZM_INSTALLER_EOF +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:5359 и файл 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=5359 + +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" '$1 ~ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/ { 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() { + # Резолвера нет (пакет удалили, выбор сняли) — снять и файл dnsmasq: иначе домены списка + # шли бы на порт, где никто не слушает, и не разрешались вовсе. + [ -s "$RES" ] && [ -s "$DOM" ] && [ -x /usr/sbin/https-dns-proxy ] || { dnsmasq_off; 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() { + # При restart файл не трогаем: start сразу запишет его снова, и dnsmasq перезапустился бы + # дважды — два провала DNS всей сети на каждое «Подобрать DNS заново». + [ "$action" = restart ] && return 0 + 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 cat > '/www/luci-static/resources/view/zapret-manager/hosts.js' << 'ZM_INSTALLER_EOF' @@ -7760,6 +12421,103 @@ html.zm-theme-dark .zm-config-editor { border-color: rgba(255,255,255,.14); } } .cbi-page-actions { display: none !important; } +.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-st-tunnels { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin: 10px 0 14px; } +.zm-st-tunnel { border: 1px solid rgba(127,127,127,.25); border-radius: 12px; padding: 10px 14px; } +.zm-st-tunnel-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; } +.zm-st-tunnel-name { font-weight: 600; } ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css' @@ -9595,6 +14353,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 @@ -10082,7 +14844,9 @@ var ICONS = { lock: '', arrow: '', alert: '', - rocket: '', + route: '', + wand: '', + rocket: '', telegram: '' }; @@ -10165,6 +14929,8 @@ function toggleTheme() { var ROUTES = [ { id: 'dashboard', title: 'Дашборд', sub: 'Состояние всех компонентов', icon: 'dashboard', group: 'Обзор' }, + { 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' }, @@ -10808,8 +15574,8 @@ body.zmw-locked .zmw-shell { filter: blur(6px); pointer-events: none; } .zmw-nav-label { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .zmw-nav-item.zmw-active { background: var(--grad-soft); color: var(--text); } .zmw-nav-item.zmw-active::before { - content: ""; position: absolute; left: -14px; top: 10px; bottom: 10px; width: 4px; - border-radius: 0 4px 4px 0; background: var(--grad); + content: ""; position: absolute; left: -9px; top: 10px; bottom: 10px; width: 4px; + border-radius: 4px; background: var(--grad); } .zmw-nav-item.zmw-active .zmw-nav-ico { background: var(--grad); color: #fff; border-color: transparent; @@ -10886,10 +15652,11 @@ html[data-theme="light"] .zmw-top { background: linear-gradient(to bottom, rgba( .zmw-link-kvn { color: #fff; border-color: transparent; background: var(--grad); background-size: 160% 100%; + background-origin: border-box; background-repeat: no-repeat; box-shadow: 0 8px 20px -10px rgba(99,102,241,.9), inset 0 1px 0 rgba(255,255,255,.22); transition: background-position .35s, transform .1s, box-shadow .2s; } -.zmw-link-kvn:hover { color: #fff; border-color: transparent; background: var(--grad); background-size: 160% 100%; background-position: 100% 0; box-shadow: 0 10px 24px -10px rgba(99,102,241,1), inset 0 1px 0 rgba(255,255,255,.22); } +.zmw-link-kvn:hover { color: #fff; border-color: transparent; background: var(--grad); background-size: 160% 100%; background-origin: border-box; background-repeat: no-repeat; background-position: 100% 0; box-shadow: 0 10px 24px -10px rgba(99,102,241,1), inset 0 1px 0 rgba(255,255,255,.22); } .zmw-link-tg { color: #229ed9; border-color: rgba(34,158,217,.35); background: rgba(34,158,217,.10); } .zmw-link-tg:hover { color: #229ed9; border-color: rgba(34,158,217,.6); background: rgba(34,158,217,.16); } html[data-theme="dark"] .zmw-link-tg, html[data-theme="dark"] .zmw-link-tg:hover { color: #5cc1f0; } @@ -11286,6 +16053,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 { diff --git a/docs/autobypass.md b/docs/autobypass.md new file mode 100644 index 0000000..97ecba6 --- /dev/null +++ b/docs/autobypass.md @@ -0,0 +1,748 @@ +# Как работает автообход + +Автообход — одна кнопка в Zapret Manager. Роутер сам проверяет популярные сервисы и каждому +подбирает самый дешёвый способ, при котором сервис открывается: напрямую, через стратегию +zapret, через туннель WARP или через свой DNS. Ниже — что именно происходит после нажатия, как +перебираются стратегии, как выбираются колонии WARP и как потом ходит трафик. + +Коротко о словах, которые встретятся дальше: + +| Слово | Что это | +|---|---| +| **zapret** | обход DPI провайдера: первые пакеты соединения подправляются так, чтобы фильтр их не узнал | +| **стратегия** | набор настроек zapret. Здесь есть наборы v (v1–v10), Flowseal, Yv (для YouTube), Dv (для голоса Discord) | +| **WARP** | бесплатный туннель Cloudflare. Весь трафик сервиса уходит в него и выходит у Cloudflare | +| **колония** | узел Cloudflare, через который выходит туннель; обозначается кодом аэропорта: HEL — Хельсинки, DME — Москва | +| **Steer** | движок, который решает, какой сервис идёт в WARP, а какой — обычным путём | +| **fakeip** | подставной адрес, который Steer выдаёт в ответ на DNS-запрос, чтобы потом узнать соединение этого сервиса | +| **DoH** | DNS поверх HTTPS: запрос имени, который провайдер не видит и не подменяет | +| **stgws** | мост Telegram на роутере: прячет протокол Telegram в веб-сокет или ведёт его через WARP | + +--- + +## 1. Что делает кнопка + +``` + ┌──────────────────────────────┐ + │ нажата кнопка «Автообход» │ + └──────────────┬───────────────┘ + │ + ┌─────────────────────────┼─────────────────────────┐ + ▼ ▼ ▼ + ┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ + │ zapret не стоит │ │ zapret выключен │ │ Steer выключен или │ + │ → ставится │ │ → включается │ │ сервис снят на его │ + │ │ │ и запускается │ │ странице → запрет │ + │ │ │ │ │ снимается │ + └──────────────────┘ └──────────────────────┘ └──────────────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ подбор (раздел 2), затем │ + │ фоновое сравнение (разд. 4)│ + └──────────────────────────────┘ +``` + +Нажатая кнопка — согласие на всё, что автообход умеет. То, что было выключено руками, снова +участвует в подборе: иначе подбор находил бы, что сервис открывается через WARP, и не +включал бы его. + +Есть два режима: + +| Режим | Какие стратегии перебираются | Сколько идёт | +|---|---|---| +| **Быстрый** | набор v (10 стратегий) | обычно 5–15 минут | +| **Полный** | Flowseal и v | дольше, до получаса | + +Полный подбор перебирает стратегии всегда, даже если при текущей настройке всё открывается: +его выбирают, чтобы найти вариант лучше. Быстрый перебирает, только если какой-то сервис не +открылся. + +Пока подбор идёт, сеть работает на прежней настройке: стратегии проверяются в стороне и +клиентов не касаются (раздел 3). Автообход нельзя запустить, пока идёт тест стратегий +Zapret Manager или операция на странице Steer. + +--- + +## 2. Порядок подбора + +``` + ┌───────────────────────────────────────────────────────────────────────────┐ + │ ① Проверка при текущей настройке │ + │ YouTube, Discord, Instagram, WhatsApp, X, GitHub │ + └─────────────────────────────────┬─────────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ ② Подбор стратегии zapret │ + │ быстрый — если что-то не открылось; полный — всегда │ + │ победитель применяется, сервисы перепроверяются, хуже — откат │ + └─────────────────────────────────┬─────────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ ③ YouTube отдельно — если всё ещё не открывается: свой набор Yv │ + └─────────────────────────────────┬─────────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ ④ «Напрямую или через zapret» — открывшиеся сервисы проверяются в обход │ + │ zapret, чтобы знать, нужен ли он им вообще │ + └─────────────────────────────────┬─────────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ ⑤ WARP — для того, что не открылось, если стоит Steer │ + │ три туннеля в разных колониях (раздел 5), сервис проверяется через │ + │ туннель и, если открылся, добавляется в Steer │ + └─────────────────────────────────┬─────────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ ⑥ Туннели для моста Telegram — если шаг ⑤ не понадобился, туннели всё │ + │ равно проверяются: мост ходит через WARP сам │ + └─────────────────────────────────┬─────────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ ⑦ ИИ-сервисы — не открываются → DoH только для их доменов (раздел 7) │ + └─────────────────────────────────┬─────────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ ⑧ Telegram — напрямую, через мост stgws или через WARP (раздел 8) │ + └─────────────────────────────────┬─────────────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────────────────┐ + │ «Готово, обход подобран» │ + │ ⑨ в фоне — сравнение близких стратегий (раздел 4), если такие нашлись │ + └───────────────────────────────────────────────────────────────────────────┘ +``` + +### Лестница стоимости + +Каждому сервису достаётся первая ступень снизу, на которой он открывается. Чем выше ступень, +тем больше вмешательства в трафик. + +``` + дороже ▲ + │ ┌──────────────────────┐ + │ │ туннель WARP │ весь трафик сервиса уходит в туннель + │ ├──────────────────────┤ + │ │ стратегия zapret │ подправляются первые пакеты соединения + │ ├──────────────────────┤ + │ │ DoH для доменов │ меняется только ответ DNS (ИИ-сервисы) + │ ├──────────────────────┤ + │ │ напрямую │ ничего не делается + дешевле └──────────────────────┘ +``` + +У Telegram лестница своя, и WARP на ней не наверху. Веб-сокет через домены-посредники за +Cloudflare дороже туннеля: на каждое соединение — рукопожатие TLS с чужим доменом, отказы 503, +лишний слой TLS поверх всего потока, и не у каждого домена есть все дата-центры. Прямой путь к +настоящему дата-центру через WARP — это просто туннель. Поэтому мост пробует его раньше +веб-сокета (раздел 8). + +``` + ┌──────────┐ + │ сервис │ + └────┬─────┘ + ▼ + ┌──────────────────────────┐ + │ открывается как есть? │ + └──────┬────────────┬──────┘ + да │ │ нет + ▼ ▼ + ┌────────────────────────┐ ┌──────────────────────────┐ + │ и в обход zapret тоже? │ │ подбор zapret помог? │ + └──┬──────────────┬──────┘ └──────┬────────────┬──────┘ + да │ │ нет да │ │ нет + ▼ ▼ ▼ ▼ + ┌───────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐ + │ напрямую │ │ через zapret│ │ через zapret│ │ Steer стоит и через │ + └───────────┘ └─────────────┘ └─────────────┘ │ WARP открывается? │ + └──────┬───────────┬───┘ + да │ │ нет + ▼ ▼ + ┌───────────┐ ┌──────────────┐ + │ через WARP│ │не открывается│ + └───────────┘ └──────────────┘ +``` + +### Когда сервис считается открытым + +У каждого сервиса два списка адресов: + +| Список | Что должно открыться | Пример | +|---|---|---| +| **обязательные** | все до одного | у YouTube — сайт, API и узел видео; у Discord — сайт, шлюз и узел обновлений | +| **дополнительные** | не меньше 60 % | картинки, CDN, статика | + +Одного главного адреса мало: страница YouTube открывается, а видео — нет. Адрес считается +открытым, если сервер ответил каким-то кодом, кроме «451, недоступно по закону», и в ответе +нет фраз геоблока вроде «not available in your country». Ответ, оборвавшийся на +16–20 КБ, открытым не считается: так выглядит блокировка «на середине страницы». + +Все проверки разрешают имена через свой DoH (1.1.1.1, 8.8.8.8 или 77.88.8.8 — первый, что +ответил), а не через DNS роутера: провайдер может подменять ответы. Исключение — ИИ-сервисы: +их проверяют именно DNS роутера, потому что лечит их как раз он (раздел 7). + +--- + +## 3. Перебор стратегий + +### Проверка в стороне + +Обычный тестер Zapret Manager переписывает настройку zapret каждой новой стратегией и +перезапускает обход. Все минуты теста клиенты сети живут то на одной случайной стратегии, то +на другой. Автообход так не делает: + +``` + ┌──────────────── ОБЫЧНЫЙ ТРАФИК СЕТИ ─────────────────┐ + │ телефоны, ноутбуки │ + │ │ │ + │ ▼ │ + │ zapret на ПРЕЖНЕЙ стратегии ──► интернет │ ← не меняется до конца подбора + └──────────────────────────────────────────────────────┘ + + ┌──────────────── ПРОВЕРКА СТРАТЕГИИ ──────────────────┐ + │ запросы самого роутера с портов 20000–20999 │ + │ │ │ + │ ▼ │ + │ свой экземпляр zapret со СВОЕЙ стратегией │ ← только эти запросы + │ на своей очереди пакетов │ + │ │ │ + │ ▼ │ + │ пакеты помечены — общий zapret их пропускает │ + │ │ │ + │ ▼ │ + │ интернет │ + └──────────────────────────────────────────────────────┘ +``` + +Проверочный трафик отличается от клиентского портами отправителя. Он попадает только в свой +экземпляр zapret, а общий zapret его не трогает. Настройка zapret роутера меняется один раз, +в конце, и только если победитель действительно лучше. + +### Что перебирается и на чём + +``` + ┌───────────────────┐ ┌──────────────────────────────────────────────┐ + │ стратегии │ │ цели проверки │ + │ │ │ │ + │ быстрый: v1–v10 │ │ ▸ общий список Zapret Manager: госуслуги, │ + │ │ × │ налоговая, rutube, instagram, discord, x, │ + │ полный: Flowseal │ │ rutracker, openwrt и др. + внешний набор │ + │ + v1–v10 │ │ сайтов с проверкой «16–20 КБ» │ + │ │ │ (на момент написания — 54 адреса) │ + │ │ │ ▸ адреса сервисов, которые не открылись │ + │ │ │ на шаге ① (считаются отдельно) │ + └───────────────────┘ └──────────────────────────────────────────────┘ +``` + +Число стратегий Flowseal и часть целей приходят из внешних списков, поэтому они меняются со +временем. Первым идёт **контроль**: те же цели без zapret. С ним сравнивается каждая +стратегия. + +Для каждой стратегии: + +``` + запустить свой zapret со стратегией ──► не запустился? ──► «не запустилась», дальше + │ + ▼ + все цели параллельно (по 8 разом; на роутере меньше 256 МБ — по 4) + │ + ▼ + основной счёт: «открылось N из 54» + хвост: сколько адресов неоткрывшихся сервисов открылось +``` + +Цель считается открытой так же, как при проверке сервисов: сервер ответил каким-то кодом, +кроме «451, недоступно по закону». Адреса из внешнего набора приходят голыми именами, без +`https://`, и проверяются по HTTPS, как остальные цели. + +### Как выбирается победитель + +``` + 1. сортировка: по основному счёту, при равном — по хвосту + │ + ▼ + 2. лучшая лучше контроля? ├── основной больше, чем без zapret ──► да + ├── основной равен, но хвост больше ──► да + └── иначе ──► «Ни одна стратегия не открыла больше, + чем без Zapret — настройку не меняем» + │ + ▼ + 3. применяется к zapret роутера + │ + ▼ + 4. все сервисы проверяются заново + открылось меньше, чем до подбора? ──► проверка ещё раз (одна проба шумит) + всё равно меньше ──► откат к прежней настройке + │ + ▼ + 5. «Стратегия … применена» +``` + +Если подбор остановили на середине, неполный победитель не применяется. Если остановили во +время перепроверки, прежняя настройка возвращается. + +YouTube подбирается отдельно, если после общего подбора он всё ещё не открывается. Для него +перебирается свой набор Yv на адресах YouTube и видеоузлов. Применяется он так же — только +если лучше контроля, с перепроверкой и откатом. + +--- + +## 4. Фоновое сравнение близких стратегий + +Короткие запросы основного прохода плохо различают стратегии: разница в пару целей из +полусотни — это шум. При этом провайдер часто рвёт соединение к CDN и хостингам уже после +того, как через него ушло 16–20 КБ. Страница успела «открыться», а дальше замерла. Поэтому +после «Готово» близкие к победителю стратегии ещё раз сравниваются в фоне другим замером. + +``` + результаты основного прохода + │ + ▼ + ┌───────────────────────────────────────────────────────────────┐ + │ близкие: основной счёт не ниже 90 % от счёта лучшей │ + │ первой — применённая, за ней открывшие больше адресов │ + │ неоткрывшихся сервисов; всего не больше 6 │ + └───────────────────────────┬───────────────────────────────────┘ + одна — сравнивать не с кем, фона нет + │ больше одной + ▼ + ┌───────────────────────────────────────────────────────────────┐ + │ у каждой — дополнительный счёт по узлам CDN (метод ниже) │ + │ ИТОГ = основной + дополнительный │ + └───────────────────────────┬───────────────────────────────────┘ + ▼ + ┌───────────────────────────────────────────────────────────────┐ + │ победитель по итогу — другая стратегия, итог СТРОГО больше? │ + │ нет → «Оставляем …» │ + │ да → настройку за это время не меняли руками? │ + │ → применить, проверить сервисы │ + │ → открывается меньше? вернуть прежнюю │ + └───────────────────────────────────────────────────────────────┘ +``` + +### Дополнительный замер — как у dpi-detector + +Способ взят у проекта dpi-detector (Runnin4ik), и узлы — из его списка: адреса Hetzner, +Cloudflare, CDN77, Akamai, AWS, Fastly, Oracle, OVH и других провайдеров. На момент +написания их 110. + +``` + роутер ──────── одно соединение ──────────► узел CDN + │ + │ запрос 1 HEAD + заголовок 4 КБ случайных символов ──► ответ + │ запрос 2 HEAD + ещё 4 КБ ──► ответ + │ … + │ запрос 10 HEAD + ещё 4 КБ (всего ≈ 40 КБ от нас) ──► ответ + │ + ▼ + все 10 ответов → узел «выдержал» первый обрыв → узел не выдержал, дальше не ждём +``` + +Меряется то, что режет DPI, — объём, ушедший **от нас** по одному соединению. У узлов, для +которых в списке указано имя сайта, оно подставляется в TLS; у остальных — example.com, как +у dpi-detector. Сертификат не проверяется: важно, дожило ли соединение, а не чей это узел. На +один запрос даётся 5 секунд. + +Пример с тестового роутера, все 110 узлов, применена v4: + +| Стратегия | Основной | Дополнительный | Итог | +|---|---|---|---| +| **v4** (применена) | 40 | 97 из 110 | **137** | +| v7 | 41 | 96 из 110 | **137** | +| v1 | 39 | 96 из 110 | 135 | +| general (ALT13) | 40 | 85 из 110 | 125 | +| general (EXP) | 38 | 85 из 110 | 123 | +| v8 | 40 | 81 из 110 | 121 | + +Здесь v7 выиграла основной проход на одну цель, а v4 отыграла её на дополнительном замере, и +итог вышел равным. При равном итоге остаётся применённая стратегия — v4. Замер одной +стратегии по 110 узлам занимает на роутере со 128 МБ несколько минут, а обход всё это время +уже работает. + +На странице автообхода, пока идёт сравнение: «Лучшая стратегия определится позже — идёт +сравнение близких». Если стратегия сменилась: «Лучшая стратегия: …». Новый подбор или +переустановка Zapret Manager фоновое сравнение прерывают. Настройка при этом не страдает: +меняется она только в самом конце. + +--- + +## 5. WARP: три туннеля и выбор колоний + +WARP ставится со Steer. Туннелей три: у каждого свои ключи и своя точка входа, и все три в +разных колониях, если это возможно. Трафик идёт через самый быстрый живой. Упал один — +Steer сам переключается на другой. Упали все — трафик сервиса идёт напрямую, без туннеля. + +### Ключи + +``` + Cloudflare API ──► ключи выданы? ──► да ──► туннель готов + │ нет + ▼ + запасные генераторы (как на странице Mixomo) +``` + +Туннель — AmneziaWG, то есть WireGuard с маскировкой рукопожатия. + +### Разведка точки входа + +Годность точки решает только **поднятый туннель**. Проверка по обычному HTTP к Cloudflare +меряет не то: она идёт по TCP, а туннель — по UDP, и провайдер режет их по-разному. А +колония в ответе — это колония *входа*, а не выхода. Поэтому каждая точка ставится на живой +туннель и проверяется изнутри. + +``` + ┌──────────────────────────────────────────────────────────────────────────┐ + │ кандидаты: 14 подсетей Cloudflare │ + │ 188.114.96–99.x · 162.159.192/193/195.x · 8.34.146.x · 8.39.x · … │ + │ сначала «якоря» .1 каждой подсети, затем 10 случайных адресов │ + │ (всего 24) │ + └──────────────────────────────────┬───────────────────────────────────────┘ + ▼ + ┌──────────────────────────────────────────────────────────────────────────┐ + │ порты: 2408 · 500 · 4500 · 987 (вне игрового фильтра zapret) │ + │ пробуются на первых трёх якорях, нужно два работающих: │ + │ разведка идёт по первому, второй — запасной; │ + │ ни один не ответил — «провайдер не выпускает ни один порт» │ + └──────────────────────────────────┬───────────────────────────────────────┘ + ▼ + для каждого кандидата: + ┌──────────────────────────────────────────────────────────────────────────┐ + │ а) поставить точку и дождаться рукопожатия (до 8 с) │ + │ б) 3 пинга через туннель → потери и круг │ + │ в) колония ИЗНУТРИ туннеля — по заголовку ответа Cloudflare │ + │ г) HTTPS через туннель обязан проходить │ + └──────────────────────────────────┬───────────────────────────────────────┘ + ▼ + нашлись 3 точки вне колоний соседних туннелей — разведка останавливается, + туннель берёт лучшую из них по потерям и кругу +``` + +Про пункт г. На части сетей через туннель проходят только мелкие пакеты и порт 80, а TLS +молчит. Такая точка «работает» по всем замерам, но сайты через неё не открываются. + +### Какую точку взять + +Кандидаты раскладываются по корзинам. Берётся первая непустая, в ней — лучшая по потерям, +а при равных потерях — по кругу. + +``` + ┌───────────────────────────────┐ + 1 │ новая колония, HTTPS есть │ ← лучший вариант + ├───────────────────────────────┤ + 2 │ та же колония, что у соседнего│ ← другой адрес в той же колонии лучше никакого + │ туннеля, но другой адрес │ + ├───────────────────────────────┤ + 3 │ колония не узналась │ + ├───────────────────────────────┤ + 4 │ российская колония │ ← выход внутри страны, за ТСПУ + │ DME SVX LED KJA REN OVB KZN │ + │ AER VVO │ + ├───────────────────────────────┤ + 5 │ HTTPS через туннель не идёт │ ← последний запас + └───────────────────────────────┘ +``` + +Российская колония выводит трафик из Cloudflare внутри страны, и дальше он идёт через те же +ТСПУ, что и без туннеля. Поэтому через неё не снимается ни геоблок, ни остальные блокировки: +туннель в DME работает, но пользы от него почти нет. Если часть туннелей всё же оказалась в +российских колониях, журнал об этом предупреждает. + +### Живые туннели + +Туннель, у которого рукопожатие было меньше трёх минут назад, при следующем подборе или +«Применить» не пересканируется: это рвало бы соединения ради того же результата. Исключение — +**российская колония**. Адреса WARP общие для многих колоний (anycast): на один и тот же +адрес Cloudflare может со временем начать отвечать из другой колонии. На тестовом роутере +туннель, выбранный в Хельсинки, через некоторое время стал выходить в Москве (DME). Поэтому +колония живого туннеля каждый раз узнаётся заново, и российская разводится с нуля. +Принудительно развести все три заново можно кнопкой «Сменить точки входа». + +### Запасной порт + +Выбранная точка ставится на туннель ещё раз, уже насовсем. Если на этот раз рукопожатия нет, +тот же адрес пробуется на втором найденном порту. Колония от этого не меняется: она зависит +от адреса, а не от порта. Не ответил и второй — туннель в этот раз не поднимается. + +### Какой туннель первый + +Туннели записываются в список лучшим первым: сначала по корзине, в которой нашлась точка +(российская и «HTTPS не идёт» — в конце), потом по потерям и кругу. Первый живой из списка +берут все, кому нужен один туннель: через него пробуются сервисы и качаются списки, мост +Telegram идёт через него, пока тот отвечает. Steer сам меряет задержку и идёт через самый +быстрый, а порядок решает, когда разница меньше 50 мс. + +Если в этот раз ни один туннель не разведывался заново, порядок остаётся прежним. Иначе +каждое «Применить» переставляло бы туннели по случайному дрожанию пинга, и Steer +перезапускался бы с обрывом соединений. Если хоть один туннель разведан заново, остальные +перемеряются тремя пингами изнутри, и список строится с нуля. + +--- + +## 6. Списки сервисов + +Чтобы Steer знал, какой трафик вести в WARP, у каждого сервиса есть список доменов и подсетей. + +``` + каталог splify2-lists ──► ссылка на набор .srs издателя itdoginfo + (конкретная версия, одинаковая у всех роутеров) + │ + ▼ + Steer разбирает набор на: + ┌─────────────────┼──────────────────────────┐ + ▼ ▼ ▼ + домены подсети подсети с сужением + │ │ по протоколу и портам + ▼ ▼ │ + fakeip в ответ DNS по адресу назначения ▼ + голос Discord: в туннель идёт + только он, а не весь TCP к + подсетям Cloudflare +``` + +| Сервис | Набор | +|---|---| +| YouTube | youtube | +| Discord | discord (с сужением голоса) | +| Instagram, WhatsApp | meta (один набор на оба) | +| X | twitter | +| Telegram | telegram | +| GitHub, ИИ-сервисы | своих наборов для туннеля нет | + +Список скачивается сначала напрямую, потом через уже поднятый туннель: GitHub у многих +закрыт. Если не скачался вовсе, берётся список, встроенный в Zapret Manager. Он хуже +свежего, но лучше никакого. + +--- + +## 7. ИИ-сервисы + +ChatGPT, Claude, Gemini и похожие закрыты не провайдером, а самим сервисом: он смотрит, +откуда пришли. Ни стратегия zapret, ни WARP в российской колонии (выход там внутри страны) +это не снимают. Снимает +**DNS**, который на имя сервиса отдаёт адрес своего прокси за границей. + +``` + запрос имени от устройства + │ + ▼ + ┌────────────────────────┐ + │ DNS роутера (dnsmasq) │ + └───┬────────────────┬───┘ + │ домен из списка│ всё остальное + │ ИИ-сервисов │ + ▼ ▼ + ┌────────────────┐ ┌─────────────────────┐ + │ свой DoH-клиент│ │ как раньше, ничего │ + │ на порту 5359 │ │ не меняется │ + │ → выбранный │ └─────────────────────┘ + │ гео-резолвер│ + └────────────────┘ +``` + +- **Только для доменов списка.** Список публикует dns.malw.link (на момент написания — около + 17 тысяч доменов после свёртки поддоменов). Остальной DNS роутера не меняется. +- **Выбор резолвера.** Гео-резолверы пула (на момент написания их 12) проверяются + параллельно, по 8 разом (на роутере меньше 256 МБ — по 4), настоящим запросом к + chatgpt.com. Берётся самый быстрый из тех, у кого ответил выданный прокси. +- **Свой экземпляр.** DoH-клиент для ИИ-доменов — отдельная служба. Чужую настройку + https-dns-proxy (страница DoH, свои резолверы) она не трогает. Если пакет ставил сам + автообход, а своей настройки до этого не было, служба пакета выключается: иначе она забрала + бы весь DNS роутера. +- **Сторож раз в 30 минут.** Проверяет, открываются ли ИИ-сервисы. Перестали — резолвер + подбирается заново. Если годного не нашлось, резолвер снимается, а сторож пробует снова + раз в два часа. Сторож идёт своей задачей: журнал последнего автообхода он не трогает, и + страница не показывает его как идущий автообход. Автообход, запущенный, пока сторож + подбирает резолвер, сначала ждёт, когда тот закончит. +- **Выключение.** Кнопка «Выключить» снимает службу, файл dnsmasq и сторожа. Если удалить + пакет https-dns-proxy или выключить службу zm-geodns в автозапуске, сторож при следующей + проверке снимет себя сам. +- **Удаление панели.** DNS для ИИ-сервисов снимается целиком, вместе со службой: без сторожа + умерший резолвер некому было бы заменить. Steer, туннели WARP и мост Telegram остаются. + +--- + +## 8. Telegram и мост stgws + +### Когда нужен мост + +Открытый сайт ещё не значит, что работает приложение: оно ходит к дата-центрам (ДЦ) Telegram +по своему протоколу MTProto, и режут именно его. Поэтому автообход шлёт пяти ДЦ настоящий +первый запрос протокола и ждёт ответа 5 секунд. + +``` + Telegram отмечен на странице Steer ──► через WARP + │ нет + ▼ + мост уже стоит и работает ──────────► через мост + │ нет + ▼ + сайт и все 5 ДЦ ответили ──────────► напрямую + │ нет + ▼ + ставится мост ──────────────────────► через мост +``` + +### Как мост выбирает путь + +Соединения устройств к адресам ДЦ Telegram роутер перехватывает и отдаёт мосту. Для каждого +соединения мост идёт по цепочке **от дешёвого к дорогому**. Дешевле всего — напрямую к +дата-центру: сначала без туннеля, потом через WARP. Веб-сокет через Cloudflare дороже любого +из них, поэтому он идёт после: + +``` + ┌──────────────────────────────┐ + │ перехвачено — это MTProto? │ + └──────┬────────────────┬──────┘ + нет │ │ да + ▼ ▼ +┌────────────────────┐ ┌──────────────────────────────┐ +│ насквозь как есть │ │ ① напрямую к ДЦ без WARP │ +│ (через WARP, если │ │ если по замеру ДЦ открыт │ +│ он есть) │ └──────┬────────────────┬──────┘ +└────────────────────┘ ответил│ │ молчит / по замеру закрыт + ▼ ▼ + ┌──────────┐ ┌──────────────────────────────┐ + │ сессия │ │ ② напрямую к ДЦ через WARP │ + └──────────┘ └──────┬────────────────┬──────┘ + ответил│ │ молчит / нет WARP + ▼ ▼ + ┌──────────┐ ┌─────────────────────────┐ + │ сессия │ │ ③ веб-сокет через WARP │ + └──────────┘ └──────┬───────────┬──────┘ + поднялся│ │ нет / нет WARP + ▼ ▼ + ┌──────────┐ ┌────────────────────┐ + │ сессия │ │ ③ веб-сокет без │ + └──────────┘ │ WARP, мимо zapret │ + └──┬──────────┬──────┘ + поднялся│ │ ни один путь + ▼ ▼ + ┌──────────┐ ┌──────────────────┐ + │ сессия │ │ ④ насквозь к ДЦ │ + └──────────┘ │ как есть │ + └──────────────────┘ +``` + +- **«ДЦ ответил»** — прямые пути проверяются боем. Соединение по TCP ещё ничего не значит: + при блокировке по адресу TCP обычно устанавливается, а дальше тишина. Путь живой, только + если ДЦ заговорил в течение 4 секунд. +- **Молчание не ломает следующий шаг.** Пока ДЦ молчит, клиент успевает что-то отправить. + Следующий путь получает начало разговора вместе с этими байтами, и для ДЦ поток начинается + с начала. +- **Веб-сокет.** ДЦ2 и ДЦ4 идут на веб-сокет самого Telegram (web.telegram.org). ДЦ1, 3, 5 + и отдельный ДЦ203, где тоже лежат файлы, — на домены-посредники за Cloudflare, как у + tg-ws-proxy (Flowseal). На каждое соединение пробуется не больше трёх доменов, по две + точки у каждого. +- **Запас.** Мост держит до 8 поднятых веб-сокетов наготове, по 2 на ДЦ, каждый не старше + 40 секунд. Клиент получает соединение сразу, без рукопожатия. Запас пополняется только + минуту после последнего обращения клиента. +- **Мимо zapret.** Соединения моста наверх идут в обход zapret роутера. Стратегия, подобранная + под сайты, может портить TLS к Cloudflare: на тестовом роутере с одной из стратегий TCP к + доменам-посредникам поднимался, а на TLS не приходило ни байта, и не грузились картинки + из ДЦ1, 3, 5 и 203. Посредники для того и подобраны, чтобы открываться без ухищрений. +- **Туннели для моста.** Мост ходит через WARP сам, по списку туннелей, который пишет + Zapret Manager после подъёма туннелей. Российские колонии в список не попадают. Список + перечитывается, как только меняется, — мост перезапускать не нужно. + +### Отставки + +Чтобы не платить ожиданием на каждом соединении, неудачный путь на время уходит в сторону: + +| Что случилось | На сколько | +|---|---| +| ДЦ промолчал напрямую | 5 минут без шага ① для этого ДЦ | +| ДЦ промолчал через WARP | 5 минут без шага ② для этого ДЦ | +| к туннелю не дошло даже TCP | 30 секунд без WARP вовсе | +| домен-посредник отказал или не отдал данные | 1 минута без этого домена | +| web.telegram.org отказал | 20 секунд | +| клиент дважды за минуту ждал больше 3 секунд и ничего не получил | путь отставляется, как при отказе | + +Когда все посредники на паузе, пробуется один, а не три. Неудавшееся соединение закрывается +сразу, чтобы роутер не держал его минутами. + +--- + +## 9. Как потом ходит трафик + +### Имя → адрес + +``` + ┌──────────────────────────┐ + │ УСТРОЙСТВО В СЕТИ │ + └────────────┬─────────────┘ + │ запрос DNS + ▼ + ┌──────────────────────────┐ + │ у Steer есть сервисы │ + │ для WARP? │ + └──────┬────────────┬──────┘ + да │ │ нет + ▼ │ + ┌─────────────────────┐ │ + │ DNS-сервер Steer │ │ + └──┬──────────────┬───┘ │ + домен сервиса │ │ прочее│ + для WARP ▼ ▼ ▼ + ┌──────────────┐ ┌─────────────────────┐ + │ fakeip │ │ DNS роутера │ + │ → потом WARP │ └──┬───────────────┬──┘ + └──────────────┘ │ ИИ-домен │ прочее + ▼ ▼ + ┌────────────────────┐ ┌────────────────────┐ + │ свой DoH → │ │ обычный DNS │ + │ гео-резолвер │ │ │ + └────────────────────┘ └────────────────────┘ +``` + +### Соединения + +``` + ┌──────────────────────────┐ + │ УСТРОЙСТВО В СЕТИ │ + └────────────┬─────────────┘ + ▼ + ┌──────────────────────────┐ + │ куда идёт соединение? │ + └──┬───────────┬────────┬──┘ + адрес ДЦ Telegram, │ │ │ всё остальное + стоит мост │ fakeip или │ + │ подсеть сервиса │ + │ для WARP │ + ▼ ▼ ▼ + ┌─────────────┐ ┌───────┐ ┌──────────────┐ + │ мост stgws │ │ WARP │ │ zapret │ + │ (раздел 8) │ │ │ │ │ + └─────────────┘ └───┬───┘ └──────┬───────┘ + ▼ ▼ + ┌───────────────────────────┐ ┌────────────────────────────────┐ + │ самый быстрый живой │ │ устройство в исключениях или │ + │ туннель │ │ трафик помечен «мимо zapret»? │ + │ упал → следующий │ │ да → как есть │ + │ упали все → напрямую, │ │ нет → первая подходящая │ + │ без туннеля │ │ стратегия сверху: │ + └───────────────────────────┘ │ Yv — YouTube │ + │ Dv — голос Discord │ + │ v / Flowseal — остальное │ + └────────────────────────────────┘ +``` + +--- + +## 10. Ограничения и оговорки + +- **Слабые роутеры.** Каждая проверка — отдельный процесс на 3–4 МБ памяти. На роутере меньше + 256 МБ проверки идут по 4 разом, а не по 8, поэтому подбор там дольше. На 128 МБ, где рядом + работают Steer, мост Telegram и DNS со списком ИИ-доменов, восемь разом доводили до + нехватки памяти. +- **Путь моста через WARP — с tgws 0.3.0.** В более ранних выпусках моста шагов через WARP из + раздела 8 нет: там он ходит только веб-сокетом, мимо zapret. +- **Фоновое сравнение различает немного.** На тестовом роутере итоги лучших близких + стратегий разошлись на единицы или совпали вовсе: 137 и 137, дальше 135. Такая разница + близка к шуму, поэтому переключение требует строго большего итога и перепроверки сервисов. +- **Внешние списки.** Общий список целей, стратегии Flowseal и Yv, узлы dpi-detector, домены + ИИ-сервисов и наборы .srs приходят из чужих репозиториев. Их состав и числа меняются без + нашего участия. +- **Проверка идёт с роутера.** Автообход открывает сервисы запросами самого роутера. Путь + устройства в сети может отличаться: например, у устройства из исключений zapret. +- **Российская колония — только запас.** Выход у неё внутри страны, за теми же ТСПУ, что и + без туннеля: через неё не снимается ни геоблок, ни остальные блокировки, и мост Telegram + через неё не ходит. diff --git a/files/AutoBypass/poster.jpg b/files/AutoBypass/poster.jpg new file mode 100644 index 0000000..423ad2c Binary files /dev/null and b/files/AutoBypass/poster.jpg differ diff --git a/files/AutoBypass/videos.txt b/files/AutoBypass/videos.txt new file mode 100644 index 0000000..9e2ccbe --- /dev/null +++ b/files/AutoBypass/videos.txt @@ -0,0 +1,2 @@ +# Ролики автообхода: файл.mp4|Название|постер.jpg. Файлы лежат рядом, в files/AutoBypass. +wait.mp4|Стандартное|poster.jpg diff --git a/files/AutoBypass/wait.mp4 b/files/AutoBypass/wait.mp4 new file mode 100644 index 0000000..6b48b97 Binary files /dev/null and b/files/AutoBypass/wait.mp4 differ