diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh new file mode 100644 index 0000000..8ae5c1d --- /dev/null +++ b/ZapretManager_LuCI.sh @@ -0,0 +1,3840 @@ +#!/bin/sh +# Zapret Manager by StressOzz LuCI installer — самодостаточный скрипт (все файлы зашиты внутри). +set -e +rm -rf \ + /usr/lib/zapret-manager \ + /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 \ + /www/luci-static/resources/view/zapret-manager \ + /www/luci-static/resources/zapret-manager \ + /tmp/zapret-manager \ + /tmp/luci-indexcache* \ + /tmp/luci-modulecache/* 2>/dev/null +/etc/init.d/rpcd restart >/dev/null 2>&1 || true +/etc/init.d/uhttpd restart >/dev/null 2>&1 || true + +mkdir -p /usr/lib/zapret-manager +cat > '/usr/lib/zapret-manager/backend.sh' << 'ZM_INSTALLER_EOF' + +CONF="/etc/config/zapret" +GH_RAW="https://raw.githubusercontent.com" +GH_MAIN="https://github.com" +EXCLUDE_URL="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/zapret-hosts-user-exclude.txt" +FLOWSEAL_ZIP="${GH_MAIN}/Flowseal/zapret-discord-youtube/archive/refs/heads/main.zip" +FLOWSEAL_FAKE_RAW="${GH_MAIN}/Flowseal/zapret-discord-youtube/raw/refs/heads/main/bin" +STR_URL="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/StrYoutube" +JOBS_DIR="/tmp/zapret-manager" +HOSTS_FILE="/etc/hosts" +EXPERT_MODE_FILE="/etc/zapret_manager_expert_mode" +PORTS_UDP="88,1024-2407,2409-4499,4502-19293,19345-49999,50101-65535" +PORTS_TCP="2802,2302,2502,3478-3480,3724,6000-8000,8085,8090,8100,8903,8904,25565,27015-27030,27036-27037,35500-35600,50001,60442" +mkdir -p "$JOBS_DIR" + +if command -v opkg >/dev/null 2>&1; then + PKG="opkg"; INSTALL="opkg install"; DELETE="opkg remove"; UPDATE="opkg update" + TG_ARCH="$(opkg print-architecture 2>/dev/null | awk '{print $2}' | tail -n1)" + RAZ="ipk" +else + PKG="apk"; INSTALL="apk add --allow-untrusted"; DELETE="apk del"; UPDATE="apk update" + TG_ARCH="$(apk --print-arch 2>/dev/null)" + RAZ="apk" +fi + +# ---------- helpers ---------------------------------------------------- + +esc() { # JSON-escape a string (arg1) for embedding inside "..." — однострочные значения + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ' +} + +esc_ml() { # то же самое, но СОХРАНЯЕТ переносы строк (как \n для JSON) — для лога + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g' +} + +_pkg_is_installed() { + if [ "$PKG" = "apk" ]; then apk info -e "$1" >/dev/null 2>&1 + else opkg list-installed 2>/dev/null | grep -q "^$1 "; fi +} + +_ensure_deps() { + # curl и unzip нужны почти всем install-операциям (скачивание релизов, + # распаковка Flowseal и т.д.) — ставим один раз в начале. + local need="" updated=0 + command -v curl >/dev/null 2>&1 || need="$need curl" + command -v unzip >/dev/null 2>&1 || need="$need unzip" + [ -z "$need" ] && return 0 + echo "==> Устанавливаем зависимости:$need" + $UPDATE >/dev/null 2>&1 + updated=1 + $INSTALL $need >/dev/null 2>&1 +} + + +job_start() { + # job_start + local name="$1"; shift + local log="$JOBS_DIR/$name.log" + local pid="$JOBS_DIR/$name.pid" + # Если задача с таким именем уже выполняется (двойной клик, гонка на + # фронтенде и т.п.) — НЕ запускаем вторую копию поверх первой: обе будут + # писать в одни и те же временные файлы одновременно, что даёт задвоенный + # вывод и задвоенные результаты. Вместо этого просто подключаемся к уже + # идущей задаче тем же job-именем (started:true — фронтенд и так просто + # продолжит опрашивать её через pollJob, ничего менять не нужно). + if [ -f "$pid" ] && kill -0 "$(cat "$pid" 2>/dev/null)" 2>/dev/null; then + printf '{"started":true,"job":"%s","already_running":true}\n' "$name" + return 0 + fi + : > "$log" + ( "$@" >>"$log" 2>&1; echo "__DONE__ $?" >>"$log" ) & + echo $! > "$pid" + printf '{"started":true,"job":"%s"}\n' "$name" +} + +job_status() { + local name="$1" + 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 + if [ -f "$log" ] && grep -q '^__DONE__' "$log"; then + done="true" + rc=$(grep '^__DONE__' "$log" | tail -1 | awk '{print $2}') + fi + printf '{"running":%s,"done":%s,"rc":"%s"}\n' "$running" "$done" "$rc" +} + +log_tail() { + local name="$1" + local log="$JOBS_DIR/$name.log" + [ -f "$log" ] || { echo '{"lines":""}'; return; } + printf '{"lines":"%s"}\n' "$(esc_ml "$(tail -n 300 "$log" | grep -v '^__DONE__')")" +} + +zapret_restart() { + [ -x /opt/zapret/sync_config.sh ] && /opt/zapret/sync_config.sh >/dev/null 2>&1 + /etc/init.d/zapret restart >/dev/null 2>&1 +} + +# ---------- status ------------------------------------------------------- + +system_info() { + local model arch owrt df_out tmp_used tmp_free root_used root_free + model=$(cat /tmp/sysinfo/model 2>/dev/null) + arch=$(grep DISTRIB_ARCH /etc/openwrt_release 2>/dev/null | cut -d"'" -f2) + owrt=$(grep '^DISTRIB_RELEASE=' /etc/openwrt_release 2>/dev/null | cut -d"'" -f2) + df_out=$(df -h /tmp / 2>/dev/null) + tmp_used=$(echo "$df_out" | awk 'NR==2{print $3}') + tmp_free=$(echo "$df_out" | awk 'NR==2{print $4}') + root_used=$(echo "$df_out" | awk 'NR==3{print $3}') + root_free=$(echo "$df_out" | awk 'NR==3{print $4}') + printf '{"model":"%s","arch":"%s","openwrt":"%s","tmp_used":"%s","tmp_free":"%s","root_used":"%s","root_free":"%s"}\n' \ + "$(esc "$model")" "$(esc "$arch")" "$(esc "$owrt")" \ + "$(esc "$tmp_used")" "$(esc "$tmp_free")" "$(esc "$root_used")" "$(esc "$root_free")" +} + +status() { + local zr="not_installed" zr_running="false" zr_ver="" + if [ -f /etc/init.d/zapret ]; then + zr="installed" + if [ "$PKG" = "opkg" ]; then + zr_ver=$(opkg list-installed zapret 2>/dev/null | awk '{sub(/-r[0-9]+$/,"",$3); print $3}') + else + zr_ver=$(apk info -v 2>/dev/null | grep '^zapret-' | head -n1 | cut -d- -f2 | sed 's/-r[0-9]\+$//') + fi + pgrep -f "/opt/zapret" >/dev/null 2>&1 && zr_running="true" + fi + + local zr2="not_installed" zr2_running="false" + if [ -f /etc/init.d/zapret2 ]; then + zr2="installed" + /etc/init.d/zapret2 status >/dev/null 2>&1 && zr2_running="true" + fi + + local strat="" fs_marker + if [ -f "$CONF" ]; then + fs_marker=$(grep -m1 '^# ZMFS:' "$CONF" | sed 's/^# ZMFS://') + strat=$(grep -oE '#v[0-9]+|#Yv[0-9]+|#Gv[1-4]|#Dv[0-9]+' "$CONF" | sed 's/^#//' | tr '\n' ' ' | sed 's/ $//') + [ -n "$fs_marker" ] && strat="$fs_marker${strat:+ $strat}" + fi + + printf '{"pkg":"%s","zapret":"%s","zapret_running":%s,"zapret_version":"%s","zapret2":"%s","zapret2_running":%s,"strategy":"%s","flowseal":"%s"}\n' \ + "$PKG" "$zr" "$zr_running" "$(esc "$zr_ver")" "$zr2" "$zr2_running" "$(esc "$strat")" "$(esc "$fs_marker")" +} + +# ---------- install / remove / start / stop ------------------------------- + +_zapret_latest_version() { + local ver + # Читаем заголовок Location из редиректа напрямую (без -L + url_effective) — + # надёжнее: не все сборки curl одинаково ведут себя со связкой -w + # url_effective + HEAD-запрос + редирект. + ver=$(curl -fsSI --connect-timeout 4 --max-time 6 \ + "https://github.com/remittor/zapret-openwrt/releases/latest" 2>/dev/null \ + | tr -d '\r' | awk -F': ' 'tolower($1)=="location"{print $2}' | tail -n1 | sed 's#.*/v##') + # Санити-проверка: версия zapret всегда вида NN.NNNNNNNN — если извлечь + # не удалось (пусто, или всё ещё похоже на URL), лучше не показывать + # ничего, чем сырую ссылку. + echo "$ver" | grep -qE '^[0-9]+\.[0-9]+$' && echo "$ver" || echo "" +} + +zapret_latest_version() { + printf '{"version":"%s"}\n' "$(esc "$(_zapret_latest_version)")" +} + +do_install_zapret() { + _ensure_deps + if [ -f /etc/init.d/zapret2 ] && [ ! -f "$EXPERT_MODE_FILE" ]; then + echo "ОШИБКА: установлен Zapret2, он несовместим с Zapret — сначала удалите Zapret2" + return 1 + fi + echo "==> Определяем версию Zapret" + local ver arch url tmp + ver="$(_zapret_latest_version)" + [ -z "$ver" ] && { echo "!! Не удалось определить версию, использую фиксированную 72.20260307"; ver="72.20260307"; } + arch="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" + url="${GH_MAIN}/remittor/zapret-openwrt/releases/download/v${ver}/zapret_v${ver}_${arch}.zip" + tmp="$JOBS_DIR/install_tmp"; rm -rf "$tmp"; mkdir -p "$tmp"; cd "$tmp" || return 1 + + echo "==> Обновляем список пакетов" + $UPDATE + + if [ -f /etc/init.d/zapret ]; then + echo "==> Останавливаем текущий Zapret" + /etc/init.d/zapret stop >/dev/null 2>&1 + for p in $(pgrep -f /opt/zapret 2>/dev/null); do kill -9 "$p" 2>/dev/null; done + fi + + echo "==> Скачиваем $url" + local attempt=1 max_attempts=5 + while [ "$attempt" -le "$max_attempts" ]; do + rm -f zapret.zip + wget -q -U "Mozilla/5.0" -O zapret.zip "$url" >/dev/null 2>&1 + command -v unzip >/dev/null 2>&1 || { echo "==> Устанавливаем unzip"; $INSTALL unzip >/dev/null 2>&1; } + if [ -s zapret.zip ] && unzip -tq zapret.zip >/dev/null 2>&1; then + break + fi + attempt=$((attempt + 1)) + done + if [ "$attempt" -gt "$max_attempts" ]; then + echo "ОШИБКА: не удалось скачать целый архив за $max_attempts попыток — проверьте соединение с GitHub" + return 1 + fi + unzip -o zapret.zip >/dev/null 2>&1 + + echo "==> Устанавливаем пакеты" + if [ "$PKG" = "apk" ]; then + for p in apk/zapret*; do + [ -f "$p" ] || continue + echo "$p" | grep -q luci && continue + echo " - $(basename "$p")"; $INSTALL "$p" >/dev/null 2>&1 || { echo "ОШИБКА: $p"; return 1; } + done + for p in apk/luci*; do + [ -f "$p" ] || continue + echo " - $(basename "$p")"; $INSTALL "$p" >/dev/null 2>&1 + done + else + for p in zapret_*.ipk; do + [ -f "$p" ] || continue + echo " - $(basename "$p")"; $INSTALL "$p" >/dev/null 2>&1 || { echo "ОШИБКА: $p"; return 1; } + done + for p in luci-app-zapret_*.ipk; do + [ -f "$p" ] || continue + echo " - $(basename "$p")"; $INSTALL "$p" >/dev/null 2>&1 + done + fi + + echo "==> Добавляем домены в исключения" + rm -f /opt/zapret/ipset/zapret-hosts-user-exclude.txt + wget -q -U "Mozilla/5.0" -O /opt/zapret/ipset/zapret-hosts-user-exclude.txt "$EXCLUDE_URL" + + do_add_fake_flow + + echo "==> Включаем автозапуск и custom.d-скрипты" + /etc/init.d/zapret enable >/dev/null 2>&1 + sed -i "/DISABLE_CUSTOM/s/'1'/'0'/" "$CONF" 2>/dev/null + + cd /; rm -rf "$tmp" + echo "==> Готово, Zapret установлен ($ver)" +} + +do_remove_zapret() { + echo "==> Останавливаем Zapret" + /etc/init.d/zapret stop >/dev/null 2>&1 + for p in $(pgrep -f /opt/zapret 2>/dev/null); do kill -9 "$p" 2>/dev/null; done + echo "==> Удаляем пакеты" + $DELETE luci-app-zapret >/dev/null 2>&1 + $DELETE zapret >/dev/null 2>&1 + echo "==> Удаляем файлы" + rm -rf /opt/zapret "$CONF" /etc/init.d/zapret /etc/firewall.zapret + crontab -l 2>/dev/null | grep -v -i zapret | crontab - 2>/dev/null + echo "==> Готово, Zapret удалён" +} + +zapret_action() { + local action="$1" + case "$action" in + install|update) job_start install_zapret do_install_zapret ;; + remove) job_start remove_zapret do_remove_zapret ;; + start) /etc/init.d/zapret start >/dev/null 2>&1; zapret_restart; status ;; + stop) + /etc/init.d/zapret stop >/dev/null 2>&1 + for p in $(pgrep -f /opt/zapret 2>/dev/null); do kill -9 "$p" 2>/dev/null; done + status ;; + *) echo '{"error":"неизвестное действие"}' ;; + esac +} + +# ---------- Zapret2 install / remove / start / stop ------------------------ +# Zapret2 is a routerich.ru-packaged alternative to Zapret, only available +# for aarch64_cortex-a53 and mutually exclusive with the main Zapret. + +do_install_zapret2() { + _ensure_deps + local arch + arch="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" + if [ "$arch" != "aarch64_cortex-a53" ]; then + echo "ОШИБКА: Zapret2 поддерживает только архитектуру aarch64_cortex-a53 (у вас: $arch)" + return 1 + fi + if [ -f /etc/init.d/zapret ] && [ ! -f "$EXPERT_MODE_FILE" ]; then + echo "ОШИБКА: установлен основной Zapret, он несовместим с Zapret2 — сначала удалите Zapret" + return 1 + fi + + local base_url raz + if [ "$PKG" = "apk" ]; then + base_url="https://packages.routerich.ru/25.12/mediatek/filogic/routerich/"; raz="apk" + else + base_url="https://packages.routerich.ru/24.10/mediatek/filogic/routerich/"; raz="ipk" + fi + + local tmp="$JOBS_DIR/install_z2_tmp"; rm -rf "$tmp"; mkdir -p "$tmp"; cd "$tmp" || return 1 + + echo "==> Ищем актуальный пакет Zapret2 в $base_url" + curl -fsS --connect-timeout 8 --max-time 15 "$base_url" -o index.html \ + || { echo "ОШИБКА: не удалось получить список пакетов"; return 1; } + + local main_file luci_file + if [ "$PKG" = "apk" ]; then + main_file=$(grep -oE 'zapret2-[0-9][^"]*\.apk' index.html | head -n1) + luci_file=$(grep -oE 'luci-app-zapret2-[0-9][^"]*\.apk' index.html | head -n1) + else + main_file=$(grep -oE 'zapret2_[0-9][^"]*_aarch64_cortex-a53\.ipk' index.html | head -n1) + luci_file=$(grep -oE 'luci-app-zapret2_[0-9][^"]*_all\.ipk' index.html | head -n1) + fi + [ -z "$main_file" ] && { echo "ОШИБКА: пакет zapret2 не найден в репозитории"; return 1; } + + echo "==> Скачиваем $main_file" + curl -fsSL --max-time 30 "${base_url}${main_file}" -o "$main_file" || { echo "ОШИБКА скачивания $main_file"; return 1; } + if [ -n "$luci_file" ]; then + echo "==> Скачиваем $luci_file" + curl -fsSL --max-time 30 "${base_url}${luci_file}" -o "$luci_file" + fi + + echo "==> Обновляем список пакетов" + $UPDATE + + echo "==> Устанавливаем" + $INSTALL ./*."$raz" || { echo "ОШИБКА установки"; return 1; } + + echo "==> Добавляем домены в исключения" + mkdir -p /opt/zapret2/ipset + wget -q -U "Mozilla/5.0" -O /opt/zapret2/ipset/zapret_hosts_user_exclude.txt "$EXCLUDE_URL" + + echo "==> Настраиваем стратегии" + mkdir -p /opt/zapret2/init.d/openwrt/custom.d + wget -q -U "Mozilla/5.0" -O /opt/zapret2/init.d/openwrt/custom.d/50-discord_media.sh \ + "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/50-discord_media.sh" \ + || echo "!! Не удалось загрузить 50-discord_media.sh" + wget -q -U "Mozilla/5.0" -O /etc/config/zapret2 \ + "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/zapret2" \ + || echo "!! Не удалось загрузить zapret2" + wget -q -U "Mozilla/5.0" -O /opt/zapret2/ipset/zapret_hosts_discord.txt \ + "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/zapret_hosts_discord.txt" \ + || echo "!! Не удалось загрузить zapret_hosts_discord.txt" + + echo "==> Запускаем Zapret2" + /etc/init.d/zapret2 enable >/dev/null 2>&1 + /etc/init.d/zapret2 restart >/dev/null 2>&1 + + cd /; rm -rf "$tmp" + echo "==> Готово, Zapret2 установлен" +} + +do_remove_zapret2() { + echo "==> Останавливаем Zapret2" + /etc/init.d/zapret2 stop >/dev/null 2>&1 + echo "==> Удаляем пакеты" + $DELETE luci-app-zapret2 >/dev/null 2>&1 + $DELETE zapret2 >/dev/null 2>&1 + echo "==> Удаляем файлы" + rm -f /etc/config/zapret2 + rm -rf /opt/zapret2 + echo "==> Готово, Zapret2 удалён" +} + +zapret2_action() { + local action="$1" + case "$action" in + install|update) job_start install_zapret2 do_install_zapret2 ;; + remove) job_start remove_zapret2 do_remove_zapret2 ;; + start) /etc/init.d/zapret2 start >/dev/null 2>&1; status ;; + stop) /etc/init.d/zapret2 stop >/dev/null 2>&1; status ;; + *) echo '{"error":"неизвестное действие"}' ;; + esac +} + +# ---------- strategies v1..v10 (constants copied verbatim from the CLI) -- + +strategy_v1() { printf '%s\n' "#v1" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=split2" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin"; } +strategy_v2() { printf '%s\n' "#v2" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=ts" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } +strategy_v3() { printf '%s\n' "#v3" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=hostfakesplit" "--dpi-desync-hostfakesplit-mod=host=ozon.ru" "--dpi-desync-repeats=4" "--dpi-desync-fooling=ts,md5sig" "--dpi-desync-badseq-increment=0"; } +strategy_v4() { printf '%s\n' "#v4" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=582" "--dpi-desync-split-pos=1" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin"; } +strategy_v5() { printf '%s\n' "#v5" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,fakeddisorder" "--dpi-desync-split-pos=1" "--dpi-desync-fake-tls=/opt/zapret/files/fake/stun.bin" "--dpi-desync-fake-tls-mod=none" "--dpi-desync-fakedsplit-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fooling=badseq,badsum" "--dpi-desync-badseq-increment=0"; } +strategy_v6() { printf '%s\n' "#v6" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=hostfakesplit" "--dpi-desync-hostfakesplit-mod=host=i2.photo.2gis.com" "--dpi-desync-hostfakesplit-midhost=host-2" "--dpi-desync-split-seqovl=726" "--dpi-desync-fooling=badsum,badseq" "--dpi-desync-badseq-increment=0"; } +strategy_v7() { printf '%s\n' "#v7" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=654" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=badseq,badsum" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin" "--dpi-desync-fake-tls=/opt/zapret/files/fake/stun.bin" "--dpi-desync-badseq-increment=0"; } +strategy_v8() { printf '%s\n' "#v8" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake" "--dpi-desync-fooling=ts" "--dpi-desync-fake-tls=/opt/zapret/files/fake/4pda.bin" "--dpi-desync-fake-tls-mod=none"; } +strategy_v9() { printf '%s\n' "#v9" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=hostfakesplit" "--dpi-desync-fooling=badseq,badsum" "--dpi-desync-hostfakesplit-mod=host=ozon.ru" "--dpi-desync-badseq-increment=0"; } +strategy_v10() { printf '%s\n' "#v10" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,split2" "--dpi-desync-split-pos=2" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-hostfakesplit-mod=host=maxcdn.bootstrapcdn.com" "--dpi-desync-fake-tls-mod=rnd,sni=maxcdn.bootstrapcdn.com" "--dpi-desync-fooling=ts"; } + +# ---------- shared post-apply helpers (verbatim behaviour from CLI) ------ + +_add_gp_domains() { + local f="/opt/zapret/ipset/zapret-hosts-google.txt" + mkdir -p "$(dirname "$f")"; touch "$f" + printf '%s\n' "gvt1.com" "googleplay.com" "play.google.com" "beacons.gvt2.com" \ + "play.googleapis.com" "play-fe.googleapis.com" "lh3.googleusercontent.com" \ + "android.clients.google.com" "connectivitycheck.gstatic.com" \ + "play-lh.googleusercontent.com" "play-games.googleusercontent.com" \ + "prod-lt-playstoregatewayadapter-pa.googleapis.com" "youtubei.youtube.com" \ + | grep -Fxv -f "$f" 2>/dev/null >> "$f" +} + +_refresh_exclude_file() { + rm -f /opt/zapret/ipset/zapret-hosts-user-exclude.txt + wget -q -U "Mozilla/5.0" -O /opt/zapret/ipset/zapret-hosts-user-exclude.txt "$EXCLUDE_URL" +} + +_add_yv_default() { + # adds a basic YouTube (Yv08) block if the strategy has none and isn't a Flowseal "general" one + if ! grep -q "^#Yv" "$CONF" && ! grep -q "^#general" "$CONF"; then + sed -i "/^[[:space:]]*option NFQWS_OPT '/a\\#Yv08\\n--filter-tcp=443\\n--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt\\n--dpi-desync=hostfakesplit\\n--dpi-desync-hostfakesplit-mod=host=google.com\\n--dpi-desync-fooling=ts\\n--new" "$CONF" + fi +} + +_discord_str_add() { + # adds the base discord,stun UDP block + a default Dv1 discord.media block if missing + if ! grep -q "option NFQWS_PORTS_UDP.*19294-19344,50000-50100" "$CONF"; then + sed -i "/^[[:space:]]*option NFQWS_PORTS_UDP '/s/'\$/,19294-19344,50000-50100'/" "$CONF" + fi + if ! grep -q "option NFQWS_PORTS_TCP.*2053,2083,2087,2096,8443" "$CONF"; then + sed -i "/^[[:space:]]*option NFQWS_PORTS_TCP '/s/'\$/,2053,2083,2087,2096,8443'/" "$CONF" + fi + if ! grep -q -- "--filter-udp=19294-19344,50000-50100" "$CONF"; then + local last_line1 + last_line1=$(grep -n "^'\$" "$CONF" | tail -n1 | cut -d: -f1) + [ -n "$last_line1" ] && sed -i "${last_line1},\$d" "$CONF" + printf "%s\n" "--new" "--filter-udp=19294-19344,50000-50100" "--filter-l7=discord,stun" \ + "--dpi-desync=fake" "--dpi-desync-fake-discord=/opt/zapret/files/fake/stun.bin" \ + "--dpi-desync-fake-stun=/opt/zapret/files/fake/stun.bin" "--dpi-desync-repeats=6" \ + "#Dv1" "--new" "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" \ + "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=652" "--dpi-desync-split-pos=2" \ + "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" \ + "'" >> "$CONF" + fi +} + +_ts_warning() { grep -q "=ts" "$CONF" 2>/dev/null && echo true || echo false; } + +strategy_list_v() { + printf '{"items":[%s]}\n' "$( + i=1 + while [ "$i" -le 10 ]; do + printf '{"id":"v%s","label":"Стратегия v%s"}' "$i" "$i" + [ "$i" -lt 10 ] && printf ',' + i=$((i + 1)) + done + )" +} + +strategy_set_v() { + local version="$1" + echo "$version" | grep -qE '^v([1-9]|10)$' || { echo '{"error":"некорректная версия"}'; return 1; } + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + sed -i '/^# ZMFS:/d' "$CONF" + sed -i "/^[[:space:]]*option NFQWS_OPT '/,\$d" "$CONF" + { echo " option NFQWS_OPT '"; strategy_"$version"; echo "'"; } >> "$CONF" + _add_gp_domains + _refresh_exclude_file + _add_yv_default + _discord_str_add + zapret_restart + printf '{"ok":true,"strategy":"%s","ts_warning":%s}\n' "$version" "$(_ts_warning)" +} + +# ---------- Flowseal strategies ----------------------------------------- + +_flowseal_file() { echo "$JOBS_DIR/flowseal_strategies.txt"; } + +do_add_fake_flow() { + # Порт ADD_FAKE_FLOW: докачивает дополнительные fake-файлы, которых нет + # в базовом пакете Zapret, но на которые ссылаются стратегии Flowseal. + [ -d /opt/zapret ] || return 0 + [ -f "$CONF" ] || return 0 + local f msg=0 + for f in stun2.bin quic_initial_tencent_com.bin quic_initial_steamcommunity_com.bin \ + tls_clienthello_sochi_park.bin quic_initial_4pda_to.bin quic_initial_5ka_ru.bin \ + tls_clienthello_5ka_ru.bin quic_initial_rutube_ru.bin; do + if [ ! -f "/opt/zapret/files/fake/$f" ]; then + [ "$msg" = 0 ] && { echo "==> Скачиваем дополнительные fake-файлы"; msg=1; } + wget -q -U "Mozilla/5.0" -O "/opt/zapret/files/fake/$f" "${FLOWSEAL_FAKE_RAW}/$f" \ + || echo "!! Не удалось загрузить файл $f" + fi + done +} + +do_flowseal_download() { + _ensure_deps + local out zip tmp attempt=1 max_attempts=5 + out="$(_flowseal_file)"; zip="$JOBS_DIR/flowseal.zip"; tmp="$JOBS_DIR/flowseal_src" + echo "==> Скачиваем список стратегий Flowseal" + rm -rf "$tmp" "$zip"; : > "$out" + command -v unzip >/dev/null 2>&1 || $INSTALL unzip >/dev/null 2>&1 + + while [ "$attempt" -le "$max_attempts" ]; do + rm -f "$zip" + wget -q -U "Mozilla/5.0" -O "$zip" "$FLOWSEAL_ZIP" >/dev/null 2>&1 + if [ -s "$zip" ] && unzip -tq "$zip" >/dev/null 2>&1; then + break + fi + attempt=$((attempt + 1)) + done + if [ "$attempt" -gt "$max_attempts" ]; then + echo "ОШИБКА: не удалось скачать целый архив за $max_attempts попыток — проверьте соединение с GitHub" + return 1 + fi + + mkdir -p "$tmp"; unzip -oq "$zip" -d "$tmp" || { echo "ОШИБКА распаковки"; return 1; } + local base="$tmp/zapret-discord-youtube-main" + + echo "==> Разбираем .bat-стратегии" + find "$base" -type f -name 'general*.bat' ! -name 'general (ALT5).bat' | while read -r F; do + MATCH=$(grep -E '^--filter-udp=19294-19344,50000-50100|^--filter-tcp=%GameFilterTCP%|^--filter-udp=%GameFilterUDP%|^--filter-tcp=2053,2083,2087,2096,8443|^--filter-tcp=443 --hostlist="%LISTS%list-google.txt"|^--filter-tcp=80,443 --hostlist="%LISTS%list-general.txt"' "$F") + [ -z "$MATCH" ] && continue + NAME=$(basename "$F" .bat) + { echo "#$NAME"; echo "$MATCH" | sed 's/--/\n--/g' | sed '/^$/d' | sed 's/[[:space:]]*$//'; echo; } >> "$out" + done + + echo "==> Приводим пути и плейсхолдеры к реальным" + # служебные списки/ipset — заменяем на реальные пути или вычищаем + sed -i '/--hostlist="%LISTS%list-general.txt"/d' "$out" + sed -i '/--ipset="%LISTS%ipset-all.txt"/d' "$out" + sed -i '/--hostlist="%LISTS%list-general-user.txt"/d' "$out" + sed -i '/--ipset-exclude="%LISTS%ipset-exclude.txt"/d' "$out" + sed -i '/--ipset-exclude="%LISTS%ipset-exclude-user.txt"/d' "$out" + sed -i '/--hostlist-exclude="%LISTS%list-exclude-user.txt"/d' "$out" + sed -i 's|"%LISTS%list-exclude.txt"|/opt/zapret/ipset/zapret-hosts-user-exclude.txt|g' "$out" + sed -i 's|"%LISTS%list-google.txt"|/opt/zapret/ipset/zapret-hosts-google.txt|g' "$out" + sed -i 's/--new[[:space:]]\^/--new/g' "$out" + + # fake-бинарники — каждый плейсхолдер на свой реальный путь в /opt/zapret/files/fake + sed -i 's|"%BIN%tls_clienthello_www_google_com.bin"|/opt/zapret/files/fake/tls_clienthello_www_google_com.bin|g' "$out" + sed -i 's|"%BIN%tls_clienthello_sochi_park.bin"|/opt/zapret/files/fake/tls_clienthello_sochi_park.bin|g' "$out" + sed -i 's|"%BIN%stun.bin"|/opt/zapret/files/fake/stun.bin|g' "$out" + sed -i 's|"%BIN%tls_clienthello_4pda_to.bin"|/opt/zapret/files/fake/4pda.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_www_google_com.bin"|/opt/zapret/files/fake/quic_initial_www_google_com.bin|g' "$out" + sed -i 's|"%BIN%stun2.bin"|/opt/zapret/files/fake/stun2.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_tencent_com.bin"|/opt/zapret/files/fake/quic_initial_tencent_com.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_steamcommunity_com.bin"|/opt/zapret/files/fake/quic_initial_steamcommunity_com.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_4pda_to.bin"|/opt/zapret/files/fake/quic_initial_4pda_to.bin|g' "$out" + sed -i 's|"%BIN%ACTIVE_DISCORD_UDP.bin"|/opt/zapret/files/fake/quic_initial_steamcommunity_com.bin|g' "$out" + sed -i 's|"%BIN%ACTIVE_GAME_UDP.bin"|/opt/zapret/files/fake/quic_initial_4pda_to.bin|g' "$out" + sed -i 's|"%BIN%tls_clienthello_max_ru.bin"|/opt/zapret/files/fake/tls_clienthello_www_onetrust_com.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_5ka_ru.bin"|/opt/zapret/files/fake/quic_initial_5ka_ru.bin|g' "$out" + sed -i 's|"%BIN%quic_initial_rutube_ru.bin"|/opt/zapret/files/fake/quic_initial_rutube_ru.bin|g' "$out" + sed -i 's|\^!|/opt/zapret/files/fake/tls_clienthello_www_google_com.bin|g' "$out" + + # игровые порты + sed -i "s|%GameFilterTCP%|$PORTS_TCP|g" "$out" + sed -i "s|%GameFilterUDP%|$PORTS_UDP|g" "$out" + + # финальная зачистка: хвостовые пробелы, пустые строки, дублирующиеся --new подряд + sed -i 's/[[:space:]]\+$//' "$out" + sed -i '/^[[:space:]]*$/d' "$out" + sed -i '/^--new$/ { N; /^--new\n$/d; }' "$out" + + rm -rf "$tmp" "$zip" + + do_add_fake_flow + + echo "==> Готово, стратегий: $(grep -c '^#' "$out")" +} + + +strategy_list_flowseal() { + local f; f="$(_flowseal_file)" + if [ ! -s "$f" ]; then + job_start flowseal_download do_flowseal_download + return + fi + printf '{"items":[%s]}\n' "$( + grep '^#' "$f" | sed 's/^#//' | awk '{ + gsub(/\\/,"\\\\"); gsub(/"/,"\\\""); + printf "%s{\"id\":\"%s\",\"label\":\"%s\"}", (NR>1?",":""), $0, $0 + }' + )" +} + +strategy_set_flowseal() { + local name="$1" f block + f="$(_flowseal_file)" + [ -s "$f" ] || { echo '{"error":"список не загружен — сначала обновите"}'; return 1; } + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + block=$(awk -v n="#$name" '$0==n{flag=1; print; next} /^#/ && flag{exit} flag{print}' "$f") + [ -z "$block" ] && { echo '{"error":"стратегия не найдена"}'; return 1; } + sed -i '/^# ZMFS:/d' "$CONF" + { printf '# ZMFS:%s\n' "$name"; cat "$CONF"; } > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" + sed -i "/option NFQWS_OPT '/,\$d" "$CONF" + { echo " option NFQWS_OPT '"; echo "$block"; echo "'"; } >> "$CONF" + if ! grep -q "option NFQWS_PORTS_UDP.*19294-19344,50000-50100" "$CONF"; then + sed -i "/^[[:space:]]*option NFQWS_PORTS_UDP '/s/'\$/,19294-19344,50000-50100'/" "$CONF" + fi + if ! grep -q "option NFQWS_PORTS_TCP.*2053,2083,2087,2096,8443" "$CONF"; then + sed -i "/^[[:space:]]*option NFQWS_PORTS_TCP '/s/'\$/,2053,2083,2087,2096,8443'/" "$CONF" + fi + _add_gp_domains + _refresh_exclude_file + # помечаем базовую игровую секцию (Gv0) — как в оригинальном flowseal_menu + sed -i '/--new/{N;/--filter-tcp=2802/{s/--new/#Gv0\n--new/;};}' "$CONF" + zapret_restart + printf '{"ok":true,"strategy":"%s","ts_warning":%s}\n' "$(esc "$name")" "$(_ts_warning)" +} + +# ---------- YouTube Yv strategies (ported from choose_strategy_YOUTUBE) -- + +_yv_file() { echo "$JOBS_DIR/youtube_strategies.txt"; } + +do_yv_download() { + _ensure_deps + local out; out="$(_yv_file)" + echo "==> Скачиваем список стратегий для YouTube" + curl -fsSL --connect-timeout 8 --max-time 15 "$STR_URL" -o "$out" || { echo "ОШИБКА скачивания"; return 1; } + echo "==> Готово, стратегий: $(grep -c '^#Yv' "$out")" +} + +strategy_list_youtube() { + local f; f="$(_yv_file)" + if [ ! -s "$f" ]; then + job_start youtube_download do_yv_download + return + fi + printf '{"items":[%s]}\n' "$( + grep -oE '^#Yv[0-9]+' "$f" | sed 's/^#//' | awk '{ + printf "%s{\"id\":\"%s\"}", (NR>1?",":""), $0 + }' + )" +} + +strategy_set_youtube() { + local name="$1" f selected + f="$(_yv_file)" + [ -s "$f" ] || { echo '{"error":"список не загружен — сначала обновите"}'; return 1; } + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + selected="#$name" + grep -qxF "$selected" "$f" || { echo '{"error":"стратегия не найдена"}'; return 1; } + + local saved="$JOBS_DIR/yv_saved" newtmp="$JOBS_DIR/yv_new" finaltmp="$JOBS_DIR/yv_final" oldtmp="$JOBS_DIR/yv_old" + local awk_strip="$JOBS_DIR/yv_strip.awk" awk_insert="$JOBS_DIR/yv_insert.awk" awk_dedup="$JOBS_DIR/yv_dedup.awk" + local flag=0 + : > "$saved" + while IFS= read -r line; do + [ "$line" = "$selected" ] && flag=1 && continue + case "$line" in \#Yv[0-9]*) flag=0 ;; esac + [ "$flag" -eq 1 ] && printf '%s\n' "$line" >> "$saved" + done < "$f" + + # 1) вырезаем текущий блок option NFQWS_OPT '...' в отдельный файл, чистим от старых #Yv + local awk_cut="$JOBS_DIR/yv_cut.awk" + cat > "$awk_cut" << 'AWK_CUT_EOF' +/^[ \t]*option NFQWS_OPT '$/ { flag = 1 } +flag { print } +AWK_CUT_EOF + awk -f "$awk_cut" "$CONF" > "$oldtmp" + rm -f "$awk_cut" + sed -i "/^[[:space:]]*option NFQWS_OPT '/,\$d" "$CONF" + sed -i "/^[[:space:]]*#Yv[0-9]\+/d" "$oldtmp" + + # 2) вырезаем существующую YouTube-секцию (--filter-tcp=443 + hostlist=google), если была + cat > "$awk_strip" << 'AWK_STRIP_EOF' +{ + if (skip) { + if ($0 == "--new" || $0 ~ /^[ \t]*'$/) { skip = 0; next } + if ($0 ~ /^[ \t]*$/) next + next + } + if ($0 == "--filter-tcp=443") { + getline n + if (n == "--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt") { skip = 1; next } + else { print $0; print n; next } + } + if ($0 == "--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt") has_google = 1 + if ($0 ~ /^[ \t]*#Yv/) next + print +} +AWK_STRIP_EOF + awk -f "$awk_strip" "$oldtmp" > "$newtmp" + + # 3) вставляем выбранную Yv-стратегию: сразу после первого "--new" (если секция уже была), + # иначе сразу после открывающей строки option NFQWS_OPT ' + cat > "$awk_insert" << 'AWK_INSERT_EOF' +BEGIN { inserted = 0; has_google = 0 } +$0 == "--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt" { has_google = 1 } +$0 == "--new" && !inserted { + while ((getline l < savedfile) > 0) if (l !~ /^[ \t]*$/) print l + print "--new" + inserted = 1 + next +} +$0 ~ /^[ \t]*option NFQWS_OPT '$/ && !has_google && !inserted { + print + print sel + while ((getline l < savedfile) > 0) if (l !~ /^[ \t]*$/) print l + print "--new" + inserted = 1 + next +} +{ print } +AWK_INSERT_EOF + awk -v sel="$selected" -v savedfile="$saved" -f "$awk_insert" "$newtmp" > "$finaltmp" + + cat "$finaltmp" >> "$CONF" + + # 4) убираем случайные дубликаты подряд идущих "--new" + cat > "$awk_dedup" << 'AWK_DEDUP_EOF' +{ + if ($0 == "--new") { if (prev != "--new") print } + else print + prev = $0 +} +AWK_DEDUP_EOF + awk -f "$awk_dedup" "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" + grep -qE "^[ \t]*'[ \t]*\$" "$CONF" || echo "'" >> "$CONF" + + _add_gp_domains + zapret_restart + rm -f "$saved" "$newtmp" "$finaltmp" "$oldtmp" "$awk_strip" "$awk_insert" "$awk_dedup" + + printf '{"ok":true,"strategy":"%s","ts_warning":%s}\n' "$(esc "$name")" "$(_ts_warning)" +} + +# ---------- Discord Dv strategies (all 17, verbatim from CLI) ----------- + +Dv1() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=652" "--dpi-desync-split-pos=2" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } +Dv2() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=ts" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } +Dv3() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake" "--dpi-desync-repeats=6" "--dpi-desync-fooling=ts" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=none"; } +Dv4() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=652" "--dpi-desync-split-pos=2" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } +Dv5() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multisplit" "--dpi-desync-repeats=6" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=1000" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } +Dv6() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } +Dv7() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=multisplit" "--dpi-desync-split-pos=2,sniext+1" "--dpi-desync-split-seqovl=679" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } +Dv8() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake" "--dpi-desync-fake-tls-mod=none" "--dpi-desync-repeats=6" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=2"; } +Dv9() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,fakedsplit" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=2" "--dpi-desync-repeats=8" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } +Dv10() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=10000000" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } +Dv11() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=ts" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } +Dv12() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake" "--dpi-desync-repeats=6" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=2" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } +Dv13() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake" "--dpi-desync-repeats=6" "--dpi-desync-fooling=ts" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } +Dv14() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,fakedsplit" "--dpi-desync-repeats=6" "--dpi-desync-fooling=ts" "--dpi-desync-fakedsplit-pattern=0x00" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } +Dv15() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multidisorder" "--dpi-desync-split-pos=1,midsld" "--dpi-desync-repeats=11" "--dpi-desync-fooling=badseq" "--dpi-desync-fake-tls=0x00000000" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } +Dv16() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,hostfakesplit" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com" "--dpi-desync-hostfakesplit-mod=host=www.google.com,altorder=1" "--dpi-desync-fooling=ts"; } +Dv17() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=hostfakesplit" "--dpi-desync-repeats=4" "--dpi-desync-fooling=ts" "--dpi-desync-hostfakesplit-mod=host=www.google.com"; } + +discord_status() { + local dv="" fake="" + if [ -f "$CONF" ]; then + dv=$(grep -oE '#Dv[0-9]+' "$CONF" | head -n1 | sed 's/#//') + fake=$(grep -m1 -- '--dpi-desync-fake-discord=' "$CONF" | sed 's|.*fake/||') + fi + printf '{"current":"%s","current_fake":"%s","available":["Dv1","Dv2","Dv3","Dv4","Dv5","Dv6","Dv7","Dv8","Dv9","Dv10","Dv11","Dv12","Dv13","Dv14","Dv15","Dv16","Dv17"]}\n' "$(esc "$dv")" "$(esc "$fake")" +} + +discord_set_dv() { + local num="$1" strat + echo "$num" | grep -qE '^(1[0-7]|[1-9])$' || { echo '{"error":"некорректный номер Dv"}'; return 1; } + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + strat="$(Dv"$num")" + grep -q -E '^[[:space:]]*--filter-tcp=2053,2083,2087,2096,8443' "$CONF" || { + echo '{"error":"блок discord.media отсутствует — сначала установите базовую стратегию с поддержкой Discord"}'; return 1; } + local start end line + start=$(grep -n -E '^[[:space:]]*--filter-tcp=2053,2083,2087,2096,8443' "$CONF" | head -n1 | cut -d: -f1) + end=$(tail -n +"$start" "$CONF" | grep -n -m1 -E '^--new$|^#|^'"'"'$' | cut -d: -f1) + end=$((start + end - 1)) + sed -i "${start},$((end - 1))d" "$CONF" + line=$start + echo "$strat" | while IFS= read -r l; do sed -i "${line}i$l" "$CONF"; line=$((line + 1)); done + if grep -q -E '^#[[:space:]]*Dv' "$CONF"; then + sed -i "s/^#[[:space:]]*Dv[0-9]\+/#Dv$num/" "$CONF" + else + sed -i "${start}i#Dv$num" "$CONF" + fi + zapret_restart + printf '{"ok":true,"dv":"Dv%s"}\n' "$num" +} + +discord_set_fake() { + local file="$1" + case "$file" in + stun.bin|stun2.bin|quic_initial_4pda_to.bin|quic_initial_tencent_com.bin|tls_clienthello_sochi_park.bin|quic_initial_www_google_com.bin|quic_initial_steamcommunity_com.bin|quic_initial_5ka_ru.bin|quic_initial_rutube_ru.bin) ;; + *) echo '{"error":"неизвестный fake-файл"}'; return 1 ;; + esac + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + grep -q -- "--filter-l7=discord,stun" "$CONF" || { echo '{"error":"блок discord,stun не найден"}'; return 1; } + awk -v new="$file" '{ + if ($0 == "--filter-l7=discord,stun") { print; getline; print; + if ($0 == "--dpi-desync=fake") { getline a; getline b; + if (a ~ /^--dpi-desync-fake-discord=/) { + print "--dpi-desync-fake-discord=/opt/zapret/files/fake/" new + print "--dpi-desync-fake-stun=/opt/zapret/files/fake/" new + } else { print a; print b } + } + next + } + print + }' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" + zapret_restart + printf '{"ok":true,"fake":"%s"}\n' "$(esc "$file")" +} + +# ---------- hosts blocks (subset, verbatim constants from CLI) ----------- + +_hosts_block() { + case "$1" in + nalog) printf '%s\n' \ + "#Nalog" \ + "213.24.64.175 lkfl2.nalog.ru" \ + "213.24.64.181 lknpd.nalog.ru" ;; + rutor) printf '%s\n' \ + "#rutor" \ + "173.245.58.219 rutor.info d.rutor.info" ;; + ntc) printf '%s\n' \ + "#ntc.party" \ + "130.255.77.28 ntc.party" ;; + instagram) printf '%s\n' \ + "#Instagram&Facebook" \ + "57.144.222.34 instagram.com www.instagram.com" \ + "157.240.9.174 instagram.com www.instagram.com" \ + "157.240.245.174 instagram.com www.instagram.com b.i.instagram.com z-p42-chat-e2ee-ig.facebook.com help.instagram.com" \ + "157.240.205.174 instagram.com www.instagram.com" \ + "57.144.244.192 static.cdninstagram.com graph.instagram.com i.instagram.com api.instagram.com edge-chat.instagram.com" \ + "31.13.66.63 scontent.cdninstagram.com scontent-hel3-1.cdninstagram.com" \ + "57.144.244.1 facebook.com www.facebook.com fb.com fbsbx.com" \ + "57.144.244.128 static.xx.fbcdn.net scontent.xx.fbcdn.net" \ + "31.13.67.20 scontent-hel3-1.xx.fbcdn.net" ;; + librusec) printf '%s\n' \ + "#lib.rus.ec" \ + "185.39.18.98 lib.rus.ec www.lib.rus.ec" ;; + ai) printf '%s\n' \ + "#Gemini" \ + "45.155.204.190 gemini.google.com" \ + "#Grok" \ + "45.155.204.190 grok.com accounts.x.ai assets.grok.com" \ + "#OpenAI" \ + "45.155.204.190 chatgpt.com ab.chatgpt.com auth.openai.com auth0.openai.com platform.openai.com cdn.oaistatic.com" \ + "45.155.204.190 tcr9i.chat.openai.com webrtc.chatgpt.com android.chat.openai.com api.openai.com operator.chatgpt.com" \ + "45.155.204.190 sora.chatgpt.com sora.com videos.openai.com ios.chat.openai.com cdn.auth0.com files.oaiusercontent.com" \ + "#Microsoft" \ + "45.155.204.190 copilot.microsoft.com sydney.bing.com edgeservices.bing.com rewards.bing.com" \ + "45.155.204.190 xsts.auth.xboxlive.com xgpuwebf2p.gssv-play-prod.xboxlive.com xgpuweb.gssv-play-prod.xboxlive.com" \ + "#ElevenLabs" \ + "45.155.204.190 elevenlabs.io api.us.elevenlabs.io elevenreader.io api.elevenlabs.io help.elevenlabs.io" \ + "#DeepL" \ + "45.155.204.190 deepl.com www.deepl.com www2.deepl.com login-wall.deepl.com w.deepl.com dict.deepl.com ita-free.www.deepl.com" \ + "45.155.204.190 write-free.www.deepl.com experimentation.deepl.com experimentation-grpc.deepl.com ita-free.app.deepl.com" \ + "45.155.204.190 ott.deepl.com api-free.deepl.com backend.deepl.com clearance.deepl.com errortracking.deepl.com" \ + "45.155.204.190 oneshot-free.www.deepl.com checkout.www.deepl.com gtm.deepl.com auth.deepl.com shield.deepl.com" \ + "#Claude" \ + "45.155.204.190 claude.ai console.anthropic.com api.anthropic.com" \ + "#Trae.ai" \ + "45.155.204.190 trae-api-sg.mchost.guru api.trae.ai api-sg-central.trae.ai api16-normal-alisg.mchost.guru" \ + "#Windsurf" \ + "45.155.204.190 windsurf.com codeium.com server.codeium.com web-backend.codeium.com marketplace.windsurf.com" \ + "45.155.204.190 unleash.codeium.com inference.codeium.com windsurf-stable.codeium.com" \ + "144.31.14.104 windsurf-telemetry.codeium.com" \ + "#Manus" \ + "45.155.204.190 manus.im api.manus.im" \ + "#Notion" \ + "45.155.204.190 www.notion.so calendar.notion.so" \ + "#AIStudio" \ + "45.155.204.190 aistudio.google.com generativelanguage.googleapis.com aitestkitchen.withgoogle.com aisandbox-pa.googleapis.com xsts.auth.xboxlive.com" \ + "45.155.204.190 webchannel-alkalimakersuite-pa.clients6.google.com alkalimakersuite-pa.clients6.google.com assistant-s3-pa.googleapis.com" \ + "45.155.204.190 proactivebackend-pa.googleapis.com robinfrontend-pa.googleapis.com o.pki.goog labs.google labs.google.com notebooklm.google" \ + "45.155.204.190 notebooklm.google.com jules.google.com stitch.withgoogle.com gemini.google.com copilot.microsoft.com edgeservices.bing.com" \ + "45.155.204.190 rewards.bing.com sydney.bing.com xboxdesignlab.xbox.com xgpuweb.gssv-play-prod.xboxlive.com xgpuwebf2p.gssv-play-prod.xboxlive.com" ;; + twitch) printf '%s\n' \ + "#Twitch" \ + "45.155.204.190 usher.ttvnw.net gql.twitch.tv" ;; + telegram) printf '%s\n' \ + "#TelegramWeb" \ + "149.154.167.220 core.telegram.org api.telegram.org flora.web.telegram.org kws1-1.web.telegram.org kws1.web.telegram.org kws2-1.web.telegram.org kws2.web.telegram.org kws4-1.web.telegram.org" \ + "149.154.167.220 kws4.web.telegram.org kws5-1.web.telegram.org kws5.web.telegram.org pluto-1.web.telegram.org pluto.web.telegram.org td.telegram.org telegram.dog" \ + "149.154.167.220 telegram.me telegram.org telegram.space telesco.pe venus.web.telegram.org web.telegram.org zws1-1.web.telegram.org zws1.web.telegram.org" \ + "149.154.167.220 tg.dev t.me zws2-1.web.telegram.org zws2.web.telegram.org zws4-1.web.telegram.org zws5-1.web.telegram.org zws5.web.telegram.org" ;; + spotify) printf '%s\n' \ + "#Spotify" \ + "45.155.204.190 api.spotify.com login5.spotify.com encore.scdn.co gew1-spclient.spotify.com spclient.wg.spotify.com" \ + "45.155.204.190 api-partner.spotify.com aet.spotify.com www.spotify.com accounts.spotify.com open.spotify.com" \ + "45.155.204.190 accounts.scdn.co gew1-dealer.spotify.com open-exp.spotifycdn.com www-growth.scdn.co" ;; + spotifyext) printf '%s\n' \ + "#SpotifyEXT" \ + "45.155.204.190 spotify.com www.spotify.com accounts.spotify.com login.spotify.com login5.spotify.com login.app.spotify.com auth.spotify.com account.spotify.com api.spotify.com api-partner.spotify.com spclient.wg.spotify.com gew1-spclient.spotify.com" \ + "45.155.204.190 guc3-spclient.spotify.com gae2-spclient.spotify.com gue1-spclient.spotify.com gnl-spclient.spotify.com spclient.spotify.com ap-gew1.spotify.com ap-gue1.spotify.com ap-gae2.spotify.com ap-gew4.spotify.com ap-sto3.spotify.com" \ + "45.155.204.190 ap-guc3.spotify.com ap.spotify.com apresolve.spotify.com aet.spotify.com gew1-dealer.g2.spotify.com guc3-dealer.g2.spotify.com gue1-dealer.g2.spotify.com dealer.spotify.com dealer-wg.spotify.com edge-web.dual-gslb.spotify.com" \ + "45.155.204.190 client.spotify.com web-partner.spotify.com connect.spotify.com gce.spotify.com clienttoken.spotify.com exp.wg.spotify.com pixel.spotify.com pixel-static.spotify.com image-upload.spotify.com content.spotify.com analytics.spotify.com" \ + "45.155.204.190 crashdump.spotify.com log.spotify.com logger.spotify.com metrics.spotify.com desktop.spotify.com audio-fa-tls13.spotifycdn.com heads-fa-tls13.spotifycdn.com heads4-fa-tls13.spotifycdn.com image-cdn-fa.spotifycdn.com" \ + "45.155.204.190 concerts.spotifycdn.com mrkt.spotifycdn.com pickasso.spotifycdn.com podz-content.spotifycdn.com seed-mix-image.spotifycdn.com thisis-images.spotifycdn.com wap.spotifycdn.com web-sdk-assets.spotifycdn.com spotifycdn.com spotifycdn.net" \ + "35.186.224.24 open.spotify.com" \ + "162.159.141.124 audio4-fa-tls13.spotifycdn.com audio-cf.spotifycdn.com open-exp.spotifycdn.com" \ + "23.36.163.34 audio-ak-spotify-com.akamaized.net" \ + "2.16.168.44 audio4-ak-spotify-com.akamaized.net" \ + "199.232.210.248 scdn.co i.scdn.co line-up.scdn.co mosaic.scdn.co daily-mix.scdn.co lineup-images.scdn.co encore.scdn.co image-cdn-fa.scdn.co accounts.scdn.co www.scdn.co www-growth.scdn.co av.scdn.co seafoam.scdn.co" \ + "23.48.23.145 heads-ak-spotify-com.akamaized.net" \ + "45.155.204.190 xpui.app.spotify.com" ;; + scell) printf '%s\n' \ + "#Supercell" \ + "103.27.157.38 accounts.supercell.com cdn.id.supercell.com clashofclans.inbox.supercell.com game-assets.brawlstarsgame.com" \ + "103.27.157.38 game-assets.clashofclans.com game-assets.clashroyaleapp.com security.id.supercell.com store.supercell.com" \ + "31.25.239.132 accounts.supercell.com cdn.id.supercell.com clashofclans.inbox.supercell.com game-assets.brawlstarsgame.com" \ + "31.25.239.132 game-assets.clashofclans.com game-assets.clashroyaleapp.com game.boombeachgame.com game.mocogame.com security.id.supercell.com store.supercell.com" \ + "185.246.223.127 game.brawlstarsgame.com" \ + "62.133.62.97 game.clashroyaleapp.com" \ + "193.23.209.189 gamea.clashofclans.com" \ + "108.61.167.26 game.squadbustersgame.com" ;; + githubraw) printf '%s\n' \ + "#githubusercontent.com" \ + "146.75.22.132 objects.githubusercontent.com release-assets.githubusercontent.com raw.githubusercontent.com private-user-images.githubusercontent.com gist.githubusercontent.com" \ + "146.75.22.132 camo.githubusercontent.com avatars.githubusercontent.com avatars0.githubusercontent.com avatars1.githubusercontent.com avatars2.githubusercontent.com avatars3.githubusercontent.com avatars4.githubusercontent.com avatars5.githubusercontent.com" ;; + github) printf '%s\n' \ + "#github.com" \ + "140.82.114.3 github.com" \ + "185.199.110.154 github.githubassets.com" \ + "185.199.110.133 camo.githubassets.com" ;; + ubisoft) printf '%s\n' \ + "#Ubisoft" \ + "52.6.7.14 ubi.com" \ + "172.67.139.108 r6s.com" \ + "54.155.2.87 rainbow6.com" \ + "52.222.149.31 ubisoft.com" \ + "54.76.54.196 uplay.ubisoft.com" \ + "2.23.89.92 static3.cdn.ubi.com" \ + "18.209.141.203 connect.ubisoft.com" \ + "2.23.89.244 ubiservices.cdn.ubi.com" \ + "99.83.188.134 public-ubiservices.ubi.com" \ + "3.33.249.140 public-ws-ubiservices.ubi.com" ;; + tapeop) printf '%s\n' \ + "#tapeop.dev" \ + "216.24.57.251 www.tapeop.dev tapeop.dev" \ + "216.24.57.3 www.tapeop.dev tapeop.dev" ;; + roblox) printf '%s\n' \ + "#tr.rbxcdn.com" \ + "108.156.22.8 tr.rbxcdn.com" \ + "108.157.32.114 tr.rbxcdn.com" \ + "18.65.147.108 tr.rbxcdn.com" \ + "18.65.147.112 tr.rbxcdn.com" \ + "13.224.181.18 tr.rbxcdn.com" \ + "13.224.181.74 tr.rbxcdn.com" \ + "54.230.253.22 tr.rbxcdn.com" \ + "54.230.253.81 tr.rbxcdn.com" \ + "54.230.253.48 tr.rbxcdn.com" \ + "54.230.253.59 tr.rbxcdn.com" \ + "143.204.214.34 tr.rbxcdn.com" \ + "143.204.214.67 tr.rbxcdn.com" \ + "143.204.214.92 tr.rbxcdn.com" \ + "99.84.181.25 tr.rbxcdn.com" \ + "99.84.181.63 tr.rbxcdn.com" \ + "65.8.158.45 tr.rbxcdn.com" \ + "65.8.158.112 tr.rbxcdn.com" ;; + updatesdiscord) printf '%s\n' \ + "#updates.discord.com" \ + "162.159.138.232 updates.discord.com" \ + "162.159.137.232 updates.discord.com" \ + "162.159.128.233 updates.discord.com" \ + "162.159.135.232 updates.discord.com" ;; + *) return 1 ;; + esac +} + +_hosts_block_status() { + local block="$1" content line + content="$(_hosts_block "$block")" || return 1 + while IFS= read -r line; do + [ -z "$line" ] && continue + grep -Fxq "$line" "$HOSTS_FILE" || { echo "false"; return; } + done <<-EOF + $content + EOF + echo "true" +} + +hosts_status() { + local blocks="nalog rutor ntc instagram librusec ai twitch telegram spotify spotifyext scell githubraw github ubisoft tapeop roblox updatesdiscord" b first=1 + local geohide="" + if grep -q '^### geohide.ru: hosts file' "$HOSTS_FILE" 2>/dev/null; then + if grep -q '^# Регион серверов: US$' "$HOSTS_FILE" 2>/dev/null; then geohide="us" + elif grep -q '^# Регион серверов: EU$' "$HOSTS_FILE" 2>/dev/null; then geohide="eu" + elif grep -q '^# Регион серверов: RU$' "$HOSTS_FILE" 2>/dev/null; then geohide="ru" + else geohide="unknown"; fi + fi + printf '{"geohide":"%s","items":[' "$(esc "$geohide")" + for b in $blocks; do + [ "$first" -eq 1 ] || printf ',' + first=0 + printf '{"id":"%s","enabled":%s}' "$b" "$(_hosts_block_status "$b")" + done + printf ']}\n' +} + +hosts_toggle() { + local block="$1" content line enabled + content="$(_hosts_block "$block")" || { echo '{"error":"неизвестный блок"}'; return 1; } + enabled="$(_hosts_block_status "$block")" + if [ "$enabled" = "true" ]; then + while IFS= read -r line; do [ -z "$line" ] && continue; sed -i "\\|^$line\$|d" "$HOSTS_FILE"; done <<-EOF + $content + EOF + else + while IFS= read -r line; do [ -z "$line" ] && continue; grep -Fxq "$line" "$HOSTS_FILE" || echo "$line" >> "$HOSTS_FILE"; done <<-EOF + $content + EOF + fi + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + printf '{"ok":true,"block":"%s","enabled":%s}\n' "$(esc "$block")" "$([ "$enabled" = "true" ] && echo false || echo true)" +} + +hosts_replace_geohide() { + local region="$1" url tmp + case "$region" in + ru) url="${GH_RAW}/Internet-Helper/GeoHideDNS/refs/heads/main/hosts/hosts" ;; + eu) url="${GH_RAW}/Internet-Helper/GeoHideDNS/refs/heads/main/hosts/eu/hosts" ;; + us) url="${GH_RAW}/Internet-Helper/GeoHideDNS/refs/heads/main/hosts/us/hosts" ;; + *) echo '{"error":"неизвестный регион"}'; return 1 ;; + esac + tmp="$JOBS_DIR/geohide_hosts.tmp" + wget -q -U "Mozilla/5.0" -O "$tmp" "$url" >/dev/null 2>&1 + if [ ! -s "$tmp" ]; then + rm -f "$tmp" + echo '{"error":"не удалось скачать GeoHide hosts"}' + return 1 + fi + mv "$tmp" "$HOSTS_FILE" + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + printf '{"ok":true,"region":"%s"}\n' "$region" +} + +hosts_reset() { + printf '%s\n' "127.0.0.1 localhost" "" "::1 localhost ip6-localhost ip6-loopback" "ff02::1 ip6-allnodes" "ff02::2 ip6-allrouters" > "$HOSTS_FILE" + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + printf '{"ok":true}\n' +} + +# ---------- Gv game strategy (ported from fix_GAME / Gv_Xtreme / GV_FAKE) -- + +_remove_ports_if_present() { + local option="$1" ports="$2" p + for p in $(echo "$ports" | tr ',' ' '); do + sed -i "\\#option $option '#s#,$p##g" "$CONF" + done + sed -i "\\#option $option '#s#,,#,#g; s#,\$##" "$CONF" +} + +_add_ports_if_missing() { + local option="$1" ports="$2" p + for p in $(echo "$ports" | tr ',' ' '); do + grep -q "option $option '.*\b$p\b" "$CONF" || sed -i "\\#option $option '#s#'\$#,$p'#" "$CONF" + done +} + +strategy_TCP_common() { + printf '%s\n' "--new" "--filter-tcp=$PORTS_TCP" "--dpi-desync-any-protocol=1" "--dpi-desync-cutoff=n5" \ + "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=582" "--dpi-desync-split-pos=1" \ + "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin" +} + +strategy_Gv1() { + printf '%s\n' "#Gv1" "--new" "--filter-udp=$PORTS_UDP" "--dpi-desync=fake" "--dpi-desync-cutoff=d2" \ + "--dpi-desync-any-protocol=1" "--dpi-desync-fake-unknown-udp=/opt/zapret/files/fake/stun.bin" +} + +strategy_Gv() { + local n="$1" + printf '%s\n' "#Gv$n" "--new" "--filter-udp=$PORTS_UDP" "--dpi-desync=fake" "--dpi-desync-repeats=10" \ + "--dpi-desync-any-protocol=1" "--dpi-desync-fake-unknown-udp=/opt/zapret/files/fake/stun.bin" "--dpi-desync-cutoff=n$n" +} + +game_status() { + local current="" xtreme="false" fake="" + if [ -f "$CONF" ]; then + local i + for i in 1 2 3 4; do grep -q "^#Gv$i\$" "$CONF" && current="Gv$i"; done + grep -q "^#Gv[0-9]\+Xtreme\$" "$CONF" && xtreme="true" + fake=$(grep -m1 -- '--dpi-desync-fake-unknown-udp=' "$CONF" | sed 's|.*fake/||') + fi + printf '{"current":"%s","xtreme":%s,"fake":"%s"}\n' "$(esc "$current")" "$xtreme" "$(esc "$fake")" +} + +game_set() { + local choice="$1" current="" i + echo "$choice" | grep -qE '^[1-4]$' || { echo '{"error":"некорректный номер Gv"}'; return 1; } + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + for i in 1 2 3 4; do grep -q "^#Gv$i\$" "$CONF" && current="Gv$i"; done + + local last_quote gv_line + last_quote=$(grep -n "^'\$" "$CONF" | tail -n1 | cut -d: -f1) + if grep -q "^#Gv" "$CONF"; then + gv_line=$(grep -n "^#Gv" "$CONF" | tail -n1 | cut -d: -f1) + sed -i "${gv_line},${last_quote}d" "$CONF" + elif [ -n "$last_quote" ]; then + sed -i "${last_quote},\$d" "$CONF" + fi + + if [ "$current" = "Gv$choice" ]; then + # повторный выбор той же стратегии = снять игровую стратегию + _remove_ports_if_present NFQWS_PORTS_UDP "$PORTS_UDP" + _remove_ports_if_present NFQWS_PORTS_TCP "$PORTS_TCP" + echo "'" >> "$CONF" + zapret_restart + printf '{"ok":true,"game":"none"}\n' + return + fi + + local strat + if [ "$choice" = "1" ]; then strat="$(strategy_Gv1; strategy_TCP_common)"; else strat="$(strategy_Gv "$choice"; strategy_TCP_common)"; fi + echo "$strat" | sed '/^$/d' >> "$CONF" + echo "'" >> "$CONF" + _add_ports_if_missing NFQWS_PORTS_UDP "$PORTS_UDP" + _add_ports_if_missing NFQWS_PORTS_TCP "$PORTS_TCP" + zapret_restart + printf '{"ok":true,"game":"Gv%s"}\n' "$choice" +} + +game_set_fake() { + local file="$1" + case "$file" in + stun.bin|stun2.bin|quic_initial_4pda_to.bin|quic_initial_tencent_com.bin|tls_clienthello_sochi_park.bin|quic_initial_www_google_com.bin|quic_initial_steamcommunity_com.bin|quic_initial_5ka_ru.bin|quic_initial_rutube_ru.bin) ;; + *) echo '{"error":"неизвестный fake-файл"}'; return 1 ;; + esac + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + grep -q -- '--dpi-desync-fake-unknown-udp=' "$CONF" || { echo '{"error":"игровая стратегия не установлена"}'; return 1; } + awk -v new="$file" 'BEGIN{done=0} !done && /--dpi-desync-fake-unknown-udp=/{sub(/\/opt\/zapret\/files\/fake\/[^ ]+/, "/opt/zapret/files/fake/" new); done=1} {print}' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" + zapret_restart + printf '{"ok":true,"fake":"%s"}\n' "$(esc "$file")" +} + +game_toggle_xtreme() { + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + local xfile="/opt/zapret/tmp/GvXtreme" + local xports="80,88,444-65535" + local xnfqws="80,88,443-65535" + mkdir -p "$(dirname "$xfile")" + + if grep -q "^#Gv[0-9]\+Xtreme\$" "$CONF"; then + [ -f "$xfile" ] || { echo '{"error":"файл восстановления отсутствует"}'; return 1; } + local old_gv old_udp old_tcp old_tcp_opt old_udp_opt + old_gv=$(sed -n '1p' "$xfile") + old_udp=$(sed -n '2p' "$xfile") + old_tcp=$(sed -n '3p' "$xfile") + old_tcp_opt=$(sed -n '4p' "$xfile") + old_udp_opt=$(sed -n '5p' "$xfile") + awk -v gv="$old_gv" -v udp="$old_udp" -v tcp="$old_tcp" ' + /^#Gv[0-9]+Xtreme$/ { print gv; restore = 1; next } + restore && /^--filter-udp=/ { print udp; next } + restore && /^--filter-tcp=/ { print tcp; restore = 0; next } + { print } + ' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" + [ -n "$old_tcp_opt" ] && sed -i "s|^[[:space:]]*option NFQWS_PORTS_TCP .*|$old_tcp_opt|" "$CONF" + [ -n "$old_udp_opt" ] && sed -i "s|^[[:space:]]*option NFQWS_PORTS_UDP .*|$old_udp_opt|" "$CONF" + rm -f "$xfile" + zapret_restart + printf '{"ok":true,"xtreme":false}\n' + return + fi + + grep -q "^#Gv[0-9]\+\$" "$CONF" || { echo '{"error":"игровая стратегия не установлена"}'; return 1; } + awk ' + /^#Gv[0-9]+$/ { gv = $0; found = 1; next } + found && /^--filter-udp=/ { udp = $0 } + found && /^--filter-tcp=/ { tcp = $0 } + /^[ \t]*option NFQWS_PORTS_TCP / { tcp_option = $0 } + /^[ \t]*option NFQWS_PORTS_UDP / { udp_option = $0 } + END { print gv; print udp; print tcp; print tcp_option; print udp_option } + ' "$CONF" > "$xfile" + sed -i -e "s|^[[:space:]]*option NFQWS_PORTS_TCP .*| option NFQWS_PORTS_TCP '$xnfqws'|" \ + -e "s|^[[:space:]]*option NFQWS_PORTS_UDP .*| option NFQWS_PORTS_UDP '$xnfqws'|" "$CONF" + sed -i "s/^#\(Gv[0-9]\+\)\$/#\1Xtreme/" "$CONF" + awk -v ports="$xports" ' + /^#Gv[0-9]+Xtreme$/ { gv = 1; print; next } + gv && /^--filter-udp=/ { print "--filter-udp=" ports; next } + gv && /^--filter-tcp=/ { print "--filter-tcp=" ports; gv = 0; next } + { print } + ' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" + zapret_restart + printf '{"ok":true,"xtreme":true}\n' +} + +# ---------- System (ported from Sys_Info / sys_menu) --------------------- + +_quic_blocked() { + uci show firewall 2>/dev/null | grep -q "name='Block_UDP_80'" \ + && uci show firewall 2>/dev/null | grep -q "name='Block_UDP_443'" \ + && echo true || echo false +} + +_ipv6_enabled_in_zapret() { + [ -f "$CONF" ] && grep -q "option DISABLE_IPV6 '0'" "$CONF" && echo true || echo false +} + +_flow_offloading_fix_applied() { + grep -q 'ct original packets ge 30 flow offload @ft;' /usr/share/firewall4/templates/ruleset.uc 2>/dev/null \ + && echo true || echo false +} + +_expert_mode() { [ -f "$EXPERT_MODE_FILE" ] && echo true || echo false; } + +system_status() { + printf '{"quic_blocked":%s,"ipv6_enabled":%s,"flow_offloading_fix":%s,"expert_mode":%s,"pkg":"%s"}\n' \ + "$(_quic_blocked)" "$(_ipv6_enabled_in_zapret)" "$(_flow_offloading_fix_applied)" "$(_expert_mode)" "$PKG" +} + +system_check_connectivity() { + local t4 t6 + t4=$(ping -4 -c1 -W2 google.com 2>/dev/null | grep 'time=' | sed -E 's/.*time=([0-9.]+).*/\1/') + t6=$(ping -6 -c1 -W2 google.com 2>/dev/null | grep 'time=' | sed -E 's/.*time=([0-9.]+).*/\1/') + printf '{"ipv4_ok":%s,"ipv4_ms":"%s","ipv6_ok":%s,"ipv6_ms":"%s"}\n' \ + "$([ -n "$t4" ] && echo true || echo false)" "$(esc "$t4")" \ + "$([ -n "$t6" ] && echo true || echo false)" "$(esc "$t6")" +} + +system_toggle_quic() { + if [ "$(_quic_blocked)" = "true" ]; then + local rule idx + for rule in Block_UDP_80 Block_UDP_443; do + while true; do + idx=$(uci show firewall | grep "name='$rule'" | cut -d. -f2 | cut -d= -f1 | head -n1) + [ -z "$idx" ] && break + uci delete firewall.$idx >/dev/null 2>&1 + done + done + uci commit firewall >/dev/null 2>&1 + /etc/init.d/firewall restart >/dev/null 2>&1 + printf '{"ok":true,"quic_blocked":false}\n' + return + fi + uci add firewall rule >/dev/null 2>&1 + uci set firewall.@rule[-1].name='Block_UDP_80' >/dev/null 2>&1 + uci add_list firewall.@rule[-1].proto='udp' >/dev/null 2>&1 + uci set firewall.@rule[-1].src='lan' >/dev/null 2>&1 + uci set firewall.@rule[-1].dest='wan' >/dev/null 2>&1 + uci set firewall.@rule[-1].dest_port='80' >/dev/null 2>&1 + uci set firewall.@rule[-1].target='REJECT' >/dev/null 2>&1 + uci add firewall rule >/dev/null 2>&1 + uci set firewall.@rule[-1].name='Block_UDP_443' >/dev/null 2>&1 + uci add_list firewall.@rule[-1].proto='udp' >/dev/null 2>&1 + uci set firewall.@rule[-1].src='lan' >/dev/null 2>&1 + uci set firewall.@rule[-1].dest='wan' >/dev/null 2>&1 + uci set firewall.@rule[-1].dest_port='443' >/dev/null 2>&1 + uci set firewall.@rule[-1].target='REJECT' >/dev/null 2>&1 + uci commit firewall >/dev/null 2>&1 + /etc/init.d/firewall restart >/dev/null 2>&1 + printf '{"ok":true,"quic_blocked":true}\n' +} + +system_toggle_ipv6() { + [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + if grep -q "option DISABLE_IPV6 '0'" "$CONF"; then + sed -i "s/option DISABLE_IPV6 '0'/option DISABLE_IPV6 '1'/" "$CONF" + zapret_restart + printf '{"ok":true,"ipv6_enabled":false}\n' + return + fi + local t6 + t6=$(ping -6 -c1 -W2 google.com 2>/dev/null | grep 'time=') + if [ -z "$t6" ]; then + echo '{"error":"IPv6 недоступен на роутере — включение не рекомендуется"}' + return 1 + fi + sed -i "s/option DISABLE_IPV6 '1'/option DISABLE_IPV6 '0'/" "$CONF" + zapret_restart + printf '{"ok":true,"ipv6_enabled":true}\n' +} + +system_toggle_flow_offloading_fix() { + local template="/usr/share/firewall4/templates/ruleset.uc" + [ -f "$template" ] || { echo '{"error":"шаблон firewall4 не найден"}'; return 1; } + if grep -q 'ct original packets ge 30 flow offload @ft;' "$template"; then + sed -i 's/meta l4proto { tcp, udp } ct original packets ge 30 flow offload @ft;/meta l4proto { tcp, udp } flow offload @ft;/' "$template" + fw4 restart >/dev/null 2>&1 + printf '{"ok":true,"flow_offloading_fix":false}\n' + return + fi + sed -i 's/meta l4proto { tcp, udp } flow offload @ft;/meta l4proto { tcp, udp } ct original packets ge 30 flow offload @ft;/' "$template" + fw4 restart >/dev/null 2>&1 + printf '{"ok":true,"flow_offloading_fix":true}\n' +} + +system_toggle_expert_mode() { + if [ -f "$EXPERT_MODE_FILE" ]; then + rm -f "$EXPERT_MODE_FILE" + printf '{"ok":true,"expert_mode":false}\n' + else + echo 1 > "$EXPERT_MODE_FILE" + printf '{"ok":true,"expert_mode":true}\n' + fi +} + +system_uninstall_panel() { + # Панель удаляет саму себя — включая собственный rpcd-скрипт и этот файл. + # Делать rm -rf прямо здесь небезопасно (шелл ещё читает этот же файл), + # поэтому копируем действие в отдельный скрипт в /tmp (вне удаляемых + # путей), запускаем его отсоединённым фоновым процессом с небольшой + # задержкой (чтобы успеть отдать ответ браузеру) и сразу возвращаем ok — + # дальше приложение уже недоступно, поэтому опрашивать тут нечего. + local script="/tmp/zm_uninstall_panel.sh" + cat > "$script" << 'ZM_UNINSTALL_EOF' +#!/bin/sh +sleep 1 +rm -rf /usr/lib/zapret-manager /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 \ + /www/luci-static/resources/view/zapret-manager \ + /www/luci-static/resources/zapret-manager \ + /tmp/zapret-manager /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null +/etc/init.d/rpcd restart >/dev/null 2>&1 +/etc/init.d/uhttpd restart >/dev/null 2>&1 +rm -f "$0" +ZM_UNINSTALL_EOF + chmod 0755 "$script" + ( sh "$script" >/dev/null 2>&1 & ) + printf '{"ok":true}\n' +} + +# ---------- OpenWrt mirror selection (ported from menu_MIR / set_mirror) -- + +_confz() { + if [ "$PKG" = "apk" ]; then echo "/etc/apk/repositories.d/distfeeds.list" + else echo "/etc/opkg/distfeeds.conf"; fi +} + +_mirror_name() { + local f url + f="$(_confz)" + [ -f "$f" ] || { echo "файл не найден"; return; } + url=$(head -n1 "$f") + case "$url" in + *mirror-03.infra.openwrt.org*) echo "Infra OpenWrt" ;; + *c3sl.ufpr.br*) echo "Brazil" ;; + *ustc.edu.cn*) echo "China" ;; + *tetaneutral.net*) echo "France" ;; + *garr.it*) echo "Italy" ;; + *marwan.ma*) echo "Morocco" ;; + *pixeldeck.net*) echo "USA" ;; + *rwth-aachen.de*) echo "Germany" ;; + *downloads.openwrt.org*) echo "default / OpenWrt" ;; + *) echo "неизвестное" ;; + esac +} + +mirror_status() { + printf '{"current":"%s"}\n' "$(esc "$(_mirror_name)")" +} + +do_mirror_set() { + local host="$1" f + echo "==> Проверяем доступность $host" + if ! wget -q --spider --timeout=5 "https://$host/releases/" >/dev/null 2>&1; then + echo "ОШИБКА: зеркало недоступно" + return 1 + fi + f="$(_confz)" + sed -i "s|https://.*/releases/|https://$host/releases/|g" "$f" + echo "==> Зеркало доступно, обновляем список пакетов" + if ! $UPDATE >/dev/null 2>&1; then + echo "ОШИБКА обновления пакетов — зеркало сброшено на default" + sed -i "s|https://.*/releases/|https://downloads.openwrt.org/releases/|g" "$f" + return 1 + fi + echo "==> Готово, зеркало переключено и работает" +} + +mirror_set() { + local id="$1" host + case "$id" in + infra) host="mirror-03.infra.openwrt.org" ;; + brazil) host="openwrt.c3sl.ufpr.br" ;; + china) host="mirrors.ustc.edu.cn/openwrt" ;; + france) host="openwrt.tetaneutral.net" ;; + italy) host="openwrt.mirror.garr.it/mirrors/openwrt" ;; + morocco) host="mirror.marwan.ma/openwrt" ;; + usa) host="openwrt.pixeldeck.net" ;; + germany_rwth) host="ftp.halifax.rwth-aachen.de/openwrt" ;; + default) host="downloads.openwrt.org" ;; + *) echo '{"error":"неизвестное зеркало"}'; return 1 ;; + esac + job_start mirror_set do_mirror_set "$host" +} + +# ---------- IP exclusions (ported from Exclusions_menu) ------------------ + +_excl_dir() { echo "/opt/zapret/init.d/openwrt/custom.d"; } +_excl_file() { echo "$(_excl_dir)/20-script.sh"; } + +_excl_current() { + local f; f="$(_excl_file)" + [ -f "$f" ] || return 0 + grep -oE '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' "$f" | sort -u +} + +_excl_write() { + local ips="$1" f + f="$(_excl_file)" + mkdir -p "$(_excl_dir)" + if [ -n "$ips" ]; then + local formatted + formatted=$(printf '%s\n' "$ips" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g') + { + echo "EXCEPT_SRC='{ $formatted }'" + echo "nft insert rule inet zapret postrouting_hook index 0 \\" + echo " ip saddr \$EXCEPT_SRC meta mark set meta mark \\| 0x40000000" + } > "$f" + else + : > "$f" + fi +} + +exclusions_status() { + [ -f /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + local current devjson="" first=1 ip name ts mac rest + + current=$(_excl_current) + + if [ -f /tmp/dhcp.leases ]; then + while read -r ts mac ip name rest; do + [ -z "$ip" ] && continue + echo "$ip" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || continue + [ -z "$name" ] && name="*" + [ "$name" = "*" ] && name="Неизвестное устройство" + [ "$first" -eq 1 ] || devjson="$devjson," + first=0 + devjson="$devjson{\"ip\":\"$ip\",\"name\":\"$(esc "$name")\",\"excluded\":$(printf '%s\n' "$current" | grep -qx "$ip" && echo true || echo false)}" + done < /tmp/dhcp.leases + fi + + local ip2 + for ip2 in $current; do + [ -z "$ip2" ] && continue + printf '%s' "$devjson" | grep -q "\"ip\":\"$ip2\"" && continue + [ "$first" -eq 1 ] || devjson="$devjson," + first=0 + devjson="$devjson{\"ip\":\"$ip2\",\"name\":\"Устройство offline\",\"excluded\":true}" + done + + printf '{"devices":[%s]}\n' "$devjson" +} + +exclusions_toggle() { + local ip="$1" current new_list excluded="false" + echo "$ip" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || { echo '{"error":"некорректный IP-адрес"}'; return 1; } + [ -f /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + current=$(_excl_current) + if printf '%s\n' "$current" | grep -qx "$ip"; then + new_list=$(printf '%s\n' "$current" | grep -vx "$ip") + else + new_list=$(printf '%s\n%s\n' "$current" "$ip" | grep -v '^$' | sort -u) + excluded="true" + fi + _excl_write "$new_list" + zapret_restart + printf '{"ok":true,"ip":"%s","excluded":%s}\n' "$(esc "$ip")" "$excluded" +} + +exclusions_clear() { + [ -f /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + _excl_write "" + zapret_restart + printf '{"ok":true}\n' +} + +# ---------- TG WS Proxy: MTProto / SOCKS5 / Rust (ported from menu_TG) --- + +TG_MTPROTO_VER="0.10" +TG_GO_VER="1.4.1" +TG_RS_VER="2.3.3" +TG_BIN_GO="/usr/bin/tg-ws-proxy-go" +TG_INIT_GO="/etc/init.d/tg-ws-proxy-go" +TG_BIN_RS="/usr/bin/tg-ws-proxy-rs" +TG_INIT_RS="/etc/init.d/tg-ws-proxy-rs" +TG_SECRET_RS_FILE="/etc/tg-ws-proxy-rs.secret" +TG_SECRET_MT_FILE="/etc/tg-ws-proxy/secret.conf" + +_tg_arch_rs() { + case "$TG_ARCH" in + aarch64*) echo "tg-ws-proxy-aarch64-unknown-linux-musl" ;; + x86_64) echo "tg-ws-proxy-x86_64-unknown-linux-musl" ;; + arm*) echo "tg-ws-proxy-armv7-unknown-linux-musleabihf" ;; + mipsel*) echo "tg-ws-proxy-mipsel-unknown-linux-musl" ;; + mips*) echo "tg-ws-proxy-mips-unknown-linux-musl" ;; + *) return 1 ;; + esac +} + +_tg_arch_go() { + case "$TG_ARCH" in + aarch64*) echo "tg-ws-proxy-openwrt-aarch64" ;; + arm*) echo "tg-ws-proxy-openwrt-armv7" ;; + mipsel*) echo "tg-ws-proxy-openwrt-mipsel_24kc" ;; + mips*) echo "tg-ws-proxy-openwrt-mips_24kc" ;; + x86_64) echo "tg-ws-proxy-openwrt-x86_64" ;; + *) return 1 ;; + esac +} + +tg_status() { + local mt="not_installed" mt_running="false" + local go="not_installed" go_running="false" + local rs="not_installed" rs_running="false" + local secret_mt="" secret_rs="" lan_ip + + if [ -f /etc/init.d/tg-ws-proxy ]; then mt="installed"; pidof tg-ws-proxy >/dev/null 2>&1 && mt_running="true"; fi + if [ -f "$TG_INIT_GO" ]; then go="installed"; pidof tg-ws-proxy-go >/dev/null 2>&1 && go_running="true"; fi + if [ -f "$TG_INIT_RS" ]; then rs="installed"; pidof tg-ws-proxy-rs >/dev/null 2>&1 && rs_running="true"; fi + + [ -f "$TG_SECRET_MT_FILE" ] && secret_mt=$(grep '^SECRET=' "$TG_SECRET_MT_FILE" | cut -d= -f2) + [ -f "$TG_SECRET_RS_FILE" ] && secret_rs=$(cat "$TG_SECRET_RS_FILE") + lan_ip=$(uci -q get network.lan.ipaddr 2>/dev/null | cut -d/ -f1) + + printf '{"mtproto":"%s","mtproto_running":%s,"socks5":"%s","socks5_running":%s,"rust":"%s","rust_running":%s,"lan_ip":"%s","secret_mtproto":"%s","secret_rust":"%s"}\n' \ + "$mt" "$mt_running" "$go" "$go_running" "$rs" "$rs_running" "$(esc "$lan_ip")" "$(esc "$secret_mt")" "$(esc "$secret_rs")" +} + +do_tg_install_mtproto() { + _ensure_deps + echo "==> Устанавливаем TG WS Proxy MTProto" + local go_suf raz url tmp arch_full + if [ "$PKG" = "apk" ]; then go_suf="r1"; raz="apk"; else go_suf="1"; raz="ipk"; fi + arch_full="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" + url="${GH_MAIN}/spatiumstas/tg-ws-proxy-go/releases/download/${TG_MTPROTO_VER}/tg-ws-proxy_${TG_MTPROTO_VER}-${go_suf}_openwrt_${arch_full}.${raz}" + tmp="/tmp/tg-ws-proxy.$raz" + rm -f /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg + $UPDATE >/dev/null 2>&1 + echo "==> Скачиваем $(basename "$url")" + wget -q -O "$tmp" "$url" || { echo "ОШИБКА скачивания $url"; return 1; } + $INSTALL "$tmp" >/dev/null 2>&1 || { echo "ОШИБКА установки"; rm -f "$tmp"; return 1; } + rm -f "$tmp" + mkdir -p "$(dirname "$TG_SECRET_MT_FILE")" + if ! grep -q '^SECRET=.' "$TG_SECRET_MT_FILE" 2>/dev/null; then + echo "SECRET=$(head -c16 /dev/urandom | hexdump -e '16/1 "%02x"')" > "$TG_SECRET_MT_FILE" + fi + rm -f /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg + /etc/init.d/tg-ws-proxy enable >/dev/null 2>&1 + /etc/init.d/tg-ws-proxy restart >/dev/null 2>&1 + echo "==> Готово" +} + +do_tg_remove_mtproto() { + echo "==> Удаляем TG WS Proxy MTProto" + /etc/init.d/tg-ws-proxy stop >/dev/null 2>&1 + /etc/init.d/tg-ws-proxy disable >/dev/null 2>&1 + $DELETE tg-ws-proxy >/dev/null 2>&1 + rm -rf /etc/tg-ws-proxy /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg + echo "==> Готово" +} + +do_tg_install_socks5() { + _ensure_deps + echo "==> Устанавливаем TG WS Proxy SOCKS5" + local file url + file="$(_tg_arch_go)" || { echo "ОШИБКА: архитектура не поддерживается ($TG_ARCH)"; return 1; } + url="${GH_MAIN}/d0mhate/-tg-ws-proxy-Manager-go/releases/download/v${TG_GO_VER}/${file}" + echo "==> Скачиваем $file" + curl -fL --max-time 30 -o "$TG_BIN_GO" "$url" || { echo "ОШИБКА скачивания"; rm -f "$TG_BIN_GO"; return 1; } + chmod +x "$TG_BIN_GO" + if [ ! -f "$TG_INIT_GO" ]; then + printf '#!/bin/sh /etc/rc.common\nSTART=99\nUSE_PROCD=1\n\nstart_service() {\n\tprocd_open_instance\n\tprocd_set_param command /usr/bin/tg-ws-proxy-go --host 0.0.0.0 --port 2080 --cf-proxy --cf-proxy-first --cf-balance\n\tprocd_set_param respawn\n\tprocd_close_instance\n}\n' > "$TG_INIT_GO" + chmod +x "$TG_INIT_GO" + "$TG_INIT_GO" enable >/dev/null 2>&1 + fi + "$TG_INIT_GO" restart >/dev/null 2>&1 + echo "==> Готово" +} + +do_tg_remove_socks5() { + echo "==> Удаляем TG WS Proxy SOCKS5" + [ -x "$TG_INIT_GO" ] && { "$TG_INIT_GO" stop >/dev/null 2>&1; "$TG_INIT_GO" disable >/dev/null 2>&1; } + rm -f "$TG_BIN_GO" "$TG_INIT_GO" + echo "==> Готово" +} + +do_tg_install_rust() { + _ensure_deps + echo "==> Устанавливаем TG WS Proxy Rust" + local file url tmp_archive tmp_dir secret + file="$(_tg_arch_rs)" || { echo "ОШИБКА: архитектура не поддерживается ($TG_ARCH)"; return 1; } + url="${GH_MAIN}/valnesfjord/tg-ws-proxy-rs/releases/download/v${TG_RS_VER}/${file}.tar.gz" + tmp_archive="/tmp/tg-ws-proxy-rs.tar.gz"; tmp_dir="/tmp/tg-ws-proxy-rs" + echo "==> Скачиваем $file" + curl -fL --max-time 30 -o "$tmp_archive" "$url" || { echo "ОШИБКА скачивания"; return 1; } + rm -rf "$tmp_dir"; mkdir -p "$tmp_dir" + tar -xzf "$tmp_archive" -C "$tmp_dir" || { echo "ОШИБКА распаковки"; rm -f "$tmp_archive"; return 1; } + rm -f "$TG_BIN_RS" + mv "$tmp_dir"/tg-ws-proxy* "$TG_BIN_RS" || { echo "ОШИБКА установки бинарника"; rm -rf "$tmp_dir" "$tmp_archive"; return 1; } + chmod +x "$TG_BIN_RS" + rm -rf "$tmp_dir" "$tmp_archive" + + if [ ! -f "$TG_SECRET_RS_FILE" ]; then + head -c16 /dev/urandom | hexdump -e '16/1 "%02x"' > "$TG_SECRET_RS_FILE" + fi + secret=$(cat "$TG_SECRET_RS_FILE") + + if [ ! -f "$TG_INIT_RS" ]; then + printf '#!/bin/sh /etc/rc.common\nSTART=99\nUSE_PROCD=1\n\nstart_service() {\n\tprocd_open_instance\n\tprocd_set_param command /usr/bin/tg-ws-proxy-rs --host 0.0.0.0 --port 2443 --secret %s --default-domains --cf-balance --cf-priority\n\tprocd_set_param respawn\n\tprocd_close_instance\n}\n' "$secret" > "$TG_INIT_RS" + chmod +x "$TG_INIT_RS" + "$TG_INIT_RS" enable >/dev/null 2>&1 + fi + "$TG_INIT_RS" restart >/dev/null 2>&1 + echo "==> Готово" +} + +do_tg_remove_rust() { + echo "==> Удаляем TG WS Proxy Rust" + [ -x "$TG_INIT_RS" ] && { "$TG_INIT_RS" stop >/dev/null 2>&1; "$TG_INIT_RS" disable >/dev/null 2>&1; } + rm -f "$TG_BIN_RS" "$TG_INIT_RS" "$TG_SECRET_RS_FILE" + echo "==> Готово" +} + +tg_action() { + local variant="$1" action="$2" + case "$variant:$action" in + mtproto:install|mtproto:update) job_start tg_install_mtproto do_tg_install_mtproto ;; + mtproto:remove) job_start tg_remove_mtproto do_tg_remove_mtproto ;; + socks5:install|socks5:update) job_start tg_install_socks5 do_tg_install_socks5 ;; + socks5:remove) job_start tg_remove_socks5 do_tg_remove_socks5 ;; + rust:install|rust:update) job_start tg_install_rust do_tg_install_rust ;; + rust:remove) job_start tg_remove_rust do_tg_remove_rust ;; + *) echo '{"error":"неизвестный вариант/действие"}' ;; + esac +} + +tg_restart_all() { + [ -x /etc/init.d/tg-ws-proxy ] && /etc/init.d/tg-ws-proxy restart >/dev/null 2>&1 + [ -x "$TG_INIT_GO" ] && "$TG_INIT_GO" restart >/dev/null 2>&1 + [ -x "$TG_INIT_RS" ] && "$TG_INIT_RS" restart >/dev/null 2>&1 + printf '{"ok":true}\n' +} + +# ---------- DNS over HTTPS (subset, verbatim constants from CLI) -------- + +_doh_file="/etc/config/https-dns-proxy" + +do_doh_install() { + local installed + installed=$(doh_status | grep -o '"installed":[a-z]*' | cut -d: -f2) + if [ "$installed" = "true" ]; then + echo "==> DNS over HTTPS уже установлен" + return 0 + fi + _ensure_deps + echo "==> Обновляем список пакетов" + $UPDATE >/dev/null 2>&1 + echo "==> Устанавливаем https-dns-proxy и luci-app-https-dns-proxy" + $INSTALL https-dns-proxy luci-app-https-dns-proxy >/dev/null 2>&1 || { echo "ОШИБКА установки"; return 1; } + echo "==> Готово, DNS over HTTPS установлен — выберите провайдера ниже" +} + +do_doh_remove() { + echo "==> Удаляем DNS over HTTPS" + echo "==> Удаляем пакеты" + $DELETE https-dns-proxy luci-app-https-dns-proxy >/dev/null 2>&1 + echo "==> Удаляем файлы конфигурации" + rm -f /etc/config/https-dns-proxy /etc/init.d/https-dns-proxy + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + echo "==> Готово, DNS over HTTPS удалён" +} + +doh_remove() { + job_start doh_remove do_doh_remove +} + +doh_install() { + job_start doh_install do_doh_install +} + +doh_status() { + local installed="false" current="" + if [ "$PKG" = "apk" ]; then apk info -e https-dns-proxy >/dev/null 2>&1 && installed="true" + else opkg list-installed 2>/dev/null | grep -q '^https-dns-proxy ' && installed="true"; fi + if [ -f "$_doh_file" ]; then + if grep -q "eu.geohide.ru" "$_doh_file"; then current="geohide_eu" + elif grep -q "us.geohide.ru" "$_doh_file"; then current="geohide_us" + elif grep -q "geohide.ru" "$_doh_file"; then current="geohide_ru" + elif grep -q "xbox-dns.ru" "$_doh_file"; then current="xbox" + elif grep -q "cloudflare-dns.com" "$_doh_file"; then current="cloudflare" + elif grep -q "dns.google" "$_doh_file"; then current="google" + elif grep -q "dns.quad9.net" "$_doh_file"; then current="quad9"; fi + fi + printf '{"installed":%s,"current":"%s"}\n' "$installed" "$(esc "$current")" +} + +doh_set() { + local provider="$1" url bootstrap="" + case "$provider" in + cloudflare) url="https://cloudflare-dns.com/dns-query"; bootstrap="1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001" ;; + google) url="https://dns.google/dns-query"; bootstrap="8.8.8.8,8.8.4.4,2001:4860:4860::8888,2001:4860:4860::8844" ;; + quad9) url="https://dns.quad9.net/dns-query"; bootstrap="9.9.9.9,149.112.112.112,2620:fe::fe,2620:fe::9" ;; + xbox) url="https://xbox-dns.ru/dns-query" ;; + geohide_ru) url="https://geohide.ru/dns-query" ;; + geohide_eu) url="https://eu.geohide.ru/dns-query" ;; + geohide_us) url="https://us.geohide.ru/dns-query" ;; + *) echo '{"error":"неизвестный провайдер"}'; return 1 ;; + esac + local installed; installed=$(doh_status | grep -o '"installed":[a-z]*' | cut -d: -f2) + if [ "$installed" != "true" ]; then + echo '{"error":"DNS over HTTPS не установлен — сначала нажмите «Установить DNS over HTTPS»"}' + return 1 + fi + { + # базовый блок config main — дословно как в оригинальном doh_set, + # критично: force_dns_port (без него принудительное перенаправление + # DNS-трафика на прокси может не сработать) + echo "config main 'config'" + echo " option canary_domains_icloud '1'" + echo " option canary_domains_mozilla '1'" + echo " option dnsmasq_config_update '*'" + echo " option force_dns '1'" + echo " option notrack_dns '1'" + echo " list force_dns_port '53'" + echo " list force_dns_port '853'" + echo " list force_dns_src_interface 'lan'" + echo " option procd_trigger_wan6 '0'" + echo " option heartbeat_domain 'heartbeat.mossdef.org'" + echo " option heartbeat_sleep_timeout '10'" + echo " option heartbeat_wait_timeout '10'" + echo " option user 'nobody'" + echo " option group 'nogroup'" + echo " option listen_addr '127.0.0.1'" + echo " option force_ip_family 'auto'" + echo "" + echo "config https-dns-proxy" + echo " option resolver_url '$url'" + [ -n "$bootstrap" ] && echo " option bootstrap_dns '$bootstrap'" + } > "$_doh_file" + /etc/init.d/https-dns-proxy reload >/dev/null 2>&1 + /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + printf '{"ok":true,"provider":"%s"}\n' "$provider" +} + + +# ---------- dispatch ------------------------------------------------------- + +cmd="$1"; shift +case "$cmd" in + status) status ;; + system_info) system_info ;; + job_status) job_status "$1" ;; + log_tail) log_tail "$1" ;; + zapret_action) zapret_action "$1" ;; + zapret_latest_version) zapret_latest_version ;; + zapret2_action) zapret2_action "$1" ;; + strategy_list_v) strategy_list_v ;; + strategy_set_v) strategy_set_v "$1" ;; + strategy_list_flowseal) strategy_list_flowseal ;; + strategy_set_flowseal) strategy_set_flowseal "$1" ;; + strategy_list_youtube) strategy_list_youtube ;; + strategy_set_youtube) strategy_set_youtube "$1" ;; + discord_status) discord_status ;; + discord_set_dv) discord_set_dv "$1" ;; + discord_set_fake) discord_set_fake "$1" ;; + game_status) game_status ;; + game_set) game_set "$1" ;; + game_set_fake) game_set_fake "$1" ;; + game_toggle_xtreme) game_toggle_xtreme ;; + system_status) system_status ;; + system_check_connectivity) system_check_connectivity ;; + system_toggle_quic) system_toggle_quic ;; + system_toggle_ipv6) system_toggle_ipv6 ;; + system_toggle_flow_offloading_fix) system_toggle_flow_offloading_fix ;; + system_toggle_expert_mode) system_toggle_expert_mode ;; + system_uninstall_panel) system_uninstall_panel ;; + mirror_status) mirror_status ;; + mirror_set) mirror_set "$1" ;; + exclusions_status) exclusions_status ;; + exclusions_toggle) exclusions_toggle "$1" ;; + exclusions_clear) exclusions_clear ;; + tg_status) tg_status ;; + tg_action) tg_action "$1" "$2" ;; + tg_restart_all) tg_restart_all ;; + hosts_status) hosts_status ;; + hosts_toggle) hosts_toggle "$1" ;; + hosts_replace_geohide) hosts_replace_geohide "$1" ;; + hosts_reset) hosts_reset ;; + doh_install) doh_install ;; + doh_remove) doh_remove ;; + doh_status) doh_status ;; + doh_set) doh_set "$1" ;; + *) echo '{"error":"неизвестная команда"}'; exit 1 ;; +esac +ZM_INSTALLER_EOF +chmod 0755 '/usr/lib/zapret-manager/backend.sh' + +mkdir -p /usr/libexec/rpcd +cat > '/usr/libexec/rpcd/zapret-manager' << 'ZM_INSTALLER_EOF' +#!/bin/sh +# rpcd ubus backend for luci-app-zapret-manager +# Bridges LuCI JS (ubus calls) to /usr/lib/zapret-manager/backend.sh + +. /usr/share/libubox/jshn.sh + +BACKEND="/usr/lib/zapret-manager/backend.sh" + +list_methods() { + json_init + json_add_object "status"; json_close_object + json_add_object "system_info"; json_close_object + json_add_object "job_status"; json_add_string "job" "string"; json_close_object + json_add_object "log_tail"; json_add_string "job" "string"; json_close_object + json_add_object "zapret_action"; json_add_string "action" "string"; json_close_object + json_add_object "zapret_latest_version"; json_close_object + json_add_object "zapret2_action"; json_add_string "action" "string"; json_close_object + json_add_object "strategy_list_v"; json_close_object + json_add_object "strategy_set_v"; json_add_string "version" "string"; json_close_object + json_add_object "strategy_list_flowseal"; json_close_object + json_add_object "strategy_set_flowseal"; json_add_string "name" "string"; json_close_object + json_add_object "strategy_list_youtube"; json_close_object + json_add_object "strategy_set_youtube"; json_add_string "name" "string"; json_close_object + json_add_object "discord_status"; json_close_object + json_add_object "discord_set_dv"; json_add_string "num" "string"; json_close_object + json_add_object "discord_set_fake"; json_add_string "file" "string"; json_close_object + json_add_object "game_status"; json_close_object + json_add_object "game_set"; json_add_string "choice" "string"; json_close_object + json_add_object "game_set_fake"; json_add_string "file" "string"; json_close_object + json_add_object "game_toggle_xtreme"; json_close_object + json_add_object "system_status"; json_close_object + json_add_object "system_check_connectivity"; json_close_object + json_add_object "system_toggle_quic"; json_close_object + json_add_object "system_toggle_ipv6"; json_close_object + json_add_object "system_toggle_flow_offloading_fix"; json_close_object + json_add_object "system_toggle_expert_mode"; json_close_object + json_add_object "system_uninstall_panel"; json_close_object + json_add_object "mirror_status"; json_close_object + json_add_object "mirror_set"; json_add_string "id" "string"; json_close_object + json_add_object "exclusions_status"; json_close_object + json_add_object "exclusions_toggle"; json_add_string "ip" "string"; json_close_object + json_add_object "exclusions_clear"; json_close_object + json_add_object "tg_status"; json_close_object + json_add_object "tg_action"; json_add_string "variant" "string"; json_add_string "action" "string"; json_close_object + json_add_object "tg_restart_all"; json_close_object + json_add_object "hosts_status"; json_close_object + json_add_object "hosts_toggle"; json_add_string "block" "string"; json_close_object + json_add_object "hosts_replace_geohide"; json_add_string "region" "string"; json_close_object + json_add_object "hosts_reset"; json_close_object + json_add_object "doh_status"; json_close_object + json_add_object "doh_install"; json_close_object + json_add_object "doh_remove"; json_close_object + json_add_object "doh_set"; json_add_string "provider" "string"; json_close_object + json_dump +} + +call_method() { + local method="$1" + local input + input="$(cat)" + json_load "$input" 2>/dev/null + + case "$method" in + status) "$BACKEND" status ;; + system_info) "$BACKEND" system_info ;; + job_status) json_get_var job job; "$BACKEND" job_status "$job" ;; + log_tail) json_get_var job job; "$BACKEND" log_tail "$job" ;; + zapret_action) json_get_var action action; "$BACKEND" zapret_action "$action" ;; + zapret_latest_version) "$BACKEND" zapret_latest_version ;; + zapret2_action) json_get_var action action; "$BACKEND" zapret2_action "$action" ;; + strategy_list_v) "$BACKEND" strategy_list_v ;; + strategy_set_v) json_get_var version version; "$BACKEND" strategy_set_v "$version" ;; + strategy_list_flowseal) "$BACKEND" strategy_list_flowseal ;; + strategy_set_flowseal) json_get_var name name; "$BACKEND" strategy_set_flowseal "$name" ;; + strategy_list_youtube) "$BACKEND" strategy_list_youtube ;; + strategy_set_youtube) json_get_var name name; "$BACKEND" strategy_set_youtube "$name" ;; + discord_status) "$BACKEND" discord_status ;; + discord_set_dv) json_get_var num num; "$BACKEND" discord_set_dv "$num" ;; + discord_set_fake) json_get_var file file; "$BACKEND" discord_set_fake "$file" ;; + game_status) "$BACKEND" game_status ;; + game_set) json_get_var choice choice; "$BACKEND" game_set "$choice" ;; + game_set_fake) json_get_var file file; "$BACKEND" game_set_fake "$file" ;; + game_toggle_xtreme) "$BACKEND" game_toggle_xtreme ;; + system_status) "$BACKEND" system_status ;; + system_check_connectivity) "$BACKEND" system_check_connectivity ;; + system_toggle_quic) "$BACKEND" system_toggle_quic ;; + system_toggle_ipv6) "$BACKEND" system_toggle_ipv6 ;; + system_toggle_flow_offloading_fix) "$BACKEND" system_toggle_flow_offloading_fix ;; + system_toggle_expert_mode) "$BACKEND" system_toggle_expert_mode ;; + system_uninstall_panel) "$BACKEND" system_uninstall_panel ;; + mirror_status) "$BACKEND" mirror_status ;; + mirror_set) json_get_var id id; "$BACKEND" mirror_set "$id" ;; + exclusions_status) "$BACKEND" exclusions_status ;; + exclusions_toggle) json_get_var ip ip; "$BACKEND" exclusions_toggle "$ip" ;; + exclusions_clear) "$BACKEND" exclusions_clear ;; + tg_status) "$BACKEND" tg_status ;; + tg_action) json_get_var variant variant; json_get_var action action; "$BACKEND" tg_action "$variant" "$action" ;; + tg_restart_all) "$BACKEND" tg_restart_all ;; + hosts_status) "$BACKEND" hosts_status ;; + hosts_toggle) json_get_var block block; "$BACKEND" hosts_toggle "$block" ;; + hosts_replace_geohide) json_get_var region region; "$BACKEND" hosts_replace_geohide "$region" ;; + hosts_reset) "$BACKEND" hosts_reset ;; + doh_status) "$BACKEND" doh_status ;; + doh_install) "$BACKEND" doh_install ;; + doh_remove) "$BACKEND" doh_remove ;; + doh_set) json_get_var provider provider; "$BACKEND" doh_set "$provider" ;; + *) echo '{"error":"unknown method"}'; return 1 ;; + esac +} + +case "$1" in + list) list_methods ;; + call) call_method "$2" ;; + *) echo '{"error":"usage: zapret-manager {list|call }"}'; exit 1 ;; +esac +ZM_INSTALLER_EOF +chmod 0755 '/usr/libexec/rpcd/zapret-manager' + +mkdir -p /usr/share/rpcd/acl.d +cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' +{ + "luci-app-zapret-manager": { + "description": "Grant access to the Zapret Manager backend", + "read": { + "ubus": { + "zapret-manager": [ + "status", "job_status", "log_tail", "system_info", + "strategy_list_v", "strategy_list_flowseal", "strategy_list_youtube", + "discord_status", "hosts_status", "doh_status", "game_status", + "system_status", "mirror_status", "exclusions_status", "tg_status", + "zapret_latest_version" + ] + } + }, + "write": { + "ubus": { + "zapret-manager": [ + "zapret_action", "zapret2_action", + "strategy_set_v", "strategy_set_flowseal", "strategy_set_youtube", + "discord_set_dv", "discord_set_fake", + "hosts_toggle", "doh_set", "hosts_replace_geohide", "hosts_reset", "doh_install", "doh_remove", + "game_set", "game_set_fake", "game_toggle_xtreme", + "system_check_connectivity", "system_toggle_quic", "system_toggle_ipv6", + "system_toggle_flow_offloading_fix", "system_toggle_expert_mode", "system_uninstall_panel", + "mirror_set", "exclusions_toggle", "exclusions_clear", + "tg_action", "tg_restart_all" + ] + } + } + } +} +ZM_INSTALLER_EOF + +mkdir -p /usr/share/luci/menu.d +cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' +{ + "admin/services/zapret-manager": { + "title": "Zapret Manager", + "order": 60, + "action": { + "type": "alias", + "path": "admin/services/zapret-manager/dashboard" + } + }, + "admin/services/zapret-manager/dashboard": { + "title": "Дашборд", + "order": 10, + "action": { "type": "view", "path": "zapret-manager/dashboard" } + }, + "admin/services/zapret-manager/strategy": { + "title": "Стратегии", + "order": 20, + "action": { "type": "view", "path": "zapret-manager/strategy" } + }, + "admin/services/zapret-manager/youtube": { + "title": "YouTube", + "order": 25, + "action": { "type": "view", "path": "zapret-manager/youtube" } + }, + "admin/services/zapret-manager/game": { + "title": "Игры", + "order": 27, + "action": { "type": "view", "path": "zapret-manager/game" } + }, + "admin/services/zapret-manager/discord": { + "title": "Discord", + "order": 30, + "action": { "type": "view", "path": "zapret-manager/discord" } + }, + "admin/services/zapret-manager/hosts": { + "title": "Hosts", + "order": 40, + "action": { "type": "view", "path": "zapret-manager/hosts" } + }, + "admin/services/zapret-manager/doh": { + "title": "DNS over HTTPS", + "order": 50, + "action": { "type": "view", "path": "zapret-manager/doh" } + }, + "admin/services/zapret-manager/tgproxy": { + "title": "TG WS Proxy", + "order": 55, + "action": { "type": "view", "path": "zapret-manager/tgproxy" } + }, + "admin/services/zapret-manager/system": { + "title": "Система", + "order": 60, + "action": { "type": "view", "path": "zapret-manager/system" } + }, + "admin/services/zapret-manager/exclusions": { + "title": "Исключения IP", + "order": 70, + "action": { "type": "view", "path": "zapret-manager/exclusions" } + } +} +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/zapret-manager +cat > '/www/luci-static/resources/zapret-manager/common.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require baseclass'; +'require rpc'; +'require ui'; + +var callStatus = rpc.declare({ object: 'zapret-manager', method: 'status', 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: {} }); +var callZapretAction = rpc.declare({ object: 'zapret-manager', method: 'zapret_action', params: ['action'], expect: {} }); +var callZapretLatestVersion = rpc.declare({ object: 'zapret-manager', method: 'zapret_latest_version', expect: {} }); +var callZapret2Action = rpc.declare({ object: 'zapret-manager', method: 'zapret2_action', params: ['action'], expect: {} }); +var callStrategyListV = rpc.declare({ object: 'zapret-manager', method: 'strategy_list_v', expect: {} }); +var callStrategySetV = rpc.declare({ object: 'zapret-manager', method: 'strategy_set_v', params: ['version'], expect: {} }); +var callStrategyListFlowseal = rpc.declare({ object: 'zapret-manager', method: 'strategy_list_flowseal', expect: {} }); +var callStrategySetFlowseal = rpc.declare({ object: 'zapret-manager', method: 'strategy_set_flowseal', params: ['name'], expect: {} }); +var callStrategyListYoutube = rpc.declare({ object: 'zapret-manager', method: 'strategy_list_youtube', expect: {} }); +var callStrategySetYoutube = rpc.declare({ object: 'zapret-manager', method: 'strategy_set_youtube', params: ['name'], expect: {} }); +var callDiscordStatus = rpc.declare({ object: 'zapret-manager', method: 'discord_status', expect: {} }); +var callDiscordSetDv = rpc.declare({ object: 'zapret-manager', method: 'discord_set_dv', params: ['num'], expect: {} }); +var callDiscordSetFake = rpc.declare({ object: 'zapret-manager', method: 'discord_set_fake', params: ['file'], expect: {} }); +var callGameStatus = rpc.declare({ object: 'zapret-manager', method: 'game_status', expect: {} }); +var callGameSet = rpc.declare({ object: 'zapret-manager', method: 'game_set', params: ['choice'], expect: {} }); +var callGameSetFake = rpc.declare({ object: 'zapret-manager', method: 'game_set_fake', params: ['file'], expect: {} }); +var callGameToggleXtreme = rpc.declare({ object: 'zapret-manager', method: 'game_toggle_xtreme', expect: {} }); +var callSystemStatus = rpc.declare({ object: 'zapret-manager', method: 'system_status', expect: {} }); +var callSystemCheckConnectivity = rpc.declare({ object: 'zapret-manager', method: 'system_check_connectivity', expect: {} }); +var callSystemToggleQuic = rpc.declare({ object: 'zapret-manager', method: 'system_toggle_quic', expect: {} }); +var callSystemToggleIpv6 = rpc.declare({ object: 'zapret-manager', method: 'system_toggle_ipv6', expect: {} }); +var callSystemToggleFlowOffloadingFix = rpc.declare({ object: 'zapret-manager', method: 'system_toggle_flow_offloading_fix', expect: {} }); +var callSystemToggleExpertMode = rpc.declare({ object: 'zapret-manager', method: 'system_toggle_expert_mode', expect: {} }); +var callSystemUninstallPanel = rpc.declare({ object: 'zapret-manager', method: 'system_uninstall_panel', expect: {} }); +var callMirrorStatus = rpc.declare({ object: 'zapret-manager', method: 'mirror_status', expect: {} }); +var callMirrorSet = rpc.declare({ object: 'zapret-manager', method: 'mirror_set', params: ['id'], expect: {} }); +var callExclusionsStatus = rpc.declare({ object: 'zapret-manager', method: 'exclusions_status', expect: {} }); +var callExclusionsToggle = rpc.declare({ object: 'zapret-manager', method: 'exclusions_toggle', params: ['ip'], expect: {} }); +var callExclusionsClear = rpc.declare({ object: 'zapret-manager', method: 'exclusions_clear', expect: {} }); +var callTgStatus = rpc.declare({ object: 'zapret-manager', method: 'tg_status', expect: {} }); +var callTgAction = rpc.declare({ object: 'zapret-manager', method: 'tg_action', params: ['variant', 'action'], expect: {} }); +var callTgRestartAll = rpc.declare({ object: 'zapret-manager', method: 'tg_restart_all', expect: {} }); +var callHostsStatus = rpc.declare({ object: 'zapret-manager', method: 'hosts_status', expect: {} }); +var callHostsToggle = rpc.declare({ object: 'zapret-manager', method: 'hosts_toggle', params: ['block'], expect: {} }); +var callHostsReplaceGeohide = rpc.declare({ object: 'zapret-manager', method: 'hosts_replace_geohide', params: ['region'], expect: {} }); +var callHostsReset = rpc.declare({ object: 'zapret-manager', method: 'hosts_reset', expect: {} }); +var callDohStatus = rpc.declare({ object: 'zapret-manager', method: 'doh_status', expect: {} }); +var callDohInstall = rpc.declare({ object: 'zapret-manager', method: 'doh_install', expect: {} }); +var callDohRemove = rpc.declare({ object: 'zapret-manager', method: 'doh_remove', expect: {} }); +var callDohSet = rpc.declare({ object: 'zapret-manager', method: 'doh_set', params: ['provider'], expect: {} }); + +function injectCss() { + if (document.getElementById('zm-css')) return; + var l = document.createElement('link'); + l.id = 'zm-css'; + l.rel = 'stylesheet'; + l.href = L.resource('view/zapret-manager/style.css'); + document.head.appendChild(l); +} + +function badge(ok, textOk, textBad) { + var cls = ok ? 'zm-ok' : 'zm-bad'; + return E('span', { 'class': 'zm-badge ' + cls }, [ + E('span', { 'class': 'zm-dot' }), + ok ? textOk : textBad + ]); +} + +// Раскрашивает построчный вывод (==> сообщения) для читаемости: стрелки — +// голубым, обычные шаги — жёлтым, успешные/готово — зелёным, ошибки — красным, +// !! предупреждения — оранжевым; необработанный вывод команд — приглушённым серым. +function renderLog(logEl, text) { + logEl.innerHTML = ''; + var lines = (text || '').split('\n'); + lines.forEach(function(line) { + if (line === '') return; + var div = document.createElement('div'); + var m = line.match(/^(==>)\s?(.*)$/); + if (m) { + var arrow = document.createElement('span'); + arrow.className = 'zm-log-arrow'; + arrow.textContent = m[1] + ' '; + div.appendChild(arrow); + var msgSpan = document.createElement('span'); + var msg = m[2]; + if (/ОШИБКА|error|не удалось/i.test(msg)) msgSpan.className = 'zm-log-msg-error'; + else if (/Готово|успешно|примен[её]н|установлен|удал[её]н|сгенерирован|сохран[её]н|переключен|обновлен|завершен/i.test(msg)) msgSpan.className = 'zm-log-msg-ok'; + else msgSpan.className = 'zm-log-msg-info'; + msgSpan.textContent = msg; + div.appendChild(msgSpan); + } else if (/^!!/.test(line)) { + div.className = 'zm-log-msg-warn'; + div.textContent = line; + } else { + div.className = 'zm-log-code'; + div.textContent = line; + } + logEl.appendChild(div); + }); + logEl.scrollTop = logEl.scrollHeight; +} + +function pollJob(job, logEl, onDone, onTick) { + logEl.classList.add('zm-show'); + var failCount = 0; + var timer = setInterval(function() { + Promise.all([ callJobStatus(job), callLogTail(job) ]).then(function(res) { + failCount = 0; + var st = res[0], lg = res[1]; + renderLog(logEl, (lg && lg.lines) || ''); + if (typeof onTick === 'function') onTick(); + if (st && st.done === true) { + clearInterval(timer); + onDone(st.rc === '0'); + } + }).catch(function() { + // Одиночная неудача опроса (роутер занят распаковкой/установкой — + // временная заминка) не должна навсегда останавливать поллинг: + // сама операция на роутере продолжает идти в фоне независимо от + // того, отвечает ли сейчас веб-интерфейс. Останавливаемся только + // после нескольких подряд неудач и явно предупреждаем. + failCount++; + if (failCount >= 8) { + clearInterval(timer); + toast('Роутер не отвечает — операция может ещё выполняться в фоне. Обновите страницу через полминуты, чтобы проверить результат.', 'warning', 25000); + } + }); + }, 1200); +} + +function refreshBanner(message) { + return E('div', { 'class': 'zm-refresh-banner zm-show' }, [ + E('span', {}, message || 'Список меню LuCI мог измениться — выйдите и зайдите заново, чтобы увидеть изменения.'), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + // Разрываем сессию в фоне, затем перезагружаем ИМЕННО текущую + // страницу (а не отдельный URL logout) — LuCI при недействительной + // сессии сама показывает форму входа прямо на этом адресе и после + // успешного входа возвращает сюда же (штатное поведение при + // истечении сессии, тут просто используем его напрямую). + fetch(L.url('admin/logout'), { credentials: 'same-origin' }).catch(function() {}).then(function() { + location.reload(); + }); + } + }, 'Выйти из LuCI') + ]); +} + +// ---------- собственные всплывающие уведомления (не системные LuCI) ---------- + +function toastContainer() { + var c = document.getElementById('zm-toast-container'); + if (!c) { + c = document.createElement('div'); + c.id = 'zm-toast-container'; + document.body.appendChild(c); + } + return c; +} + +function toast(message, kind, duration) { + var container = toastContainer(); + var el = E('div', { 'class': 'zm-toast zm-toast-' + (kind || 'info') }, [ + E('span', { 'class': 'zm-toast-icon' }, kind === 'error' ? '✕' : (kind === 'warning' ? '!' : '✓')), + E('span', { 'class': 'zm-toast-text' }, message) + ]); + container.appendChild(el); + requestAnimationFrame(function() { el.classList.add('zm-toast-show'); }); + var hide = function() { + el.classList.remove('zm-toast-show'); + setTimeout(function() { el.parentNode && el.parentNode.removeChild(el); }, 250); + }; + el.addEventListener('click', hide); + setTimeout(hide, duration || (kind === 'error' ? 14000 : 9000)); +} + +function notifyStrategyResult(res, okLabel) { + if (res.error) { + toast(res.error, 'error'); + return false; + } + toast(okLabel + ' применена', 'info'); + return true; +} + +return baseclass.extend({ + injectCss: injectCss, + badge: badge, + pollJob: pollJob, + renderLog: renderLog, + refreshBanner: refreshBanner, + toast: toast, + notifyStrategyResult: notifyStrategyResult, + status: callStatus, + systemInfo: callSystemInfo, + jobStatus: callJobStatus, + logTail: callLogTail, + zapretAction: callZapretAction, + zapretLatestVersion: callZapretLatestVersion, + zapret2Action: callZapret2Action, + strategyListV: callStrategyListV, + strategySetV: callStrategySetV, + strategyListFlowseal: callStrategyListFlowseal, + strategySetFlowseal: callStrategySetFlowseal, + strategyListYoutube: callStrategyListYoutube, + strategySetYoutube: callStrategySetYoutube, + discordStatus: callDiscordStatus, + discordSetDv: callDiscordSetDv, + discordSetFake: callDiscordSetFake, + gameStatus: callGameStatus, + gameSet: callGameSet, + gameSetFake: callGameSetFake, + gameToggleXtreme: callGameToggleXtreme, + systemStatus: callSystemStatus, + systemCheckConnectivity: callSystemCheckConnectivity, + systemToggleQuic: callSystemToggleQuic, + systemToggleIpv6: callSystemToggleIpv6, + systemToggleFlowOffloadingFix: callSystemToggleFlowOffloadingFix, + systemToggleExpertMode: callSystemToggleExpertMode, + systemUninstallPanel: callSystemUninstallPanel, + mirrorStatus: callMirrorStatus, + mirrorSet: callMirrorSet, + exclusionsStatus: callExclusionsStatus, + exclusionsToggle: callExclusionsToggle, + exclusionsClear: callExclusionsClear, + tgStatus: callTgStatus, + tgAction: callTgAction, + tgRestartAll: callTgRestartAll, + hostsStatus: callHostsStatus, + hostsToggle: callHostsToggle, + hostsReplaceGeohide: callHostsReplaceGeohide, + hostsReset: callHostsReset, + dohStatus: callDohStatus, + dohInstall: callDohInstall, + dohRemove: callDohRemove, + dohSet: callDohSet +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/dashboard.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require ui'; +'require zapret-manager.common as zm'; + +return view.extend({ + load: function() { + zm.injectCss(); + return Promise.all([ + zm.status(), + zm.dohStatus().catch(function() { return {}; }), + zm.hostsStatus().catch(function() { return { items: [] }; }), + zm.systemStatus().catch(function() { return {}; }), + zm.zapretLatestVersion().catch(function() { return {}; }), + zm.systemInfo().catch(function() { return {}; }) + ]); + }, + + render: function(all) { + var view = this; + var data = all[0], dohData = all[1], hostsData = all[2], sysData = all[3]; + var latestVersion = (all[4] && all[4].version) || ''; + var sysInfo = all[5] || {}; + var wrap = E('div', { 'class': 'zm-wrap' }); + var overviewEl = E('div', {}); + var cards = E('div', { 'class': 'zm-cards' }); + var logEl = E('pre', { 'class': 'zm-log' }); + + var DOH_LABELS = { cloudflare: 'Cloudflare', google: 'Google', quad9: 'Quad9', xbox: 'XBOX', geohide_ru: 'GeoHide RU', geohide_eu: 'GeoHide EU', geohide_us: 'GeoHide US' }; + + function renderOverview(d, doh, hosts, sys) { + var hostsEnabled = (hosts.items || []).filter(function(it) { return it.enabled; }).length; + var hostsTotal = (hosts.items || []).length; + + var sysFlags = []; + if (sys.quic_blocked) sysFlags.push('QUIC заблокирован'); + if (sys.ipv6_enabled) sysFlags.push('IPv6 в Zapret включён'); + if (sys.flow_offloading_fix) sysFlags.push('Flow Offloading fix'); + + return E('div', { 'class': 'zm-card', 'style': 'margin-bottom:4px' }, [ + E('h3', {}, 'Обзор'), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Zapret'), + d.zapret === 'installed' + ? zm.badge(d.zapret_running === true, 'запущен', 'остановлен') + : zm.badge(false, '', 'не установлен') + ]), + d.zapret2 === 'installed' ? E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Zapret2'), + zm.badge(d.zapret2_running === true, 'запущен', 'остановлен') + ]) : E([]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'DNS over HTTPS'), + doh.installed + ? zm.badge(true, DOH_LABELS[doh.current] || doh.current || 'установлен, провайдер не определён', '') + : zm.badge(false, '', 'не установлен') + ]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Домены в hosts'), + hosts.geohide + ? zm.badge(true, 'GeoHide ' + hosts.geohide.toUpperCase(), '') + : hostsTotal + ? zm.badge(hostsEnabled > 0, hostsEnabled + ' из ' + hostsTotal + ' включено', 'ничего не включено') + : E('span', {}, '—') + ]), + sysFlags.length ? E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Система'), + E('span', { 'style': 'overflow-wrap:anywhere' }, sysFlags.join(', ')) + ]) : E([]) + ]); + } + + function renderCards(d) { + cards.innerHTML = ''; + + var zActions = []; + if (d.zapret === 'installed') { + zActions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': ui.createHandlerFn(view, 'doAction', 'remove') + }, 'Удалить')); + if (/^[0-9]+\.[0-9]+$/.test(latestVersion) && d.zapret_version && latestVersion !== d.zapret_version) { + zActions.push(E('button', { + 'class': 'cbi-button', + 'click': ui.createHandlerFn(view, 'doAction', 'update') + }, 'Обновить до ' + latestVersion)); + } + zActions.push(E('button', { + 'class': 'cbi-button', + 'click': ui.createHandlerFn(view, 'doAction', d.zapret_running ? 'stop' : 'start') + }, d.zapret_running ? 'Остановить' : 'Запустить')); + } else { + zActions.push(E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(view, 'doAction', 'install') + }, 'Установить')); + } + + var zCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Zapret'), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Статус'), + d.zapret === 'installed' + ? zm.badge(d.zapret_running === true, 'запущен', 'остановлен') + : zm.badge(false, '', 'не установлен') + ]), + d.zapret_version ? E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Версия'), E('span', {}, d.zapret_version) + ]) : E([]), + d.strategy ? E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Стратегия'), E('span', {}, d.strategy) + ]) : E([]), + E('div', { 'class': 'zm-actions' }, zActions) + ]); + + var z2Actions = []; + if (d.zapret2 === 'installed') { + z2Actions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': ui.createHandlerFn(view, 'doAction2', 'remove') + }, 'Удалить')); + z2Actions.push(E('button', { + 'class': 'cbi-button', + 'click': ui.createHandlerFn(view, 'doAction2', d.zapret2_running ? 'stop' : 'start') + }, d.zapret2_running ? 'Остановить' : 'Запустить')); + } else { + z2Actions.push(E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(view, 'doAction2', 'install') + }, 'Установить')); + } + + var z2Card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Zapret2'), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Статус'), + d.zapret2 === 'installed' + ? zm.badge(d.zapret2_running === true, 'запущен', 'остановлен') + : zm.badge(false, '', 'не установлен') + ]), + E('p', { 'class': 'zm-hint' }, 'Только для архитектуры aarch64_cortex-a53. Несовместим с Zapret.'), + E('div', { 'class': 'zm-actions' }, z2Actions) + ]); + + var sysCard = E('div', { 'class': 'zm-card' }, [ + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Модель:'), E('span', {}, sysInfo.model || '—') + ]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Архитектура:'), E('span', {}, sysInfo.arch || '—') + ]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'OpenWrt:'), E('span', {}, sysInfo.openwrt || '—') + ]), + E('p', { 'class': 'zm-hint', 'style': 'margin-top:10px; margin-bottom:2px' }, 'Место на роутере:'), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, '/tmp'), + E('span', {}, sysInfo.tmp_free ? ('занято ' + sysInfo.tmp_used + ' · свободно ' + sysInfo.tmp_free) : '—') + ]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, '/root'), + E('span', {}, sysInfo.root_free ? ('занято ' + sysInfo.root_used + ' · свободно ' + sysInfo.root_free) : '—') + ]) + ]); + + cards.appendChild(zCard); + cards.appendChild(z2Card); + cards.appendChild(sysCard); + } + + function refreshOverview() { + Promise.all([ + zm.status(), + zm.dohStatus().catch(function() { return {}; }), + zm.hostsStatus().catch(function() { return { items: [] }; }), + zm.systemStatus().catch(function() { return {}; }) + ]).then(function(res) { + overviewEl.innerHTML = ''; + overviewEl.appendChild(renderOverview(res[0], res[1], res[2], res[3])); + }); + } + + overviewEl.appendChild(renderOverview(data, dohData, hostsData, sysData)); + renderCards(data); + wrap.appendChild(E('div', { 'class': 'zm-header' }, [ + E('h2', {}, 'Zapret Manager'), + E('span', { 'class': 'zm-header-by' }, 'by StressOzz'), + E('div', { 'class': 'zm-header-links' }, [ + E('a', { 'href': 'http://stresskvn.lol/', 'target': '_blank', 'rel': 'noreferrer' }, 'StressKVN — обход белых списков!'), + E('a', { 'href': 'https://t.me/stressozz_manager', 'target': '_blank', 'rel': 'noreferrer' }, 'Сообщество Telegram') + ]) + ])); + wrap.appendChild(overviewEl); + wrap.appendChild(cards); + wrap.appendChild(logEl); + var bannerEl = E('div', {}); + wrap.appendChild(bannerEl); + + this.logEl = logEl; + this.bannerEl = bannerEl; + this.renderCards = renderCards; + this.refreshOverview = refreshOverview; + + return wrap; + }, + + doAction: function(action) { + var view = this; + if (view.busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + var job = (action === 'install' || action === 'update') ? 'install_zapret' + : (action === 'remove') ? 'remove_zapret' : null; + var LABELS = { install: 'Устанавливаем Zapret', update: 'Обновляем Zapret', remove: 'Удаляем Zapret', start: 'Запускаем Zapret', stop: 'Останавливаем Zapret' }; + zm.toast(LABELS[action] || 'Выполняем', 'warning'); + if (job) view.busy = true; + + zm.zapretAction(action).then(function(res) { + if (job && res && res.started) { + zm.pollJob(job, view.logEl, function(ok) { + view.busy = false; + zm.toast(ok ? 'Готово' : 'Операция завершилась с ошибкой', ok ? 'info' : 'error'); + zm.status().then(function(d) { view.renderCards(d); }); + view.refreshOverview(); + if (ok && (action === 'install' || action === 'remove')) { + view.bannerEl.innerHTML = ''; + view.bannerEl.appendChild(zm.refreshBanner('Пункт меню Zapret в LuCI мог измениться — выйдите и зайдите заново.')); + } + }); + } else if (res) { + view.busy = false; + view.renderCards(res); + view.refreshOverview(); + } + }).catch(function() { view.busy = false; }); + }, + + doAction2: function(action) { + var view = this; + if (view.busy2) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + var job = (action === 'install' || action === 'update') ? 'install_zapret2' + : (action === 'remove') ? 'remove_zapret2' : null; + var LABELS = { install: 'Устанавливаем Zapret2', update: 'Обновляем Zapret2', remove: 'Удаляем Zapret2', start: 'Запускаем Zapret2', stop: 'Останавливаем Zapret2' }; + zm.toast(LABELS[action] || 'Выполняем', 'warning'); + if (job) view.busy2 = true; + + zm.zapret2Action(action).then(function(res) { + if (job && res && res.started) { + zm.pollJob(job, view.logEl, function(ok) { + view.busy2 = false; + zm.toast(ok ? 'Готово' : 'Операция завершилась с ошибкой', ok ? 'info' : 'error'); + zm.status().then(function(d) { view.renderCards(d); }); + view.refreshOverview(); + if (ok && (action === 'install' || action === 'remove')) { + view.bannerEl.innerHTML = ''; + view.bannerEl.appendChild(zm.refreshBanner('Пункт меню Zapret2 в LuCI мог измениться — выйдите и зайдите заново.')); + } + }); + } else if (res) { + view.busy2 = false; + view.renderCards(res); + view.refreshOverview(); + } + }).catch(function() { view.busy2 = false; }); + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/discord.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +var FAKES = [ + 'stun.bin', 'stun2.bin', 'quic_initial_4pda_to.bin', + 'quic_initial_tencent_com.bin', 'tls_clienthello_sochi_park.bin', + 'quic_initial_www_google_com.bin', 'quic_initial_steamcommunity_com.bin', + 'quic_initial_5ka_ru.bin', 'quic_initial_rutube_ru.bin' +]; + +return view.extend({ + load: function() { + zm.injectCss(); + return zm.discordStatus(); + }, + + render: function(data) { + var wrap = E('div', { 'class': 'zm-wrap' }); + var dvGrid = E('div', { 'class': 'zm-grid' }); + var fakeGrid = E('div', { 'class': 'zm-grid' }); + + function renderDv() { + dvGrid.innerHTML = ''; + (data.available || []).forEach(function(dv) { + var num = dv.replace('Dv', ''); + dvGrid.appendChild(E('div', { + 'class': 'zm-tile' + (data.current === dv ? ' zm-active' : ''), + 'click': function() { + zm.toast('Применяем стратегию ' + dv + '', 'warning'); + zm.discordSetDv(num).then(function(res) { + if (!zm.notifyStrategyResult(res, dv)) return; + refreshState(); + }); + } + }, dv)); + }); + } + + function renderFake() { + fakeGrid.innerHTML = ''; + FAKES.forEach(function(f) { + fakeGrid.appendChild(E('div', { + 'class': 'zm-tile' + (data.current_fake === f ? ' zm-active' : ''), + 'click': function() { + zm.toast('Меняем fake-файл на ' + f + '', 'warning'); + zm.discordSetFake(f).then(function(res) { + if (!zm.notifyStrategyResult(res, f)) return; + refreshState(); + }); + } + }, f)); + }); + } + + function refreshState() { + zm.discordStatus().then(function(res) { + data = res; + renderDv(); + renderFake(); + }); + } + + renderDv(); + renderFake(); + + var dvCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Стратегия для discord.media'), + dvGrid, + E('p', { 'class': 'zm-hint' }, 'Нужна базовая стратегия с блоком discord.media') + ]); + + var fakeCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Fake-файл для discord,stun'), + fakeGrid + ]); + + wrap.appendChild(dvCard); + wrap.appendChild(fakeCard); + return wrap; + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/doh.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +var PROVIDERS = [ + { id: 'google', label: 'Google' }, + { id: 'cloudflare', label: 'Cloudflare' }, + { id: 'quad9', label: 'Quad9' }, + { id: 'xbox', label: 'XBOX' }, + { id: 'geohide_ru', label: 'GeoHide RU' }, + { id: 'geohide_eu', label: 'GeoHide EU' }, + { id: 'geohide_us', label: 'GeoHide US' } +]; + +return view.extend({ + load: function() { + zm.injectCss(); + return zm.dohStatus(); + }, + + render: function(data) { + var wrap = E('div', { 'class': 'zm-wrap' }); + var logEl = E('pre', { 'class': 'zm-log' }); + var bannerEl = E('div', {}); + var busy = false; + + var grid = E('div', { 'class': 'zm-grid' }); + function renderGrid() { + grid.innerHTML = ''; + PROVIDERS.forEach(function(p) { + grid.appendChild(E('div', { + 'class': 'zm-tile' + (data.current === p.id ? ' zm-active' : ''), + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + zm.toast('Меняем DNS на ' + p.label, 'warning'); + zm.dohSet(p.id).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(p.label + ' применён', 'info'); + zm.dohStatus().then(function(res2) { data = res2; renderGrid(); }); + }); + } + }, p.label)); + }); + } + renderGrid(); + + var installBtn = E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + zm.toast('Устанавливаем DNS over HTTPS', 'warning'); + busy = true; + zm.dohInstall().then(function(res) { + if (res.error) { busy = false; zm.toast(res.error, 'error'); return; } + if (res.started) { + zm.pollJob('doh_install', logEl, function(ok) { + busy = false; + zm.toast(ok ? 'DNS over HTTPS установлен' : 'Ошибка установки', ok ? 'info' : 'error'); + if (ok) { + bannerEl.innerHTML = ''; + bannerEl.appendChild(zm.refreshBanner('Пункт меню DNS over HTTPS в LuCI мог измениться — выйдите и зайдите заново.')); + } + }); + } else { + busy = false; + } + }).catch(function() { busy = false; }); + } + }, 'Установить DNS over HTTPS'); + + var removeBtn = E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + zm.toast('Удаляем DNS over HTTPS', 'warning'); + busy = true; + zm.dohRemove().then(function(res) { + if (res.error) { busy = false; zm.toast(res.error, 'error'); return; } + if (res.started) { + zm.pollJob('doh_remove', logEl, function(ok) { + busy = false; + zm.toast(ok ? 'DNS over HTTPS удалён' : 'Ошибка удаления', ok ? 'info' : 'error'); + if (ok) { + bannerEl.innerHTML = ''; + bannerEl.appendChild(zm.refreshBanner('Пункт меню DNS over HTTPS в LuCI мог измениться — выйдите и зайдите заново.')); + } + }); + } else { + busy = false; + } + }).catch(function() { busy = false; }); + } + }, 'Удалить'); + + var card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'DNS over HTTPS'), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Пакет'), + zm.badge(data.installed === true, 'установлен', 'не установлен') + ]), + E('div', { 'class': 'zm-actions' }, data.installed ? [ removeBtn ] : [ installBtn ]), + grid, + logEl + ]); + + wrap.appendChild(card); + wrap.appendChild(bannerEl); + return wrap; + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/exclusions.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +return view.extend({ + load: function() { + zm.injectCss(); + return zm.exclusionsStatus(); + }, + + render: function(data) { + var wrap = E('div', { 'class': 'zm-wrap' }); + + if (data.error) { + wrap.appendChild(E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Исключения IP'), + E('p', { 'class': 'zm-hint' }, data.error) + ])); + return wrap; + } + + var grid = E('div', { 'class': 'zm-grid' }); + + function renderGrid(devices) { + grid.innerHTML = ''; + (devices || []).forEach(function(d) { + grid.appendChild(E('div', { + 'class': 'zm-tile' + (d.excluded ? ' zm-active' : ''), + 'click': function() { + zm.toast('Переключаем исключение для ' + d.ip + '', 'warning'); + zm.exclusionsToggle(d.ip).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(d.ip + (res.excluded ? ' исключён' : ' больше не исключён'), 'info'); + zm.exclusionsStatus().then(function(r) { renderGrid(r.devices); }); + }); + } + }, [ E('div', {}, d.ip), E('div', { 'class': 'zm-hint' }, d.name) ])); + }); + if (!devices || !devices.length) + grid.appendChild(E('p', { 'class': 'zm-hint' }, 'Устройства не найдены (нет записей в /tmp/dhcp.leases).')); + } + + renderGrid(data.devices); + + var manualInput = E('input', { 'type': 'text', 'placeholder': '192.168.1.100', 'class': 'cbi-input-text' }); + + var card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Исключения IP из Zapret'), + grid, + E('p', { 'class': 'zm-hint' }, 'Клик по устройству — включить/выключить исключение (трафик этого IP не проходит через Zapret).'), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + zm.toast('Обновляем список устройств', 'warning'); + zm.exclusionsStatus().then(function(res) { + renderGrid(res.devices); + zm.toast('Список устройств обновлён', 'info'); + }); + } + }, 'Обновить список'), + manualInput, + E('button', { + 'class': 'cbi-button', + 'click': function() { + var ip = manualInput.value.trim(); + if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) { + zm.toast('Некорректный IPv4 адрес', 'error'); + return; + } + zm.toast('Добавляем ' + ip + ' в исключения', 'warning'); + zm.exclusionsToggle(ip).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + manualInput.value = ''; + zm.toast(ip + (res.excluded ? ' добавлен в исключения' : ' убран из исключений'), 'info'); + zm.exclusionsStatus().then(function(r) { renderGrid(r.devices); }); + }); + } + }, 'Добавить вручную'), + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { + zm.toast('Очищаем все исключения', 'warning'); + zm.exclusionsClear().then(function() { + zm.toast('Все исключения очищены', 'info'); + zm.exclusionsStatus().then(function(res) { renderGrid(res.devices); }); + }); + } + }, 'Очистить все') + ]) + ]); + + wrap.appendChild(card); + return wrap; + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/game.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +var FAKES = [ + 'stun.bin', 'stun2.bin', 'quic_initial_4pda_to.bin', + 'quic_initial_tencent_com.bin', 'tls_clienthello_sochi_park.bin', + 'quic_initial_www_google_com.bin', 'quic_initial_steamcommunity_com.bin', + 'quic_initial_5ka_ru.bin', 'quic_initial_rutube_ru.bin' +]; + +return view.extend({ + load: function() { + zm.injectCss(); + return zm.gameStatus(); + }, + + render: function(data) { + var wrap = E('div', { 'class': 'zm-wrap' }); + var gvGrid = E('div', { 'class': 'zm-grid' }); + var xtremeRow = E('div', {}); + var fakeGrid = E('div', { 'class': 'zm-grid' }); + + function renderGv() { + gvGrid.innerHTML = ''; + [1, 2, 3, 4].forEach(function(n) { + gvGrid.appendChild(E('div', { + 'class': 'zm-tile' + (data.current === ('Gv' + n) ? ' zm-active' : ''), + 'click': function() { + zm.toast('Применяем игровую стратегию Gv' + n + '', 'warning'); + zm.gameSet(String(n)).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(res.game === 'none' ? 'Игровая стратегия снята' : res.game + ' применена', 'info'); + refreshState(); + }); + } + }, 'Gv' + n)); + }); + } + + function renderXtreme() { + var xtreme = data.xtreme === true; + xtremeRow.innerHTML = ''; + xtremeRow.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Статус'), + zm.badge(xtreme, 'включён', 'выключен') + ])); + xtremeRow.appendChild(E('p', { 'class': 'zm-hint' }, 'Внимание: может повлиять на работу приложений и соединений — используйте только для проверки игр.')); + xtremeRow.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': xtreme ? 'cbi-button cbi-button-remove' : 'cbi-button cbi-button-positive', + 'click': function() { + zm.toast(xtreme ? 'Выключаем Xtreme' : 'Включаем Xtreme', 'warning'); + zm.gameToggleXtreme().then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(res.xtreme ? 'Xtreme включён' : 'Xtreme выключен', 'info'); + refreshState(); + }); + } + }, xtreme ? 'Выключить Xtreme' : 'Включить Xtreme') + ])); + } + + function renderFake() { + fakeGrid.innerHTML = ''; + FAKES.forEach(function(f) { + fakeGrid.appendChild(E('div', { + 'class': 'zm-tile' + (data.fake === f ? ' zm-active' : ''), + 'click': function() { + zm.toast('Меняем fake-файл на ' + f + '', 'warning'); + zm.gameSetFake(f).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(f + ' установлен', 'info'); + refreshState(); + }); + } + }, f)); + }); + } + + function refreshState() { + zm.gameStatus().then(function(res) { + data = res; + renderGv(); + renderXtreme(); + renderFake(); + }); + } + + renderGv(); + renderXtreme(); + renderFake(); + + var gvCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Игровая стратегия'), + gvGrid, + E('p', { 'class': 'zm-hint' }, 'Повторный клик по уже выбранной — снимает игровую стратегию.') + ]); + + var xtremeCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Xtreme режим'), + xtremeRow + ]); + + var fakeCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Fake-файл для игровой стратегии'), + fakeGrid, + E('p', { 'class': 'zm-hint' }, 'Доступно только если игровая стратегия уже выбрана.') + ]); + + wrap.appendChild(gvCard); + wrap.appendChild(xtremeCard); + wrap.appendChild(fakeCard); + return wrap; + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/hosts.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +var LABELS = { + nalog: 'Налог.ру', + rutor: 'RUTOR', + ntc: 'ntc.party', + instagram: 'Instagram & Facebook', + librusec: 'lib.rus.ec', + ai: 'AI сервисы (ChatGPT, Claude, Gemini)', + twitch: 'Twitch', + telegram: 'Telegram Web', + spotify: 'Spotify', + spotifyext: 'Spotify (расширенный)', + scell: 'Supercell (Clash, Brawl Stars)', + githubraw: 'githubusercontent.com', + github: 'GitHub', + ubisoft: 'Ubisoft', + tapeop: 'tapeop.dev', + roblox: 'Roblox', + updatesdiscord: 'Discord (updates.discord.com)' +}; + +return view.extend({ + load: function() { + zm.injectCss(); + return zm.hostsStatus(); + }, + + render: function(data) { + var wrap = E('div', { 'class': 'zm-wrap' }); + var grid = E('div', { 'class': 'zm-grid' }); + + function renderGrid(items) { + grid.innerHTML = ''; + (items || []).forEach(function(it) { + grid.appendChild(E('div', { + 'class': 'zm-tile' + (it.enabled ? ' zm-active' : ''), + 'click': function() { + zm.toast('Переключаем ' + (LABELS[it.id] || it.id) + '', 'warning'); + zm.hostsToggle(it.id).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast((LABELS[it.id] || it.id) + (res.enabled ? ' включён' : ' выключен'), 'info'); + zm.hostsStatus().then(function(r) { renderGrid(r.items); }); + }); + } + }, LABELS[it.id] || it.id)); + }); + } + + renderGrid(data.items); + + var card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Домены в /etc/hosts'), + grid, + E('p', { 'class': 'zm-hint' }, 'Нажмите на блок, чтобы включить или выключить прописанные IP этого сервиса.') + ]); + wrap.appendChild(card); + + var geoLogEl = E('pre', { 'class': 'zm-log' }); + var geoGrid = E('div', { 'class': 'zm-grid' }); + + function renderGeoGrid() { + geoGrid.innerHTML = ''; + geoGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.geohide === 'ru' ? ' zm-active' : ''), 'click': function() { replaceGeohide('ru'); } }, 'GeoHide RU')); + geoGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.geohide === 'eu' ? ' zm-active' : ''), 'click': function() { replaceGeohide('eu'); } }, 'GeoHide EU')); + geoGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.geohide === 'us' ? ' zm-active' : ''), 'click': function() { replaceGeohide('us'); } }, 'GeoHide US')); + } + renderGeoGrid(); + + var resetBtn = E('button', { 'class': 'cbi-button cbi-button-remove', 'click': resetHosts }, 'Восстановить hosts'); + + var geoCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Заменить hosts на GeoHide'), + E('p', { 'class': 'zm-hint' }, 'Внимание: это ПОЛНОСТЬЮ заменит файл /etc/hosts на список от GeoHide DNS — все блоки выше и любые ваши собственные записи будут удалены.'), + geoGrid, + E('p', { 'class': 'zm-hint' }, 'Или восстановить hosts к чистому виду. Уберёт и блоки выше, и GeoHide.'), + E('div', { 'class': 'zm-actions' }, [ resetBtn ]), + geoLogEl + ]); + wrap.appendChild(geoCard); + + function refreshAll() { + zm.hostsStatus().then(function(res) { + data = res; + renderGrid(res.items); + renderGeoGrid(); + }); + } + + function replaceGeohide(region) { + zm.toast('Заменяем hosts на GeoHide ' + region.toUpperCase() + '', 'warning'); + geoLogEl.classList.add('zm-show'); + zm.renderLog(geoLogEl, '==> Скачиваем и заменяем /etc/hosts'); + zm.hostsReplaceGeohide(region).then(function(res) { + if (res.error) { zm.renderLog(geoLogEl, '==> ОШИБКА: ' + res.error); zm.toast(res.error, 'error'); return; } + zm.renderLog(geoLogEl, '==> Готово — hosts заменён на GeoHide ' + region.toUpperCase() + '.'); + zm.toast('hosts заменён на GeoHide ' + region.toUpperCase(), 'info'); + refreshAll(); + }); + } + + function resetHosts() { + zm.toast('Восстанавливаем hosts', 'warning'); + geoLogEl.classList.add('zm-show'); + zm.renderLog(geoLogEl, '==> Восстанавливаем hosts'); + zm.hostsReset().then(function(res) { + if (res.error) { zm.renderLog(geoLogEl, '==> ОШИБКА: ' + res.error); zm.toast(res.error, 'error'); return; } + zm.renderLog(geoLogEl, '==> Готово — hosts восстановлен.'); + zm.toast('hosts восстановлен', 'info'); + refreshAll(); + }); + } + + return wrap; + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/strategy.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +return view.extend({ + load: function() { + zm.injectCss(); + return Promise.all([ zm.status(), zm.strategyListV() ]); + }, + + render: function(data) { + var status = data[0], vList = data[1]; + var lastFlowseal = null; + var wrap = E('div', { 'class': 'zm-wrap' }); + var logEl = E('pre', { 'class': 'zm-log' }); + + var currentBanner = E('div', { 'class': 'zm-current-banner' }); + wrap.appendChild(currentBanner); + + var vGrid = E('div', { 'class': 'zm-grid' }); + var vCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Стратегии v1 – v10'), + vGrid + ]); + + var fGrid = E('div', { 'class': 'zm-grid' }); + var fCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Стратегии Flowseal'), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + zm.toast('Обновляем список Flowseal', 'warning'); + fGrid.innerHTML = 'Загрузка списка'; + zm.strategyListFlowseal().then(function(res) { + if (res.started) { + zm.pollJob('flowseal_download', logEl, function(ok) { + zm.toast(ok ? 'Список Flowseal обновлён' : 'Не удалось обновить список', ok ? 'info' : 'error'); + zm.strategyListFlowseal().then(renderFlowseal); + }); + } else { + renderFlowseal(res); + zm.toast('Список Flowseal обновлён', 'info'); + } + }); + } + }, 'Обновить список') + ]), + fGrid + ]); + + function renderBanner() { + currentBanner.className = 'zm-current-banner' + (status.strategy ? '' : ' zm-current-empty'); + currentBanner.innerHTML = ''; + if (status.strategy) { + currentBanner.appendChild(E('span', {}, 'Сейчас применено: ')); + currentBanner.appendChild(E('b', {}, status.strategy)); + } else { + currentBanner.appendChild(E('span', {}, 'Стратегия ещё не выбрана')); + } + } + + function renderV() { + vGrid.innerHTML = ''; + (vList.items || []).forEach(function(it) { + var words = (' ' + (status.strategy || '') + ' '); + vGrid.appendChild(E('div', { + 'class': 'zm-tile' + (!status.flowseal && words.indexOf(' ' + it.id + ' ') !== -1 ? ' zm-active' : ''), + 'click': function() { + zm.toast('Применяем стратегию ' + it.id + '', 'warning'); + zm.strategySetV(it.id).then(function(res) { + if (!zm.notifyStrategyResult(res, it.id)) return; + refreshState(); + }); + } + }, it.id)); + }); + } + + function renderFlowseal(res) { + lastFlowseal = res; + fGrid.innerHTML = ''; + (res.items || []).forEach(function(it) { + fGrid.appendChild(E('div', { + 'class': 'zm-tile' + (status.flowseal === it.id ? ' zm-active' : ''), + 'click': function() { + zm.toast('Применяем стратегию ' + it.label + '', 'warning'); + zm.strategySetFlowseal(it.id).then(function(r2) { + if (!zm.notifyStrategyResult(r2, it.id)) return; + refreshState(); + }); + } + }, it.label)); + }); + if (!res.items || !res.items.length) + fGrid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список пуст — нажмите «Обновить список».')); + } + + function refreshState() { + zm.status().then(function(s) { + status = s; + renderBanner(); + renderV(); + if (lastFlowseal) renderFlowseal(lastFlowseal); + }); + } + + renderBanner(); + renderV(); + + wrap.appendChild(vCard); + wrap.appendChild(fCard); + wrap.appendChild(logEl); + + zm.strategyListFlowseal().then(function(res) { + if (!res.started) renderFlowseal(res); + else fGrid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список ещё не загружен — нажмите «Обновить список».')); + }); + + return wrap; + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/style.css' << 'ZM_INSTALLER_EOF' +.zm-wrap { display: flex; flex-direction: column; gap: 16px; max-width: 1100px; } + +.zm-header { display: flex; align-items: baseline; gap: 10px; margin-bottom: -4px; flex-wrap: wrap; } +.zm-header h2 { margin: 0; font-size: 22px; font-weight: 700; } +.zm-header-by { font-size: 13px; opacity: .55; } +.zm-header-links { display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; } +.zm-header-links a { + font-size: 12.5px; font-weight: 600; text-decoration: none; + color: #229ed9; background: rgba(34,158,217,.1); border: 1px solid rgba(34,158,217,.25); + border-radius: 999px; padding: 5px 13px; transition: background .15s; +} +.zm-header-links a:hover { background: rgba(34,158,217,.18); } + +.zm-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; } + +.zm-card { + background: var(--background-color-medium, #fff); + border: 1px solid rgba(0,0,0,.08); + border-radius: 12px; + padding: 18px 20px; + box-shadow: 0 1px 3px rgba(0,0,0,.05), 0 1px 2px rgba(0,0,0,.04); + overflow-wrap: break-word; + transition: box-shadow .15s; +} +.zm-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.08); } + +.zm-card h3 { margin: 0 0 12px 0; font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; } + +/* Строка "подпись: значение" — подпись и значение стоят РЯДОМ (умеренный + отступ), а не растянуты в разные концы широкой карточки. */ +.zm-row { display: flex; align-items: center; gap: 12px; margin: 7px 0; font-size: 13px; flex-wrap: wrap; } +.zm-row .zm-label { opacity: .65; flex-shrink: 0; } +.zm-row > span:last-child { overflow-wrap: anywhere; } + +.zm-badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; } +.zm-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; } + +.zm-ok { background: rgba(46,160,67,.12); color: #1a7f37; } +.zm-ok .zm-dot { background: #1a7f37; } +.zm-bad { background: rgba(207,34,46,.10); color: #cf222e; } +.zm-bad .zm-dot { background: #cf222e; } +.zm-warn { background: rgba(191,135,0,.12); color: #9a6700; } +.zm-warn .zm-dot { background: #9a6700; } +.zm-off { background: rgba(110,118,129,.12); color: #57606a; } +.zm-off .zm-dot { background: #57606a; } + +.zm-actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 14px 0; } +.zm-actions .cbi-button { margin: 0; } + +/* Плитки: flex вместо grid — компактные "чипы" по контенту, а не растянутые + на всю колонку, аккуратно переносятся на новую строку. */ +.zm-grid { display: flex; flex-wrap: wrap; gap: 9px; } + +.zm-tile { + flex: 0 1 auto; + min-width: 90px; + max-width: 100%; + border: 1px solid rgba(0,0,0,.1); + border-radius: 9px; + padding: 10px 16px; + cursor: pointer; + text-align: center; + font-size: 13px; + line-height: 1.35; + overflow-wrap: break-word; + transition: border-color .15s, background .15s, transform .1s; + background: var(--background-color-low, #fafafa); +} +.zm-tile:hover { border-color: #1a7f37; transform: translateY(-1px); } +.zm-tile.zm-active { + border-color: #1a7f37; background: rgba(26,127,55,.16); + font-weight: 700; color: #15803d; + box-shadow: 0 0 0 2px rgba(26,127,55,.35); +} +.zm-tile.zm-active::before { content: "✓ "; } +.zm-tile.zm-tile-pending { opacity: .55; border-style: dashed; cursor: not-allowed; } +.zm-tile.zm-tile-pending:hover { border-color: rgba(0,0,0,.1); transform: none; } + +/* Живой вывод действий (установка/тест/и т.д.) — большая, ясно видимая + консоль вместо мелкой полоски: крупнее шрифт, больше высоты, чёткая рамка. + white-space: pre-wrap — длинные строки (URL и т.п.) переносятся ВНИЗ, + а не вылезают вправо за пределы карточки. */ +.zm-log { + background: #0d1117; color: #e6edf3; + font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", monospace; + font-size: 13.5px; line-height: 1.7; + border: 1px solid rgba(255,255,255,.10); + border-radius: 10px; padding: 16px 18px; + white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; + max-height: 520px; min-height: 220px; + overflow-x: hidden; overflow-y: auto; + margin-top: 14px; display: none; + box-shadow: inset 0 0 0 1px rgba(0,0,0,.2); +} +.zm-log.zm-show { display: block; } +.zm-log:empty::before { content: "Ожидание вывода..."; opacity: .4; } + +/* Раскраска построчного вывода (==> сообщения) для читаемости. */ +.zm-log-arrow { color: #56d4dd; font-weight: 700; } +.zm-log-msg-info { color: #e3c04a; } +.zm-log-msg-ok { color: #3fb950; font-weight: 600; } +.zm-log-msg-error { color: #ff7b72; font-weight: 600; } +.zm-log-msg-warn { color: #ffa657; } +.zm-log-code { color: #8b949e; } + +.zm-hint { font-size: 12px; opacity: .65; margin-top: 6px; line-height: 1.5; overflow-wrap: break-word; } + +/* Уведомление о необходимости обновить страницу LuCI после установки/удаления + пакетов, добавляющих собственные меню (luci-app-*). */ +.zm-refresh-banner { + display: flex; align-items: center; justify-content: space-between; gap: 14px; + background: rgba(191,135,0,.12); border: 2px solid rgba(191,135,0,.35); + color: #9a6700; border-radius: 12px; padding: 16px 20px; font-size: 15px; font-weight: 500; + margin-top: 12px; +} +.zm-refresh-banner button { flex-shrink: 0; } + +/* Собственные всплывающие уведомления — вместо системных баннеров LuCI. + Сделаны крупными и заметными по запросу (увеличены повторно). */ +#zm-toast-container { + position: fixed; top: 20px; right: 20px; z-index: 10000; + display: flex; flex-direction: column; gap: 14px; + max-width: 520px; +} +.zm-toast { + display: flex; align-items: flex-start; gap: 14px; + background: #1c2128; color: #e6edf3; + padding: 22px 26px; border-radius: 14px; + box-shadow: 0 10px 40px rgba(0,0,0,.4); + font-size: 17px; line-height: 1.5; font-weight: 500; cursor: pointer; + opacity: 0; transform: translateX(24px); + transition: opacity .22s ease, transform .22s ease; + border-left: 6px solid #1a7f37; +} +.zm-toast-show { opacity: 1; transform: translateX(0); } +.zm-toast-error { border-left-color: #cf222e; } +.zm-toast-warning { border-left-color: #9a6700; } +.zm-toast-icon { flex-shrink: 0; font-weight: 700; font-size: 22px; line-height: 1.3; } +.zm-toast-info .zm-toast-icon { color: #3fb950; } +.zm-toast-error .zm-toast-icon { color: #ff7b72; } +.zm-toast-warning .zm-toast-icon { color: #e3b341; } +.zm-toast-text { overflow-wrap: anywhere; } + +/* Крупная плитка со ссылкой для вставки в Telegram (tg://proxy / tg://socks) — + отдельно от узких карточек статуса, чтобы длинная ссылка помещалась целиком. */ +.zm-tg-link-card { + background: rgba(26,127,55,.06); + border: 1px solid rgba(26,127,55,.25); + border-radius: 12px; + padding: 16px 18px; + margin-bottom: 12px; +} +.zm-tg-link-card h4 { margin: 0 0 8px 0; font-size: 14px; font-weight: 700; } +.zm-tg-link-hint { font-size: 12.5px; opacity: .7; margin-bottom: 10px; } +.zm-tg-link-box { + background: #0d1117; color: #7ee787; + font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace; + font-size: 14.5px; line-height: 1.6; + border-radius: 8px; padding: 12px 14px; + white-space: pre-wrap; word-break: break-all; overflow-wrap: anywhere; + margin-bottom: 8px; +} +.zm-tg-link-row { display: flex; align-items: flex-start; gap: 10px; } +.zm-tg-link-row .zm-tg-link-box { flex: 1 1 auto; } + +/* Заметный индикатор "что сейчас используется/применено" на страницах + стратегий/DoH/hosts/fake. */ +.zm-current-banner { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + background: rgba(26,127,55,.07); border: 1px solid rgba(26,127,55,.22); + border-radius: 10px; padding: 10px 16px; font-size: 13px; margin-bottom: 4px; +} +.zm-current-banner b { font-weight: 700; } +.zm-current-banner.zm-current-empty { + background: rgba(110,118,129,.08); border-color: rgba(110,118,129,.2); +} + +/* Наши страницы не используют UCI-формы LuCI (никакие uci.set/save не + вызываются) — стандартная панель "Save & Apply / Save / Reset" здесь + ничего не делает и только сбивает с толку. Скрываем её, пока наш CSS + загружен (то есть только на страницах Zapret Manager — при переходе на + любую другую страницу LuCI загружается заново без нашего style.css, и + панель снова появится там, где она реально нужна). */ +.cbi-page-actions { display: none !important; } +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/system.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +var MIRRORS = [ + { id: 'infra', label: 'Infra OpenWrt' }, + { id: 'brazil', label: 'Brazil' }, + { id: 'china', label: 'China' }, + { id: 'france', label: 'France' }, + { id: 'italy', label: 'Italy' }, + { id: 'morocco', label: 'Morocco' }, + { id: 'usa', label: 'USA' }, + { id: 'germany_rwth', label: 'Germany' }, + { id: 'default', label: 'default / OpenWrt' } +]; + +return view.extend({ + load: function() { + zm.injectCss(); + return Promise.all([ zm.systemStatus(), zm.mirrorStatus() ]); + }, + + render: function(data) { + var sysData = data[0], mirrorData = data[1]; + var view = this; + var wrap = E('div', { 'class': 'zm-wrap' }); + var netEl = E('div', {}, [ E('p', { 'class': 'zm-hint' }, 'Нажмите «Проверить», чтобы протестировать IPv4/IPv6') ]); + + function renderStatusCard(d) { + var card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Система'), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Блокировка QUIC'), + zm.badge(d.quic_blocked === true, 'включена', 'выключена') + ]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'IPv6 в Zapret'), + zm.badge(d.ipv6_enabled === true, 'включён', 'выключен') + ]), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Fix для Flow Offloading'), + zm.badge(d.flow_offloading_fix === true, 'применён', 'не применён') + ]), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': d.quic_blocked ? 'cbi-button cbi-button-remove' : 'cbi-button', + 'click': function() { + zm.toast(d.quic_blocked ? 'Выключаем блокировку QUIC' : 'Включаем блокировку QUIC', 'warning'); + zm.systemToggleQuic().then(function(res) { + zm.toast(d.quic_blocked ? 'Блокировка QUIC выключена' : 'Блокировка QUIC включена', 'info'); + zm.systemStatus().then(refresh); + }); + } + }, d.quic_blocked ? 'Выключить блокировку QUIC' : 'Включить блокировку QUIC'), + E('button', { + 'class': 'cbi-button', + 'click': function() { + zm.toast(d.ipv6_enabled ? 'Выключаем IPv6 в Zapret' : 'Включаем IPv6 в Zapret', 'warning'); + zm.systemToggleIpv6().then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(res.ipv6_enabled ? 'IPv6 в Zapret включён' : 'IPv6 в Zapret выключен', 'info'); + zm.systemStatus().then(refresh); + }); + } + }, d.ipv6_enabled ? 'Выключить IPv6 в Zapret' : 'Включить IPv6 в Zapret'), + E('button', { + 'class': 'cbi-button', + 'click': function() { + zm.toast(d.flow_offloading_fix ? 'Отключаем Fix Flow Offloading' : 'Применяем Fix Flow Offloading', 'warning'); + zm.systemToggleFlowOffloadingFix().then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast(res.flow_offloading_fix ? 'Fix для Flow Offloading применён' : 'Fix для Flow Offloading отключён', 'info'); + zm.systemStatus().then(refresh); + }); + } + }, d.flow_offloading_fix ? 'Отключить Fix Flow Offloading' : 'Применить Fix Flow Offloading') + ]) + ]); + return card; + } + + function refresh(d) { + var newCard = renderStatusCard(d); + wrap.replaceChild(newCard, wrap.firstChild); + } + + wrap.appendChild(renderStatusCard(sysData)); + + var netCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Проверка сети'), + netEl, + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + netEl.textContent = 'Проверяем'; + zm.toast('Проверяем IPv4/IPv6', 'warning'); + zm.systemCheckConnectivity().then(function(res) { + netEl.innerHTML = ''; + netEl.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'IPv4 (google.com)'), + zm.badge(res.ipv4_ok === true, 'ok, ' + res.ipv4_ms + ' ms', 'недоступен') + ])); + netEl.appendChild(E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'IPv6 (google.com)'), + zm.badge(res.ipv6_ok === true, 'ok, ' + res.ipv6_ms + ' ms', 'недоступен') + ])); + }); + } + }, 'Проверить IPv4 / IPv6') + ]) + ]); + wrap.appendChild(netCard); + + var mirrorGrid = E('div', { 'class': 'zm-grid' }); + var mirrorCurrentEl = E('span', {}, mirrorData.current); + var mirrorBusy = false; + MIRRORS.forEach(function(m) { + mirrorGrid.appendChild(E('div', { + 'class': 'zm-tile' + (mirrorData.current === m.label ? ' zm-active' : ''), + 'click': function() { + if (mirrorBusy) { zm.toast('Дождитесь завершения переключения зеркала', 'warning'); return; } + if (mirrorData.current === m.label) { zm.toast('Это зеркало уже выбрано', 'info'); return; } + mirrorBusy = true; + zm.toast('Переключаем зеркало на «' + m.label + '»', 'warning'); + mirrorLog.classList.add('zm-show'); + zm.renderLog(mirrorLog, '==> Проверяем и переключаем'); + zm.mirrorSet(m.id).then(function(res) { + if (res.error) { mirrorBusy = false; zm.toast(res.error, 'error'); return; } + zm.pollJob('mirror_set', mirrorLog, function(ok) { + mirrorBusy = false; + zm.toast(ok ? ('Зеркало переключено на «' + m.label + '»') : 'Не удалось переключить зеркало', ok ? 'info' : 'error'); + if (!ok) return; + zm.mirrorStatus().then(function(res2) { + mirrorData.current = res2.current; + mirrorCurrentEl.textContent = res2.current; + Array.prototype.forEach.call(mirrorGrid.children, function(el, i) { + el.classList.toggle('zm-active', MIRRORS[i].label === res2.current); + }); + }); + }); + }).catch(function() { mirrorBusy = false; }); + } + }, m.label)); + }); + var mirrorLog = E('pre', { 'class': 'zm-log' }); + var mirrorCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Зеркало OpenWrt'), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Текущее'), mirrorCurrentEl + ]), + mirrorGrid, + mirrorLog + ]); + wrap.appendChild(mirrorCard); + + var uninstallLog = E('pre', { 'class': 'zm-log' }); + var uninstallCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Удалить Zapret Manager из LuCI'), + E('p', { 'class': 'zm-hint' }, + 'Уберёт только веб-интерфейс (эту панель) — саму программу-оболочку из LuCI. ' + + 'Zapret, Zapret2, DNS over HTTPS и TG WS Proxy, если они были установлены через ' + + 'панель, останутся на роутере без изменений и продолжат работать.' + ), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { + if (!confirm('Удалить веб-интерфейс Zapret Manager из LuCI?\n\nСам Zapret и остальные установленные через панель компоненты не пострадают. Действие необратимо — панель придётся ставить заново.')) { + return; + } + uninstallLog.classList.add('zm-show'); + zm.renderLog(uninstallLog, '==> Удаляем веб-интерфейс Zapret Manager'); + zm.toast('Удаляем Zapret Manager из LuCI', 'warning'); + zm.systemUninstallPanel().then(function(res) { + if (res.error) { zm.renderLog(uninstallLog, '==> ОШИБКА: ' + res.error); zm.toast(res.error, 'error'); return; } + zm.renderLog(uninstallLog, '==> Готово. Панель удалена, страница больше не будет отвечать. Выходим из LuCI'); + zm.toast('Zapret Manager удалён из LuCI', 'info'); + setTimeout(function() { location.href = L.url('admin/logout'); }, 2500); + }).catch(function() { + zm.renderLog(uninstallLog, '==> Готово (соединение прервано — это ожидаемо, панель уже удалена). Выходим из LuCI'); + setTimeout(function() { location.href = L.url('admin/logout'); }, 2500); + }); + } + }, 'Удалить панель из LuCI') + ]), + uninstallLog + ]); + wrap.appendChild(uninstallCard); + + return wrap; + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/tgproxy.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +var VARIANTS = [ + { id: 'mtproto', title: 'MTProto (пакет)', port: 1443, kind: 'proto' }, + { id: 'socks5', title: 'SOCKS5', port: 2080, kind: 'socks' }, + { id: 'rust', title: 'Rust (MTProto)', port: 2443, kind: 'proto' } +]; + +function copyToClipboard(text) { + if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function() { + zm.toast('Ссылка скопирована', 'info'); + }).catch(function() { + fallbackCopy(text); + }); + } else { + fallbackCopy(text); + } +} + +function fallbackCopy(text) { + // LuCI обычно открыт по обычному http:// (не https), а Clipboard API работает + // только в защищённом контексте — используем старый, но надёжный способ через + // скрытое текстовое поле + document.execCommand('copy'), он работает и по http. + var ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '0'; + ta.style.left = '0'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + ta.setSelectionRange(0, text.length); + var ok = false; + try { ok = document.execCommand('copy'); } catch (e) { ok = false; } + document.body.removeChild(ta); + zm.toast(ok ? 'Ссылка скопирована' : 'Не удалось скопировать — выделите ссылку вручную', ok ? 'info' : 'warning'); +} + +return view.extend({ + load: function() { + zm.injectCss(); + return zm.tgStatus(); + }, + + render: function(data) { + var view = this; + var wrap = E('div', { 'class': 'zm-wrap' }); + var cards = E('div', { 'class': 'zm-cards' }); + var linksWrap = E('div', {}); + var logEl = E('pre', { 'class': 'zm-log' }); + + function statusOf(d, id) { + if (id === 'mtproto') return { installed: d.mtproto === 'installed', running: d.mtproto_running === true, secret: d.secret_mtproto }; + if (id === 'socks5') return { installed: d.socks5 === 'installed', running: d.socks5_running === true, secret: '' }; + return { installed: d.rust === 'installed', running: d.rust_running === true, secret: d.secret_rust }; + } + + function renderLinks(d) { + linksWrap.innerHTML = ''; + var any = false; + VARIANTS.forEach(function(v) { + var st = statusOf(d, v.id); + if (!st.installed || !st.running || !d.lan_ip) return; + any = true; + + var link, extra; + if (v.kind === 'socks') { + link = 'tg://socks?server=' + d.lan_ip + '&port=' + v.port; + extra = 'SOCKS5-адрес: ' + d.lan_ip + ':' + v.port; + } else { + var secretPrefixed = 'dd' + st.secret; + link = 'tg://proxy?server=' + d.lan_ip + '&port=' + v.port + '&secret=' + secretPrefixed; + extra = 'Хост: ' + d.lan_ip + ' Порт: ' + v.port + ' Ключ: ' + secretPrefixed; + } + + linksWrap.appendChild(E('div', { 'class': 'zm-tg-link-card' }, [ + E('h4', {}, v.title), + E('p', { 'class': 'zm-tg-link-hint' }, 'Откройте эту ссылку на телефоне/компьютере с Telegram — прокси подключится автоматически. Либо скопируйте и вставьте её в браузер/Telegram вручную.'), + E('div', { 'class': 'zm-tg-link-row' }, [ + E('div', { 'class': 'zm-tg-link-box' }, link), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { copyToClipboard(link); } + }, 'Скопировать') + ]), + E('p', { 'class': 'zm-hint' }, extra) + ])); + }); + if (!any) { + linksWrap.appendChild(E('p', { 'class': 'zm-hint' }, 'Ссылки появятся здесь после установки и запуска хотя бы одного варианта прокси ниже.')); + } + } + + function renderCards(d) { + cards.innerHTML = ''; + VARIANTS.forEach(function(v) { + var st = statusOf(d, v.id); + var actions = []; + if (st.installed) { + actions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { doAction(v.id, 'remove'); } + }, 'Удалить')); + actions.push(E('button', { + 'class': 'cbi-button', + 'click': function() { doAction(v.id, 'update'); } + }, 'Обновить')); + } else { + actions.push(E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { doAction(v.id, 'install'); } + }, 'Установить')); + } + cards.appendChild(E('div', { 'class': 'zm-card' }, [ + E('h3', {}, v.title), + E('div', { 'class': 'zm-row' }, [ + E('span', { 'class': 'zm-label' }, 'Статус'), + st.installed ? zm.badge(st.running, 'запущен', 'остановлен') : zm.badge(false, '', 'не установлен') + ]), + E('div', { 'class': 'zm-actions' }, actions) + ])); + }); + } + + function doAction(variantId, action) { + var job = action === 'remove' ? ('tg_remove_' + (variantId === 'mtproto' ? 'mtproto' : variantId)) + : ('tg_install_' + (variantId === 'mtproto' ? 'mtproto' : variantId)); + zm.toast((action === 'remove' ? 'Удаляем ' : action === 'update' ? 'Обновляем ' : 'Устанавливаем ') + variantId + '', 'warning'); + zm.tgAction(variantId, action).then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + if (res.started) { + zm.pollJob(job, logEl, function(ok) { + zm.toast(ok ? 'Готово' : 'Ошибка', ok ? 'info' : 'error'); + zm.tgStatus().then(function(d) { renderCards(d); renderLinks(d); }); + }); + } + }); + } + + renderLinks(data); + renderCards(data); + wrap.appendChild(linksWrap); + wrap.appendChild(cards); + wrap.appendChild(logEl); + + wrap.appendChild(E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Общие действия'), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + zm.toast('Перезапускаем TG WS Proxy', 'warning'); + zm.tgRestartAll().then(function() { + zm.toast('Все запущенные TG WS Proxy перезапущены', 'info'); + }); + } + }, 'Перезапустить все') + ]) + ])); + + return wrap; + } +}); +ZM_INSTALLER_EOF + +mkdir -p /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/youtube.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +return view.extend({ + load: function() { + zm.injectCss(); + return Promise.all([ zm.status(), zm.strategyListYoutube() ]); + }, + + render: function(data) { + var status = data[0], initial = data[1]; + var wrap = E('div', { 'class': 'zm-wrap' }); + var logEl = E('pre', { 'class': 'zm-log' }); + var grid = E('div', { 'class': 'zm-grid' }); + var lastList = null; + var current = ''; + + function currentYv(s) { + var words = (s.strategy || '').split(' '); + for (var i = 0; i < words.length; i++) { + if (/^Yv[0-9]+$/.test(words[i])) return words[i]; + } + return ''; + } + + function renderGrid(res) { + lastList = res; + grid.innerHTML = ''; + (res.items || []).forEach(function(it) { + grid.appendChild(E('div', { + 'class': 'zm-tile' + (current === it.id ? ' zm-active' : ''), + 'click': function() { + zm.toast('Применяем стратегию ' + it.id + '', 'warning'); + zm.strategySetYoutube(it.id).then(function(r2) { + if (!zm.notifyStrategyResult(r2, it.id)) return; + refreshState(); + }); + } + }, it.id)); + }); + if (!res.items || !res.items.length) + grid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список пуст — нажмите «Обновить список».')); + } + + function refreshState() { + zm.status().then(function(s) { + current = currentYv(s); + if (lastList) renderGrid(lastList); + }); + } + + current = currentYv(status); + + var card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, 'Стратегии для YouTube'), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button', + 'click': function() { + zm.toast('Обновляем список YouTube-стратегий', 'warning'); + grid.innerHTML = 'Загрузка списка'; + zm.strategyListYoutube().then(function(res) { + if (res.started) { + zm.pollJob('youtube_download', logEl, function(ok) { + zm.toast(ok ? 'Список YouTube-стратегий обновлён' : 'Не удалось обновить список', ok ? 'info' : 'error'); + zm.strategyListYoutube().then(renderGrid); + }); + } else { + renderGrid(res); + zm.toast('Список YouTube-стратегий обновлён', 'info'); + } + }); + } + }, 'Обновить список') + ]), + grid, + E('p', { 'class': 'zm-hint' }, 'Применяется поверх текущей стратегии — заменяет только YouTube-часть.') + ]); + + wrap.appendChild(card); + wrap.appendChild(logEl); + + if (!initial.started) { + renderGrid(initial); + } else { + grid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список ещё не загружен — нажмите «Обновить список».')); + } + + return wrap; + } +}); +ZM_INSTALLER_EOF + +rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null || true +/etc/init.d/rpcd restart >/dev/null 2>&1 +/etc/init.d/uhttpd restart >/dev/null 2>&1 +if command -v apk >/dev/null 2>&1; then PM="apk"; INSTALL="apk add" +else PM="opkg"; INSTALL="opkg install"; fi +command -v curl >/dev/null 2>&1 || $INSTALL curl >/dev/null 2>&1 || true +command -v unzip >/dev/null 2>&1 || $INSTALL unzip >/dev/null 2>&1 || true