From e166e933cbd342d1afd80830ac6e74391b6cbdbf Mon Sep 17 00:00:00 2001 From: StressOzz <131419324+StressOzz@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:24:36 +0500 Subject: [PATCH] v1.30 --- ZapretManager_LuCI.sh | 3435 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 3201 insertions(+), 234 deletions(-) diff --git a/ZapretManager_LuCI.sh b/ZapretManager_LuCI.sh index 32b7bff..b8634a4 100644 --- a/ZapretManager_LuCI.sh +++ b/ZapretManager_LuCI.sh @@ -1,6 +1,6 @@ #!/bin/sh # Zapret Manager by StressOzz for LuCI installer -# Version: 1.29 +# Version: 1.30 set -e GREEN="\033[1;32m"; CYAN="\033[1;36m"; YELLOW="\033[1;33m"; MAGENTA="\033[1;35m"; BLUE="\033[0;34m"; NC="\033[0m"; DGRAY="\033[38;5;244m" @@ -20,14 +20,17 @@ rm -f \ /www/luci-static/resources/view/zapret-manager/youtube.js \ /www/luci-static/resources/view/zapret-manager/game.js \ /www/luci-static/resources/view/zapret-manager/discord.js \ - /www/luci-static/resources/view/zapret-manager/exclusions.js 2>/dev/null + /www/luci-static/resources/view/zapret-manager/exclusions.js \ + /usr/share/luci/menu.d/luci-app-ytbypass.json \ + /usr/share/rpcd/acl.d/luci-app-ytbypass.json \ + /www/luci-static/resources/view/ytbypass/main.js 2>/dev/null mkdir -p /opt/zapret-manager-luci chmod 0755 /opt/zapret-manager-luci cat > '/opt/zapret-manager-luci/backend.sh' << 'ZM_INSTALLER_EOF' CONF="/etc/config/zapret" -ZM_VERSION="1.29" +ZM_VERSION="1.30" ZM_SCRIPT_URL="https://raw.githubusercontent.com/StressOzz/Zapret-Manager/refs/heads/main/ZapretManager_LuCI.sh" GH_RAW="https://raw.githubusercontent.com" GH_MAIN="https://github.com" @@ -274,7 +277,7 @@ do_install_zapret_full() { echo "==> Добавляем домены в hosts" local b - for b in ai instagram ntc librusec telegram twitch scell spotify; do + for b in ai instagram ntc librusec telegram twitch scell spotify rutor; do local content line content="$(_hosts_block "$b")" while IFS= read -r line; do @@ -896,21 +899,9 @@ _hosts_block() { "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" ;; + rutor) printf '%s\n' \ + "#rutor" \ + "172.66.159.63 rutor.info d.rutor.info" ;; scell) printf '%s\n' \ "#Supercell" \ "103.27.157.38 accounts.supercell.com cdn.id.supercell.com clashofclans.inbox.supercell.com game-assets.brawlstarsgame.com" \ @@ -970,7 +961,7 @@ _hosts_block_status() { } hosts_status() { - local blocks="nalog ntc instagram librusec ai twitch telegram spotify spotifyext scell githubraw github tapeop roblox" b first=1 + local blocks="nalog ntc instagram librusec ai twitch telegram spotify rutor scell githubraw github tapeop roblox" 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" @@ -1490,6 +1481,31 @@ exclusions_clear() { printf '{"ok":true}\n' } +EXCLUDE_DOMAINS_FILE="/opt/zapret/ipset/zapret-hosts-user-exclude.txt" + +exclusions_file_get() { + [ -f "$EXCLUDE_DOMAINS_FILE" ] || { printf '{"content":""}\n'; return 0; } + printf '{"content":"%s"}\n' "$(esc_ml "$(cat "$EXCLUDE_DOMAINS_FILE")")" +} + +exclusions_file_set() { + local content="$1" + [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + mkdir -p "$(dirname "$EXCLUDE_DOMAINS_FILE")" + printf '%s' "$content" > "$EXCLUDE_DOMAINS_FILE" + zapret_restart + printf '{"ok":true}\n' +} + +exclusions_file_restore() { + [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } + mkdir -p "$(dirname "$EXCLUDE_DOMAINS_FILE")" + rm -f "$EXCLUDE_DOMAINS_FILE" + wget -q --timeout=20 -U "Mozilla/5.0" -O "$EXCLUDE_DOMAINS_FILE" "$EXCLUDE_URL" + [ -s "$EXCLUDE_DOMAINS_FILE" ] || { echo '{"error":"не удалось скачать список исключений"}'; return 1; } + printf '{"content":"%s"}\n' "$(esc_ml "$(cat "$EXCLUDE_DOMAINS_FILE")")" +} + TG_MTPROTO_VER="0.10" TGWS_VERSION="0.2.5" @@ -1868,6 +1884,7 @@ _test_prepare_urls() { "facebook.com|https://facebook.com" \ "rutracker.org|https://rutracker.org" \ "nnmclub.to|https://nnmclub.to" \ + "rutor.info|https://rutor.info" \ "openwrt.org|https://openwrt.org" \ "discord.com|https://discord.com" \ "x.com|https://x.com" \ @@ -2922,6 +2939,1540 @@ mixomo_warp_integrate_action() { job_start mixomo_warp_integrate do_mixomo_warp_integrate } + +# ================================================================ ByeTube +BYEDPI_REPO="DPITrickster/ByeDPI-OpenWrt" + +_bytetube_fetch() { # URL OUT + if command -v curl >/dev/null 2>&1; then + curl -fsSL --connect-timeout 15 --max-time 90 -o "$2" "$1" + else + wget -q -T 20 -O "$2" "$1" + fi +} + +bytetube_installed() { + if [ -x /usr/bin/ytbypass ]; then + printf '{"installed":true}\n' + else + printf '{"installed":false}\n' + fi +} + +_bytetube_ensure_dnsmasq_full() { + if dnsmasq --version 2>/dev/null | grep -Eq '(^| )nftset( |$)'; then + echo "==> dnsmasq с поддержкой nftset уже установлен" + return 0 + fi + echo "==> Заменяю dnsmasq на dnsmasq-full (нужен nftset)" + cp /etc/config/dhcp /tmp/dhcp.ytb.bak 2>/dev/null + _pkg_is_installed dnsmasq && $DELETE dnsmasq >/dev/null 2>&1 + _pkg_is_installed dnsmasq-dhcpv6 && $DELETE dnsmasq-dhcpv6 >/dev/null 2>&1 + rm -f /tmp/resolv.conf + if grep -qs '^nameserver' /tmp/resolv.conf.d/resolv.conf.auto 2>/dev/null; then + cp /tmp/resolv.conf.d/resolv.conf.auto /tmp/resolv.conf + else + printf 'nameserver 1.1.1.1\nnameserver 8.8.8.8\n' > /tmp/resolv.conf + fi + if ! $INSTALL dnsmasq-full >/dev/null 2>&1; then + echo "!! не удалось поставить dnsmasq-full, возвращаю обычный dnsmasq" + rm -f /tmp/resolv.conf + if grep -qs '^nameserver' /tmp/resolv.conf.d/resolv.conf.auto 2>/dev/null; then + cp /tmp/resolv.conf.d/resolv.conf.auto /tmp/resolv.conf + else + printf 'nameserver 1.1.1.1\nnameserver 8.8.8.8\n' > /tmp/resolv.conf + fi + $INSTALL dnsmasq >/dev/null 2>&1 + [ -f /etc/config/dhcp ] || cp /tmp/dhcp.ytb.bak /etc/config/dhcp 2>/dev/null + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + echo "ОШИБКА: dnsmasq-full не установлен" + return 1 + fi + [ -f /etc/config/dhcp ] || cp /tmp/dhcp.ytb.bak /etc/config/dhcp 2>/dev/null + /etc/init.d/dnsmasq enable >/dev/null 2>&1 + /etc/init.d/dnsmasq restart >/dev/null 2>&1 +} + +_bytetube_install_byedpi() { + local arch rel_mm url json cands f + if [ -x /usr/bin/ciadpi ]; then + echo "==> ByeDPI уже установлен" + return 0 + fi + . /etc/openwrt_release + arch="$DISTRIB_ARCH" + rel_mm=$(echo "$DISTRIB_RELEASE" | cut -d. -f1,2) + echo "==> Ищу пакет byedpi ($arch, .$RAZ) в $BYEDPI_REPO" + json=$(_bytetube_fetch "https://api.github.com/repos/$BYEDPI_REPO/releases?per_page=40" - 2>/dev/null) + cands=$(printf '%s\n' "$json" \ + | grep -o '"browser_download_url": *"[^"]*"' \ + | sed 's/^[^:]*: *"//; s/"$//' \ + | grep -E "/byedpi_[^/]*_${arch}\.${RAZ}\$") + url=$(printf '%s\n' "$cands" | grep -F "$rel_mm" | head -n 1) + [ -n "$url" ] || url=$(printf '%s\n' "$cands" | head -n 1) + if [ -z "$url" ]; then + echo "ОШИБКА: не нашёл пакет byedpi для $arch (.$RAZ)" + return 1 + fi + echo "==> Скачиваю $url" + mkdir -p /tmp/ytb-dl + f="/tmp/ytb-dl/$(basename "$url")" + _bytetube_fetch "$url" "$f" || { echo "ОШИБКА: не удалось скачать byedpi"; return 1; } + if [ "$PKG" = apk ]; then apk add --allow-untrusted "$f"; else opkg install "$f"; fi \ + || { echo "ОШИБКА: не удалось установить byedpi"; return 1; } + NEW_BYEDPI=1 +} + +_bytetube_install_payload() { + mkdir -p /etc/config + [ -f /etc/config/ytbypass ] || cat > /etc/config/ytbypass <<'YTB_FILE_END_7f3a9c' +config ytbypass 'main' + option enabled '1' + # локальный порт SOCKS5 у ByeDPI (отдельный экземпляр, штатный byedpi не трогаем) + option byedpi_port '1088' + # стратегия обхода DPI, подбирается под провайдера (вкладка «Тест стратегий») + option byedpi_opts '-d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -r1+s -S -a1 -As -d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -S -a1' + # заворачивать IPv6-адреса YouTube (0 — только IPv4) + option ipv6 '1' + # QUIC (UDP/443): block — отбрасывать, чтобы клиент откатился на TCP; proxy — гнать через ByeDPI + option quic 'block' + # использовать встроенный список доменов YouTube + option default_domains '1' + # дополнительные домены: + # list domain 'example.com' +YTB_FILE_END_7f3a9c + chmod 644 /etc/config/ytbypass + mkdir -p /etc/hotplug.d/firewall + cat > /etc/hotplug.d/firewall/90-ytbypass <<'YTB_FILE_END_7f3a9c' +#!/bin/sh +# Если после reload firewall наша таблица пропала — восстановить. +[ -f /var/run/ytbypass.started ] || exit 0 +nft list table inet ytbypass >/dev/null 2>&1 && exit 0 +logger -t ytbypass "таблица nft пропала после reload firewall — восстанавливаю" +/etc/init.d/ytbypass restart >/dev/null 2>&1 +exit 0 +YTB_FILE_END_7f3a9c + chmod 755 /etc/hotplug.d/firewall/90-ytbypass + mkdir -p /etc/init.d + cat > /etc/init.d/ytbypass <<'YTB_FILE_END_7f3a9c' +#!/bin/sh /etc/rc.common +# YouTube Bypass: ByeDPI + hev-socks5-tunnel, маршрутизация только доменов YouTube + +START=99 +STOP=10 +USE_PROCD=1 + +NAME=ytbypass +LIBEXEC=/usr/libexec/ytbypass +RUNDIR=/var/etc/ytbypass +DOMAINS_DEFAULT=/usr/share/ytbypass/domains.list +STARTED_FLAG=/var/run/ytbypass.started + +. "$LIBEXEC/common.sh" + +log() { logger -t "$NAME" "$*"; } +_echo() { echo "$1"; } + +# итоговый список доменов: встроенный + свои, только валидные имена +collect_domains() { + local use_default + config_get use_default main default_domains 1 + { + [ "$use_default" = "1" ] && [ -f "$DOMAINS_DEFAULT" ] && cat "$DOMAINS_DEFAULT" + config_list_foreach main domain _echo + } | sed 's/#.*//; s/^[[:space:]]*//; s/[[:space:]]*$//' \ + | tr 'A-Z' 'a-z' \ + | grep -E '^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$' \ + | sort -u \ + | awk ' + { name[NR] = $0; have[$0] = 1 } + END { + for (i = 1; i <= NR; i++) { + n = split(name[i], p, ".") + suf = ""; redundant = 0 + # proper-суффиксы справа налево: если родительский домен уже в списке — запись избыточна + for (j = n; j >= 2; j--) { + suf = (suf == "") ? p[j] : p[j] "." suf + if (suf in have) { redundant = 1; break } + } + if (!redundant) print name[i] + } + }' +} + +dns_remove() { + local conf + conf="$(dnsmasq_confdir)/ytbypass.conf" + [ -f "$conf" ] || return 0 + rm -f "$conf" + /etc/init.d/dnsmasq restart >/dev/null 2>&1 +} + +# Записать nftset-правила для dnsmasq; dnsmasq перезапускается только при изменении. +# Строка конфига dnsmasq не может быть длиннее ~1 КБ (иначе dnsmasq вообще не запустится), +# поэтому домены разбиваются на несколько строк nftset= по <= 800 байт. +dns_apply() { + local ipv6="$1" conf domains suffix new old + conf="$(dnsmasq_confdir)/ytbypass.conf" + domains=$(collect_domains) + if [ -z "$domains" ]; then + dns_remove + return 0 + fi + suffix="4#inet#${NFT_TABLE}#yt4" + [ "$ipv6" = "1" ] && suffix="$suffix,6#inet#${NFT_TABLE}#yt6" + new=$(printf '%s\n' "$domains" | awk -v suffix="$suffix" -v max=800 ' + { + if (cur != "" && length("nftset=/" cur "/" $0 "/" suffix) > max) { + print "nftset=/" cur "/" suffix + cur = "" + } + cur = (cur == "") ? $0 : cur "/" $0 + } + END { if (cur != "") print "nftset=/" cur "/" suffix }') + old=$(cat "$conf" 2>/dev/null) + if [ "$old" != "$new" ]; then + mkdir -p "$(dirname "$conf")" + printf '%s\n' "$new" > "$conf" + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + fi +} + +write_hev_conf() { + local port="$1" ipv6="$2" f="$RUNDIR/hev.yml" + { + echo "tunnel:" + echo " name: $TUN" + echo " mtu: 1500" + echo " ipv4: 198.18.0.1" + [ "$ipv6" = "1" ] && echo " ipv6: 'fc00::1'" + echo " post-up-script: $LIBEXEC/route-up.sh" + echo "socks5:" + echo " port: $port" + echo " address: 127.0.0.1" + echo " udp: 'udp'" + echo "misc:" + echo " log-level: warn" + } > "$f" +} + +# правило forward в fw4 (иначе policy drop не пропустит LAN -> tun) +ensure_fw4_include() { + command -v fw4 >/dev/null 2>&1 || { log "fw4 не найден — нужен OpenWrt 22.03+"; return 0; } + nft list chain inet fw4 forward 2>/dev/null | grep -q "ytbypass" && return 0 + [ -f /usr/share/nftables.d/chain-pre/forward/50-ytbypass.nft ] || return 0 + log "перезагружаю firewall, чтобы подхватить правило forward" + /etc/init.d/firewall reload >/dev/null 2>&1 +} + +start_service() { + local enabled byedpi_port byedpi_opts ipv6 byedpi hev + + config_load "$NAME" + config_get enabled main enabled 0 + if [ "$enabled" != "1" ]; then + "$LIBEXEC/net.sh" purge + dns_remove + rm -f "$STARTED_FLAG" + return 0 + fi + + config_get byedpi_port main byedpi_port 1088 + config_get byedpi_opts main byedpi_opts "" + byedpi_opts=$(byedpi_opts_clean "$byedpi_opts") + config_get ipv6 main ipv6 1 + [ -f /proc/net/if_inet6 ] || ipv6=0 + + case "$byedpi_port" in + ''|*[!0-9]*) log "некорректный порт: $byedpi_port"; return 1 ;; + esac + + byedpi=$(find_byedpi) + hev=$(command -v hev-socks5-tunnel) + [ -n "$byedpi" ] || { log "ciadpi не найден: установите пакет byedpi"; return 1; } + [ -n "$hev" ] || { log "hev-socks5-tunnel не найден: установите пакет"; return 1; } + dnsmasq_has_nftset || { log "dnsmasq без поддержки nftset: установите dnsmasq-full"; return 1; } + [ -c /dev/net/tun ] || modprobe tun 2>/dev/null + + mkdir -p "$RUNDIR" + write_hev_conf "$byedpi_port" "$ipv6" + + "$LIBEXEC/net.sh" up || { log "не удалось настроить nftables/маршрутизацию"; return 1; } + ensure_fw4_include + dns_apply "$ipv6" + + # --- ByeDPI: локальный SOCKS5 с десинхронизацией --- + procd_open_instance byedpi + procd_set_param command "$byedpi" -i 127.0.0.1 -p "$byedpi_port" + set -f + # shellcheck disable=SC2086 + [ -n "$byedpi_opts" ] && procd_append_param command $byedpi_opts + set +f + procd_set_param respawn 3600 5 0 + procd_set_param stderr 1 + procd_close_instance + + # --- hev-socks5-tunnel: TUN -> SOCKS5 ByeDPI --- + procd_open_instance hev + procd_set_param command "$hev" "$RUNDIR/hev.yml" + procd_set_param respawn 3600 5 0 + procd_set_param stderr 1 + procd_close_instance + + touch "$STARTED_FLAG" +} + +stop_service() { + rm -f "$STARTED_FLAG" + "$LIBEXEC/net.sh" down +} + +service_triggers() { + procd_add_reload_trigger "$NAME" +} +YTB_FILE_END_7f3a9c + chmod 755 /etc/init.d/ytbypass + mkdir -p /lib/upgrade/keep.d + cat > /lib/upgrade/keep.d/ytbypass <<'YTB_FILE_END_7f3a9c' +/etc/ytbypass/ +YTB_FILE_END_7f3a9c + chmod 644 /lib/upgrade/keep.d/ytbypass + mkdir -p /usr/bin + cat > /usr/bin/ytbypass <<'YTB_FILE_END_7f3a9c' +#!/bin/sh +# Вспомогательная утилита YouTube Bypass (используется LuCI и для отладки) +# ytbypass status — состояние в JSON +# ytbypass flush — очистить наборы IP (клиентам нужно заново резолвить домены) +# ytbypass list — показать IP в наборах +# ytbypass diag [IP|MAC|имя] [сек] — диагностика клиента: куда идёт его DNS и :443-трафик (без аргумента — список клиентов) +# ytbypass test … — тест стратегий ByeDPI (start|stop|status|log|results|clear|list) +# ytbypass test list get|set|reset strategies|domains [текст] — свои списки для теста +# ytbypass set-strategy "<параметры ciadpi>" — записать стратегию и перезапустить службу + +. /lib/functions.sh +. /usr/libexec/ytbypass/common.sh + +svc_running() { + ubus call service list '{"name":"ytbypass"}' 2>/dev/null \ + | jsonfilter -e "@.ytbypass.instances.$1.running" 2>/dev/null | grep -q true +} + +count_set() { + nft list set inet "$NFT_TABLE" "$1" 2>/dev/null | grep -o 'expires' | wc -l +} + +b() { if [ "$1" = "1" ]; then echo true; else echo false; fi; } + +case "$1" in +status) + config_load ytbypass + config_get enabled main enabled 0 + config_get ipv6 main ipv6 1 + config_get quic main quic block + + v_byedpi=0; svc_running byedpi && v_byedpi=1 + v_hev=0; svc_running hev && v_hev=1 + v_tun=0; ip link show "$TUN" >/dev/null 2>&1 && v_tun=1 + v_nft=0; nft list table inet "$NFT_TABLE" >/dev/null 2>&1 && v_nft=1 + v_rule=0; ip rule show 2>/dev/null | grep -q "lookup $TABLE" && v_rule=1 + v_route=0; ip route show table "$TABLE" 2>/dev/null | grep -q "$TUN" && v_route=1 + v_dns=0; [ -s "$(dnsmasq_confdir)/ytbypass.conf" ] && v_dns=1 + v_nftset=0; dnsmasq_has_nftset && v_nftset=1 + v_fw=0; nft list chain inet fw4 forward 2>/dev/null | grep -q ytbypass && v_fw=1 + v_bin=0; { [ -x /usr/bin/ciadpi ] || [ -x /usr/bin/byedpi ]; } && [ -x /usr/bin/hev-socks5-tunnel ] && v_bin=1 + + printf '{"enabled":%s,"byedpi":%s,"hev":%s,"tun":%s,"nft":%s,"rule":%s,"route":%s,"dns":%s,"dnsmasq_nftset":%s,"fw":%s,"binaries":%s,"ipv6":%s,"quic":"%s","ips4":%s,"ips6":%s}\n' \ + "$(b "$enabled")" "$(b $v_byedpi)" "$(b $v_hev)" "$(b $v_tun)" "$(b $v_nft)" \ + "$(b $v_rule)" "$(b $v_route)" "$(b $v_dns)" "$(b $v_nftset)" "$(b $v_fw)" "$(b $v_bin)" \ + "$(b "$ipv6")" "$quic" "$(count_set yt4)" "$(count_set yt6)" + ;; +flush) + nft flush set inet "$NFT_TABLE" yt4 2>/dev/null + nft flush set inet "$NFT_TABLE" yt6 2>/dev/null + echo "наборы очищены" + ;; +list) + nft list set inet "$NFT_TABLE" yt4 2>/dev/null + nft list set inet "$NFT_TABLE" yt6 2>/dev/null + ;; +diag) + arg="$2"; secs="${3:-20}" + LEASES="${YTB_LEASES:-/tmp/dhcp.leases}" + CT="${YTB_CT:-/proc/net/nf_conntrack}" + case "$secs" in ''|*[!0-9]*) secs=20 ;; esac + + echo "=== YouTube Bypass: диагностика ===" + "$0" status + echo + echo "--- счётчики nft (растут, когда трафик попадает под правила) ---" + { nft list chain inet "$NFT_TABLE" prerouting; nft list chain inet "$NFT_TABLE" forward; } 2>/dev/null \ + | grep counter | sed 's/^[[:space:]]*//' + echo + echo "--- какие DNS раздаются клиентам по DHCP ---" + uci -q show dhcp | grep -E 'dhcp_option|\.dns=' || echo "своих dhcp_option/dns нет (клиенты получают DNS роутера)" + echo "--- IPv6: маршрут по умолчанию: $(ip -6 route show default 2>/dev/null | head -n 1)" + + router_addrs=$(ip -o addr show 2>/dev/null | awk '{print $4}' | sed 's#/.*##' | tr '\n' ' ') + lan_ip=$(uci -q get network.lan.ipaddr | sed 's#/.*##') + [ -n "$lan_ip" ] && router_addrs="$router_addrs $lan_ip" + + list_leases() { + echo "--- клиенты в DHCP ---" + if [ -s "$LEASES" ]; then + awk '{printf " %-16s %-18s %s\n", $3, $2, $4}' "$LEASES" + else + echo " (файл аренд пуст — смотрите IP в LuCI: Состояние → Обзор)" + fi + } + + if [ -z "$arg" ]; then + echo + list_leases + echo + echo "Запустите: ytbypass diag [секунд, по умолчанию 20]" + exit 0 + fi + + # имя/MAC -> IP через аренды DHCP + client="$arg" + if ! printf '%s' "$arg" | grep -Eq '^[0-9]+(\.[0-9]+){3}$'; then + found=$(grep -i -- "$arg" "$LEASES" 2>/dev/null) + n=$(printf '%s\n' "$found" | grep -c .) + if [ "$n" -eq 0 ]; then + echo; echo "«$arg» не найден в DHCP-арендах."; list_leases; exit 0 + elif [ "$n" -gt 1 ]; then + echo; echo "«$arg» подходит нескольким клиентам — уточните:"; list_leases; exit 0 + fi + client=$(printf '%s\n' "$found" | awk '{print $3}') + fi + case " $router_addrs " in + *" $client "*) + echo + echo "$client — это адрес самого РОУТЕРА. Нужен IP телефона:" + list_leases + exit 0 ;; + esac + + # все адреса устройства: IPv4 + его глобальные IPv6 (по MAC) + mac=$(ip neigh show 2>/dev/null | awk -v ip="$client" '$1==ip {for(i=1;i<=NF;i++) if($i=="lladdr") print $(i+1)}' | head -n 1) + ips="$client" + [ -n "$mac" ] && ips="$ips $(ip -6 neigh show 2>/dev/null | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $1}' | grep -v '^fe80' | tr '\n' ' ')" + echo + echo "=== клиент: $ips (MAC: ${mac:-не найден}) ===" + echo ">>> Собираю данные ${secs} с. ОТКРОЙТЕ YouTube НА ТЕЛЕФОНЕ (перезапустите приложение / обновите страницу) <<<" + + raw="/tmp/ytb-ct.$$"; out="/tmp/ytb-diag.$$"; ins="/tmp/ytb-ins.$$" + : > "$raw"; : > "$ins" + i=0 + while [ "$i" -lt "$secs" ]; do + cat "$CT" >> "$raw" 2>/dev/null + i=$((i + 1)) + [ "$i" -lt "$secs" ] && sleep 1 + done + + # каждый поток считаем один раз; «в туннеле», если у него хоть раз была метка + awk -v ips=" $ips " ' + { + proto=$3; src=""; dst=""; sport=""; dport=""; mark=0 + for (i=1;i<=NF;i++) { + if (src=="" && $i ~ /^src=/) src=substr($i,5) + else if (dst=="" && $i ~ /^dst=/) dst=substr($i,5) + else if (sport=="" && $i ~ /^sport=/) sport=substr($i,7) + else if (dport=="" && $i ~ /^dport=/) dport=substr($i,7) + else if ($i ~ /^mark=/) mark=substr($i,6)+0 + } + if (index(ips, " " src " ") == 0) next + key=proto " " src " " sport " " dst " " dport + if (!(key in P)) { P[key]=proto; D[key]=dst; Q[key]=dport; M[key]=0 } + if (int(mark/65536)%2==1) M[key]=1 + } + END { + for (k in P) { + if (Q[k]=="53" || Q[k]=="853") c["DNS " P[k] " " D[k] ":" Q[k]]++ + else if (Q[k]=="443") c["443 " P[k] " " (M[k] ? "tunnel" : "direct") " " D[k]]++ + } + for (k in c) print k, c[k] + }' "$raw" 2>/dev/null | sort > "$out" + + echo + echo "--- DNS-запросы клиента (куда он реально их шлёт) ---" + dns_ok=0; dns_bad=0 + if grep -q '^DNS' "$out"; then + while read -r _ proto target n; do + host="${target%:*}"; port="${target##*:}" + if [ "$port" = "853" ]; then + who="DNS-over-TLS (Частный DNS) — МИМО роутера"; dns_bad=1 + elif case " $router_addrs " in *" $host "*) true ;; *) false ;; esac; then + who="роутер (ок)"; dns_ok=1 + else + who="НЕ роутер — набор для этого клиента не заполнится"; dns_bad=1 + fi + echo " $proto $target x$n — $who" + done </dev/null 2>&1; then + echo " $dst x$n — в наборе, но трафик не помечен" | tee -a "$ins" + else + echo " $dst x$n — не в наборе" + fi + done + in_set=$(grep -c . "$ins") + + echo + found_any=0 + if [ "$dns_bad" -eq 1 ]; then + found_any=1 + if [ "$dns_ok" -eq 0 ]; then + echo "ВЫВОД: телефон шлёт DNS мимо роутера (Частный DNS / DoT / внешний DNS) — набор не заполняется, YouTube идёт напрямую." + else + echo "ВЫВОД: часть DNS-запросов телефона идёт мимо роутера (Частный DNS / DoT / внешний DNS) — для таких имён набор не заполнится." + fi + echo " Отключите «Частный DNS» и «Безопасный DNS» в Chrome, уберите статический DNS/VPN/AdGuard и переподключите Wi-Fi." + fi + if [ "$in_set" -gt 0 ]; then + found_any=1 + echo "ВЫВОД: есть соединения на IP из набора, но без метки — пришлите этот вывод целиком." + fi + if [ "$tcp_t" -gt 0 ]; then + found_any=1 + echo "ИНФО: часть трафика YouTube от телефона идёт через туннель — маршрутизация работает." + [ "$dns_bad" -eq 0 ] && [ "$in_set" -eq 0 ] && \ + echo " Если видео всё равно не грузится — стратегия ByeDPI (вкладка «Тест стратегий») или домены, которых нет в списке." + fi + if [ "$tcp_t" -eq 0 ] && [ "$tcp_d" -eq 0 ] && [ "$dns_ok" -eq 0 ] && [ "$dns_bad" -eq 0 ]; then + found_any=1 + echo "ВЫВОД: от этого клиента не было ни DNS, ни :443-трафика. Проверьте IP/MAC, что телефон в этой сети, и откройте YouTube во время сбора." + fi + [ "$found_any" -eq 1 ] || echo "ВЫВОД: однозначно определить не удалось — пришлите этот вывод целиком." + rm -f "$raw" "$out" "$ins" + ;; +test) + shift + exec /usr/libexec/ytbypass/test.sh "$@" + ;; +set-strategy) + opts="$2" + if [ -z "$opts" ] || [ "$(printf '%s' "$opts" | wc -l)" -gt 0 ]; then + echo '{"error":"пустая или многострочная стратегия"}' + exit 0 + fi + uci set ytbypass.main.byedpi_opts="$opts" && uci commit ytbypass + /etc/init.d/ytbypass restart >/dev/null 2>&1 + echo '{"ok":true}' + ;; +*) + echo "usage: ytbypass status|flush|list|diag|test|set-strategy" >&2 + exit 1 + ;; +esac +YTB_FILE_END_7f3a9c + chmod 755 /usr/bin/ytbypass + mkdir -p /usr/libexec/ytbypass + cat > /usr/libexec/ytbypass/common.sh <<'YTB_FILE_END_7f3a9c' +#!/bin/sh +# Общие константы и функции YouTube Bypass + +TUN=ytb0 # имя TUN-интерфейса hev-socks5-tunnel +MARK=0x10000 # fwmark (один выделенный бит) +MASK=0x10000 +TABLE=89 # таблица маршрутизации +PREF=8900 # приоритет ip rule +NFT_TABLE=ytbypass + +# каталог conf-dir, который читает dnsmasq +dnsmasq_confdir() { + local d + d=$(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | head -n 1) + [ -n "$d" ] || d=/tmp/dnsmasq.d + echo "$d" +} + +# dnsmasq собран с nftset (dnsmasq-full)? +dnsmasq_has_nftset() { + dnsmasq --version 2>/dev/null | grep -Eq '(^| )nftset( |$)' +} + +# путь к бинарнику ByeDPI +find_byedpi() { + local b + for b in /usr/bin/ciadpi /usr/bin/byedpi; do + [ -x "$b" ] && { echo "$b"; return 0; } + done + command -v ciadpi +} + +# Параметры ByeDPI приходят из UCI как строка, а раскрываются без eval (по пробелам). +# Кавычки вроде -n "google.com" при этом остались бы в аргументе буквально, +# поэтому их убираем: у аргументов ciadpi пробелов внутри не бывает. +byedpi_opts_clean() { + printf '%s' "$1" | tr -d "\"'" | tr '\n\r\t' ' ' | sed 's/^ *//; s/ *$//; s/ */ /g' +} +YTB_FILE_END_7f3a9c + chmod 755 /usr/libexec/ytbypass/common.sh + cat > /usr/libexec/ytbypass/net.sh <<'YTB_FILE_END_7f3a9c' +#!/bin/sh +# Настройка nftables и policy routing. +# Использование: net.sh up | down | purge +# up — создать таблицу inet ytbypass и ip rule +# down — убрать правила маркировки и ip rule (наборы IP остаются, чтобы dnsmasq не сыпал ошибками) +# purge — удалить всё, включая таблицу + +. /lib/functions.sh +. /usr/libexec/ytbypass/common.sh + +config_load ytbypass +config_get IPV6 main ipv6 1 +config_get QUIC main quic block +[ -f /proc/net/if_inet6 ] || IPV6=0 + +rules_del() { + while ip rule del pref "$PREF" 2>/dev/null; do :; done + while ip -6 rule del pref "$PREF" 2>/dev/null; do :; done + ip route del default dev "$TUN" table "$TABLE" 2>/dev/null + ip -6 route del default dev "$TUN" table "$TABLE" 2>/dev/null +} + +set_mark_stmt="counter ct mark set ct mark | $MARK meta mark set meta mark | $MARK" + +gen_ruleset() { + cat </dev/null + gen_ruleset | nft -f - || exit 1 + ip rule add pref "$PREF" fwmark "$MARK/$MASK" lookup "$TABLE" || exit 1 + [ "$IPV6" = "1" ] && ip -6 rule add pref "$PREF" fwmark "$MARK/$MASK" lookup "$TABLE" + # если туннель уже поднят — сразу добавить маршрут (иначе это сделает post-up hev) + ip link show "$TUN" >/dev/null 2>&1 && /usr/libexec/ytbypass/route-up.sh "$TUN" + exit 0 + ;; +down) + rules_del + nft flush chain inet "$NFT_TABLE" prerouting 2>/dev/null + nft flush chain inet "$NFT_TABLE" forward 2>/dev/null + ;; +purge) + rules_del + nft delete table inet "$NFT_TABLE" 2>/dev/null + ;; +*) + echo "usage: $0 up|down|purge" >&2 + exit 1 + ;; +esac +exit 0 +YTB_FILE_END_7f3a9c + chmod 755 /usr/libexec/ytbypass/net.sh + cat > /usr/libexec/ytbypass/route-up.sh <<'YTB_FILE_END_7f3a9c' +#!/bin/sh +# Вызывается hev-socks5-tunnel после подъёма TUN (и из net.sh up). +# Если туннель упадёт, маршрут исчезнет вместе с интерфейсом, и помеченный +# трафик уйдёт по основной таблице напрямую (fail-open). + +. /lib/functions.sh +. /usr/libexec/ytbypass/common.sh + +config_load ytbypass +config_get IPV6 main ipv6 1 +[ -f /proc/net/if_inet6 ] || IPV6=0 + +ip link set "$TUN" up 2>/dev/null +ip route replace default dev "$TUN" table "$TABLE" +[ "$IPV6" = "1" ] && ip -6 route replace default dev "$TUN" table "$TABLE" + +# ответы приходят с адресов YouTube на TUN — нужен нестрогий rp_filter (2 = loose) +echo 2 > "/proc/sys/net/ipv4/conf/$TUN/rp_filter" 2>/dev/null +exit 0 +YTB_FILE_END_7f3a9c + chmod 755 /usr/libexec/ytbypass/route-up.sh + cat > /usr/libexec/ytbypass/test.sh <<'YTB_FILE_END_7f3a9c' +#!/bin/sh +# Тест стратегий ByeDPI. +# ytbypass test start | stop | status | log | results | clear +# +# Списки стратегий и доменов: свои (/etc/ytbypass/, переживают обновление) или встроенные. +# ytbypass test list get|set|reset strategies|domains [текст] +# +# Каждая стратегия запускается во ВРЕМЕННОМ экземпляре ciadpi на отдельном порту, +# домены проверяются через него по curl --socks5. Боевой сервис и трафик клиентов +# не затрагиваются, конфигурация не меняется. Сначала контрольный замер без обхода. + +. /lib/functions.sh +. /usr/libexec/ytbypass/common.sh + +TEST_DIR=/tmp/ytbypass-test +PIDF="$TEST_DIR/job.pid" +LOGF="$TEST_DIR/job.log" +RES="$TEST_DIR/results.txt" +RAW="$TEST_DIR/results.raw" +STOP="$TEST_DIR/stop" +CPID="$TEST_DIR/ciadpi.pid" +USER_DIR=/etc/ytbypass +STRATS_DEFAULT=/usr/share/ytbypass/strategies.txt +DOMS_DEFAULT=/usr/share/ytbypass/test-domains.txt +PORT_BASE=22000 +UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) curl/8.0' +TAB=$(printf '\t') + +is_running() { + [ -f "$PIDF" ] && kill -0 "$(cat "$PIDF" 2>/dev/null)" 2>/dev/null +} + +count_lines() { # файл -> число строк без пустых и комментариев + sed 's/#.*//' "$1" 2>/dev/null | tr -d '\r' | grep -c '[^[:space:]]' +} + +# ---------------------------------------------------------------- списки +# имя файла у пользователя +list_name() { + case "$1" in + strategies) echo strategies.txt ;; + domains) echo test-domains.txt ;; + esac +} + +# путь действующего списка: свой (если есть и не пуст), иначе встроенный +list_file() { + local nm + nm=$(list_name "$1") + if [ -n "$nm" ] && [ -s "$USER_DIR/$nm" ]; then + echo "$USER_DIR/$nm" + else + case "$1" in + strategies) echo "$STRATS_DEFAULT" ;; + domains) echo "$DOMS_DEFAULT" ;; + esac + fi +} + +list_is_custom() { # kind + local nm + nm=$(list_name "$1") + [ -n "$nm" ] && [ -s "$USER_DIR/$nm" ] +} + +json_esc() { + printf '%s' "$1" | tr -d '\000-\037' | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +# домены: по одному в строке; допускаем ссылки, *.домен, запятые/пробелы; комментарии (#) сохраняем. +# Первая некорректная запись пишется в файл $1. +norm_domains() { + awk -v badf="$1" ' + { + gsub(/\r/, "") + line = $0 + sub(/^[ \t]+/, "", line); sub(/[ \t]+$/, "", line) + if (line == "") next + if (substr(line, 1, 1) == "#") { print line; next } + sub(/#.*/, "", line) + n = split(line, tok, /[ \t,;]+/) + for (i = 1; i <= n; i++) { + d = tolower(tok[i]) + if (d == "") continue + sub(/^[a-z][a-z0-9+.-]*:\/\//, "", d) + sub(/[\/?#].*$/, "", d) + sub(/^\*?\./, "", d) + if (d ~ /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/ && index(d, ".") > 0) { + if (!(d in seen)) { seen[d] = 1; print d } + } else if (!bad) { bad = 1; print tok[i] > badf } + } + }' +} + +# стратегии: по одной в строке, пробелы схлопываются, дубли убираются, комментарии (#) сохраняем +norm_strategies() { + awk ' + { + gsub(/\r/, ""); gsub(/[ \t]+/, " ") + line = $0; sub(/^ /, "", line); sub(/ $/, "", line) + if (line == "") next + if (substr(line, 1, 1) == "#") { print line; next } + if (!(line in seen)) { seen[line] = 1; print line } + }' +} + +cmd_list() { + local action="$1" kind="$2" text="$3" nm tmp bad n + nm=$(list_name "$kind") + if [ -z "$nm" ]; then + echo '{"error":"неизвестный список"}' + return 0 + fi + case "$action" in + get) + local f + f=$(list_file "$kind") + [ -f "$f" ] && cat "$f" + ;; + set) + if is_running; then + echo '{"error":"тест выполняется — остановите его перед сохранением списка"}' + return 0 + fi + mkdir -p "$TEST_DIR" + tmp="$TEST_DIR/list.$$"; bad="$TEST_DIR/bad.$$"; : > "$bad" + if [ "$kind" = "domains" ]; then + printf '%s\n' "$text" | norm_domains "$bad" > "$tmp" + else + printf '%s\n' "$text" | norm_strategies > "$tmp" + fi + if [ -s "$bad" ]; then + printf '{"error":"Некорректный домен: %s"}\n' "$(json_esc "$(head -n 1 "$bad")")" + rm -f "$tmp" "$bad" + return 0 + fi + rm -f "$bad" + n=$(count_lines "$tmp") + if [ "${n:-0}" -le 0 ]; then + echo '{"error":"список пуст — чтобы вернуть встроенный, нажмите «Сбросить к встроенному»"}' + rm -f "$tmp" + return 0 + fi + mkdir -p "$USER_DIR" + mv "$tmp" "$USER_DIR/$nm" + printf '{"ok":true,"count":%s,"custom":true}\n' "$n" + ;; + reset) + if is_running; then + echo '{"error":"тест выполняется — остановите его перед сбросом списка"}' + return 0 + fi + rm -f "$USER_DIR/$nm" + printf '{"ok":true,"count":%s,"custom":false}\n' "$(count_lines "$(list_file "$kind")")" + ;; + *) + echo '{"error":"неизвестное действие"}' + ;; + esac + return 0 +} + +# ---------------------------------------------------------------- управление +cmd_start() { + if is_running; then + echo '{"started":true,"already_running":true}' + return 0 + fi + command -v curl >/dev/null 2>&1 || { echo '{"error":"не установлен curl (apk add curl / opkg install curl)"}'; return 0; } + [ -n "$(find_byedpi)" ] || { echo '{"error":"не найден ciadpi (пакет byedpi)"}'; return 0; } + mkdir -p "$TEST_DIR" + rm -f "$STOP" + : > "$LOGF" + # полностью отвязываем от stdin/stdout, иначе rpcd будет ждать завершения + ( "$0" run >>"$LOGF" 2>&1; echo "__DONE__ $?" >>"$LOGF" ) >/dev/null 2>&1 "$PIDF" + echo '{"started":true}' +} + +cmd_stop() { + if ! is_running; then + echo '{"error":"тест не запущен"}' + return 0 + fi + touch "$STOP" + [ -f "$CPID" ] && kill "$(cat "$CPID" 2>/dev/null)" 2>/dev/null + echo '{"ok":true}' +} + +cmd_status() { + local running=false has=false cu=false rc="" + is_running && running=true + [ -s "$RES" ] && has=true + command -v curl >/dev/null 2>&1 && cu=true + [ -f "$LOGF" ] && rc=$(grep '^__DONE__' "$LOGF" | tail -n 1 | awk '{print $2}') + local sc=false dc=false + list_is_custom strategies && sc=true + list_is_custom domains && dc=true + printf '{"running":%s,"has_results":%s,"curl":%s,"strategies":%s,"domains":%s,"strategies_custom":%s,"domains_custom":%s,"rc":"%s"}\n' \ + "$running" "$has" "$cu" "$(count_lines "$(list_file strategies)")" "$(count_lines "$(list_file domains)")" "$sc" "$dc" "$rc" +} + +cmd_log() { + [ -f "$LOGF" ] && tail -n 400 "$LOGF" | grep -v '^__DONE__' + return 0 +} + +cmd_results() { + [ -s "$RES" ] && cat "$RES" + return 0 +} + +cmd_clear() { + if is_running; then + echo '{"error":"тест выполняется"}' + return 0 + fi + rm -f "$RES" "$RAW" + echo '{"ok":true}' +} + +# ---------------------------------------------------------------- движок +# check_url ЗАПИСЬ OKFILE LOGFILE [socks host:port] +check_url() { + local entry="$1" okfile="$2" logfile="$3" proxy="$4" host url rc + host="${entry%%|*}" + url="${entry#*|}" + if [ -n "$proxy" ]; then + curl -4 -sL --socks5 "$proxy" --connect-timeout 4 --max-time 6 --speed-time 3 --speed-limit 1 \ + --range 0-65535 -A "$UA" -o /dev/null "$url" /dev/null 2>&1 + else + curl -4 -sL --connect-timeout 4 --max-time 6 --speed-time 3 --speed-limit 1 \ + --range 0-65535 -A "$UA" -o /dev/null "$url" /dev/null 2>&1 + fi + rc=$? + if [ "$rc" -eq 0 ]; then + echo 1 >> "$okfile" + echo "[ OK ] $host" >> "$logfile" + else + echo "[FAIL] $host" >> "$logfile" + fi +} + +# check_all ФАЙЛ_URL LOGFILE [socks] -> "ok total" +check_all() { + local urls="$1" logfile="$2" proxy="$3" okf run=0 total=0 ok entry + okf="$TEST_DIR/ok.$$" + : > "$okf" + : > "$logfile" + while IFS= read -r entry; do + [ -n "$entry" ] || continue + [ -f "$STOP" ] && break + total=$((total + 1)) + check_url "$entry" "$okf" "$logfile" "$proxy" & + run=$((run + 1)) + if [ "$run" -ge "$PARALLEL" ]; then + wait + run=0 + fi + done < "$urls" + wait + ok=$(wc -l < "$okf" | tr -d ' ') + rm -f "$okf" + echo "$ok $total" +} + +cleanup() { + [ -f "$CPID" ] && kill "$(cat "$CPID" 2>/dev/null)" 2>/dev/null + rm -f "$CPID" +} + +cmd_run() { + local BIN cand keys urls line k opts port idx total ntot res ok tot cok ctot cpid skipped=0 best + + set -f # параметры ciadpi раскрываем по пробелам без glob + trap cleanup EXIT + trap 'exit 130' INT TERM + + config_load ytbypass + config_get PARALLEL main test_parallel 8 + config_get CUR main byedpi_opts "" + case "$PARALLEL" in ''|*[!0-9]*) PARALLEL=8 ;; esac + [ "$PARALLEL" -ge 1 ] || PARALLEL=8 + + BIN=$(find_byedpi) + [ -n "$BIN" ] || { echo "ОШИБКА: ciadpi не найден"; exit 1; } + command -v curl >/dev/null 2>&1 || { echo "ОШИБКА: не установлен curl"; exit 1; } + + mkdir -p "$TEST_DIR" + rm -f "$STOP" + cand="$TEST_DIR/candidates.txt" + keys="$TEST_DIR/keys.txt" + urls="$TEST_DIR/urls.txt" + : > "$cand"; : > "$keys"; : > "$RAW" + + echo "==> Собираем стратегии для теста" + # текущая стратегия идёт первой, затем список; дубликаты (без учёта кавычек) отбрасываем + { printf '%s\n' "$CUR"; cat "$(list_file strategies)"; } | tr -d '\r' | while IFS= read -r line; do + line=$(printf '%s' "$line" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + case "$line" in ''|'#'*) continue ;; esac + k=$(byedpi_opts_clean "$line") + [ -n "$k" ] || continue + grep -qxF -- "$k" "$keys" && continue + echo "$k" >> "$keys" + echo "$line" >> "$cand" + done + total=$(wc -l < "$cand" | tr -d ' ') + [ "$total" -gt 0 ] || { echo "ОШИБКА: нет стратегий для теста"; exit 1; } + + echo "==> Собираем список доменов" + tr -d '\r' < "$(list_file domains)" | sed 's/#.*//; s/^[[:space:]]*//; s/[[:space:]]*$//' \ + | grep -E '^[A-Za-z0-9._-]+$' | awk '!s[$0]++' | sed 's#.*#&|https://&/#' > "$urls" + ntot=$(wc -l < "$urls" | tr -d ' ') + [ "$ntot" -gt 0 ] || { echo "ОШИБКА: нет доменов для теста"; exit 1; } + echo "==> Стратегий: $total, доменов: $ntot, параллельно: $PARALLEL" + + echo "==> Контрольный тест: без обхода" + res=$(check_all "$urls" "$TEST_DIR/log_control.txt" "") + if [ -f "$STOP" ]; then + echo "==> Тест остановлен на контрольном замере, результатов нет" + rm -f "$STOP" + return 0 + fi + cok=${res% *}; ctot=${res#* } + echo "==> Результат: $cok/$ctot" + + idx=0 + while IFS= read -r line; do + [ -f "$STOP" ] && break + idx=$((idx + 1)) + opts=$(byedpi_opts_clean "$line") + port=$((PORT_BASE + idx)) + echo "==> [$idx/$total] $line" + "$BIN" -i 127.0.0.1 -p "$port" $opts >/dev/null 2>&1 "$CPID" + sleep 1 + if ! kill -0 "$cpid" 2>/dev/null; then + echo " пропуск: ciadpi не запустился с этими параметрами" + skipped=$((skipped + 1)) + continue + fi + res=$(check_all "$urls" "$TEST_DIR/log_$idx.txt" "127.0.0.1:$port") + kill "$cpid" 2>/dev/null + wait "$cpid" 2>/dev/null + rm -f "$CPID" + if [ -f "$STOP" ]; then + echo " прервано — эта стратегия в результаты не входит" + break + fi + ok=${res% *}; tot=${res#* } + ok=${ok:-0} + echo " результат: $ok/$tot" + # ключ сортировки: (999999-ok) и номер — при обычном лексикографическом sort получается + # «больше ok выше, при равенстве раньше в списке». sort -k/-n в busybox OpenWrt может не работать. + printf '%06d\t%06d\t%s\t%s\t%s\n' "$((999999 - ok))" "$idx" "$ok" "$tot" "$line" >> "$RAW" + done < "$cand" + + if [ -f "$STOP" ]; then + echo "==> Тест остановлен пользователем, показываю то, что успели проверить" + rm -f "$STOP" + else + echo "==> Тест завершён" + fi + [ "$skipped" -gt 0 ] && echo "==> Пропущено стратегий (не запустились): $skipped" + + # итог: по убыванию числа доступных доменов, при равенстве — в порядке списка + { + echo "Контрольный тест (без обхода) → $cok/$ctot" + sort "$RAW" | while IFS="$TAB" read -r _ _ ok tot line; do + echo "$line → $ok/$tot" + done + } > "$RES" + + echo "==> Основной сервис и его настройки не менялись." + return 0 +} + +case "$1" in + start) cmd_start ;; + stop) cmd_stop ;; + status) cmd_status ;; + log) cmd_log ;; + results) cmd_results ;; + clear) cmd_clear ;; + list) shift; cmd_list "$@" ;; + run) cmd_run ;; + *) echo "usage: ytbypass test start|stop|status|log|results|clear|list" >&2; exit 1 ;; +esac +exit 0 +YTB_FILE_END_7f3a9c + chmod 755 /usr/libexec/ytbypass/test.sh + mkdir -p /usr/share/nftables.d/chain-pre/forward + cat > /usr/share/nftables.d/chain-pre/forward/50-ytbypass.nft <<'YTB_FILE_END_7f3a9c' +oifname "ytb0" accept comment "ytbypass" +YTB_FILE_END_7f3a9c + chmod 644 /usr/share/nftables.d/chain-pre/forward/50-ytbypass.nft + mkdir -p /usr/share/ytbypass + cat > /usr/share/ytbypass/domains.list <<'YTB_FILE_END_7f3a9c' +# Домены YouTube и связанных сервисов Google (поддомены подхватываются автоматически). +# Список объединяется с «Дополнительными доменами» из настроек. Вложенные записи +# (например, i.ytimg.com при наличии ytimg.com) сервис сам отбрасывает как избыточные. +android.clients.google.com +beacons.gvt2.com +cdn.youtube.com +connectivitycheck.gstatic.com +fonts.googleapis.com +fonts.gstatic.com +ggpht.com +googleapis.com +googleplay.com +googleusercontent.com +googlevideo.com +gvt1.com +i.ytimg.com +i9.ytimg.com +jnn-pa.googleapis.com +kids.youtube.com +lh3.googleusercontent.com +m.youtube.com +manifest.googlevideo.com +music.youtube.com +nhacmp3youtube.com +play-fe.googleapis.com +play-games.googleusercontent.com +play-lh.googleusercontent.com +play.google.com +play.googleapis.com +prod-lt-playstoregatewayadapter-pa.googleapis.com +returnyoutubedislikeapi.com +s.ytimg.com +signaler-pa.youtube.com +studio.youtube.com +tv.youtube.com +wide-youtube.l.google.com +withyoutube.com +youtu.be +youtube-nocookie.com +youtube-ui.l.google.com +youtube.ae +youtube.al +youtube.am +youtube.at +youtube.az +youtube.ba +youtube.be +youtube.bg +youtube.bh +youtube.bo +youtube.by +youtube.ca +youtube.cat +youtube.ch +youtube.cl +youtube.co +youtube.co.ae +youtube.co.at +youtube.co.cr +youtube.co.hu +youtube.co.id +youtube.co.il +youtube.co.in +youtube.co.jp +youtube.co.ke +youtube.co.kr +youtube.co.ma +youtube.co.nz +youtube.co.th +youtube.co.tz +youtube.co.ug +youtube.co.uk +youtube.co.ve +youtube.co.za +youtube.co.zw +youtube.com +youtube.com.ar +youtube.com.au +youtube.com.az +youtube.com.bd +youtube.com.bh +youtube.com.bo +youtube.com.br +youtube.com.by +youtube.com.co +youtube.com.do +youtube.com.ec +youtube.com.ee +youtube.com.eg +youtube.com.es +youtube.com.gh +youtube.com.gr +youtube.com.gt +youtube.com.hk +youtube.com.hn +youtube.com.hr +youtube.com.jm +youtube.com.jo +youtube.com.kw +youtube.com.lb +youtube.com.lv +youtube.com.ly +youtube.com.mk +youtube.com.mt +youtube.com.mx +youtube.com.my +youtube.com.ng +youtube.com.ni +youtube.com.om +youtube.com.pa +youtube.com.pe +youtube.com.ph +youtube.com.pk +youtube.com.pt +youtube.com.py +youtube.com.qa +youtube.com.ro +youtube.com.sa +youtube.com.sg +youtube.com.sv +youtube.com.tn +youtube.com.tr +youtube.com.tw +youtube.com.ua +youtube.com.uy +youtube.com.ve +youtube.cr +youtube.cz +youtube.de +youtube.dk +youtube.ee +youtube.es +youtube.fi +youtube.fr +youtube.ge +youtube.googleapis.com +youtube.gr +youtube.gt +youtube.hk +youtube.hr +youtube.hu +youtube.ie +youtube.in +youtube.iq +youtube.is +youtube.it +youtube.jo +youtube.jp +youtube.kr +youtube.kz +youtube.la +youtube.lk +youtube.lt +youtube.lu +youtube.lv +youtube.ly +youtube.ma +youtube.md +youtube.me +youtube.mk +youtube.mn +youtube.mx +youtube.my +youtube.ng +youtube.ni +youtube.nl +youtube.no +youtube.pa +youtube.pe +youtube.ph +youtube.pk +youtube.pl +youtube.pr +youtube.pt +youtube.qa +youtube.ro +youtube.rs +youtube.ru +youtube.sa +youtube.se +youtube.sg +youtube.si +youtube.sk +youtube.sn +youtube.soy +youtube.sv +youtube.tn +youtube.tv +youtube.ua +youtube.ug +youtube.uy +youtube.vn +youtubeeducation.com +youtubeembeddedplayer.googleapis.com +youtubefanfest.com +youtubegaming.com +youtubego.co.id +youtubego.co.in +youtubego.com +youtubego.com.br +youtubego.id +youtubego.in +youtubei.googleapis.com +youtubei.youtube.com +youtubekids.com +youtubemobilesupport.com +yt-video-upload.l.google.com +yt.be +yt3.ggpht.com +yt3.googleusercontent.com +yt4.ggpht.com +ytimg.com +ytimg.l.google.com +yting.com +YTB_FILE_END_7f3a9c + chmod 644 /usr/share/ytbypass/domains.list + cat > /usr/share/ytbypass/strategies.txt <<'YTB_FILE_END_7f3a9c' +-f-200 -Qr -s3:5+sm -a1 -As -d1 -s4+sm -s8+sh -f-300 -d6+sh -a1 -At,r,s -o2 -f-30 -As -r5 -Mh -r6+sh -f-250 -s2:7+s -s3:6+sm -a1 -At,r,s -s3:5+sm -s6+s -s7:9+s -q30+sm -a1 +-d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -r1+s -S -a1 -As -d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -S -a1 +-q2 -s2 -s3+s -r3 -s4 -r4 -s5+s -r5+s -s6 -s7+s -r8 -s9+s -Qr -Mh,d,r -a1 -At,r -s2+s -r2 -d2 -s3 -r3 -r4 -s4 -d5+s -r5 -d6 -s7+s -d7 -a1 +-o1 -d1 -a1 -At,r,s -s1 -d1 -s5+s -s10+s -s15+s -s20+s -r1+s -S -a1 -As -s1 -d1 -s5+s -s10+s -s15+s -s20+s -S -a1 +-n "google.com" -Qr -f-204 -s1:5+sm -a1 -As -d1 -s3+s -s5+s -q7 -a1 -As -o2 -f-43 -a1 -As -r5 -Mh -s1:5+s -s3:7+sm -a1 +-n "google.com" -Qr -f-205 -a1 -As -s1:3+sm -a1 -As -s5:8+sm -a1 -As -d3 -q7 -o2 -f-43 -f-85 -f-165 -r5 -Mh -a1 +-d1+s -s50+s -a1 -As -f20 -r2+s -a1 -At -d2 -s1+s -s5+s -s10+s -s15+s -s25+s -s35+s -s50+s -s60+s -a1 +-o1 -a1 -At,r,s -f-1 -a1 -At,r,s -d1:11+sm -S -a1 -At,r,s -n "google.com" -Qr -f1 -d1:11+sm -s1:11+sm -S -a1 +-d1 -s1 -q1 -a1 -Ar -s5 -o1+s -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -a1 +-f1+nme -t6 -a1 -As -n "google.com" -Qr -s1:6+sm -a1 -As -s5:12+sm -a1 -As -d3 -q7 -r6 -Mh -a1 +-d1 -s1+s -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -a1 +-d1 -s1+s -d1+s -s3+s -d6+s -s12+s -d14+s -s20+s -d24+s -s30+s -a1 +-o1 -a1 -At,r,s -f-1 -a1 -Ar,s -o1 -a1 -At -r1+s -f-1 -t6 -a1 +-d1 -s1+s -s3+s -s6+s -s9+s -s12+s -s15+s -s20+s -s30+s -a1 +-d1 -d3+s -s6+s -d6+s -s7+s -d8+s -s10+s -a1 -t12 -At,s -r3 +-f1 -t5 -n "google.com" -q3+h -Qr -f2 -q1 -r1+s -t15 -q1 -o2 -a1 +-n "google.com" -d2:5:2+h -f-3 -r2+sm -o2 -o50+s -r2+s -f-4 -a1 +-f-1 -Qr -s1+sm -d3+s -s5+sm -o2 -a1 -As -r1+s -d8+s -a1 +-r-1+s -o20+sm -s3:7+sm -d5:3+sm -f300+s -Qr -f-1 -a1 +-o2 -O4 -s1 -q1 -a1 -Ar -s5 -o1+s -f1+s -r20+s -a1 +-o1 -r-5+se -a1 -At,r,s -d1 -n "google.com" -Qr -f-1 -a1 +--fake -1 --ttl 8 --split 1+s --disorder 3+s -a1 +-n "google.com" -Qr -f6+nr -d2 -d11 -f9+hm -o3 -t7 -a1 +-r5+s -s25+s -a1 -At,r,s -s50 -r5+s -s50+s -a1 +-d1 -d3+s -s6+s -d9+s -s20+s -d25+s -s30+s -a1 +-d9+s -q20+s -s25+s -t5 -a1 -At,r,s -r1+h -a1 +-q1+s -s29+s -s30+s -s14+s -o5+s -f-1 -S -a1 +-d1 -s1+s -r1+s -e1 -m1 -o1+s -f-1 -t2 -a1 +-d1 -o1 -a1 -Ar -o1 -a1 -At -f-1 -r1+s -a1 +-d1 -s4 -d8 -s1+s -d5+s -s10+s -d20+s -a1 +-f-1 -n "google.com" -Qr -s2+s -r3 -o20 -t4 -a1 +-n "google.com" -Qr -d5+sm -f3+sm -o2 -t4 -a1 +-o1 -a1 -Ar -q1 -a1 -At -f-1 -r1+s -a1 +-q1 -a1 -Ar -o1 -a1 -At -f-1 -r1+s -a1 +-s4+sn -r9+s -Qr -n "google.com" -S -a1 +-o1 -d1 -r1+s -S -s1+s -d3+s -a1 +-q1+s -s29+s -o5+s -f-1 -S -a1 +-n "google.com" -Qr -m2 -f-1 -d7 -a1 +-d1 -s1+s -r1+s -f-1 -t8 -a1 +-o1 -a1 -An -f1+nme -t6 -a1 +-n "google.com" -Qr -f-1 -r1+s -a1 +-n "google.com" -Qr -d1:3 -f-1 -a1 +-s1 -d3+s -a1 -At -r1+s -a1 +-f-1 -t8 -n "google.com" -s1+s -a1 +-n "google.com" -Qr -d1 -f-1 -a1 +-f64+se -n "google.com" -t5 -a1 +-o1 -a1 -At,r,s -d1 -a1 +-d1+s -o2 -s5 -r5 -a1 +-r8 -o2 -s7 -q4+s -a1 +-o1 -f-1 -r-5+se -a1 +-d6+s -q4+hm -o2 -a1 +-s5+s -s35+s -m4 -a1 +-f-1+sm -t7 -m2 -a1 +-o1 -r-5+se -a1 +-o1+s -d3+s -a1 +-o1 -s4 -s6 -a1 +-q1 -r25+s -a1 +-d1 -s3+s -a1 +-o3 -d7 -a1 +-d7 -s2 -a1 +-o1 -a1 -r-5+se +YTB_FILE_END_7f3a9c + chmod 644 /usr/share/ytbypass/strategies.txt + cat > /usr/share/ytbypass/test-domains.txt <<'YTB_FILE_END_7f3a9c' +# Google and Youtube +youtu.be +youtube.com +i.ytimg.com +i9.ytimg.com +yt3.ggpht.com +yt4.ggpht.com +googleapis.com +jnn-pa.googleapis.com +googleusercontent.com +signaler-pa.youtube.com +youtubei.googleapis.com +manifest.googlevideo.com +yt3.googleusercontent.com + +# Googlevideo +rr1---sn-4axm-n8vs.googlevideo.com +rr1---sn-gvnuxaxjvh-o8ge.googlevideo.com +rr1---sn-ug5onuxaxjvh-p3ul.googlevideo.com +rr1---sn-ug5onuxaxjvh-n8v6.googlevideo.com +rr4---sn-q4flrnsl.googlevideo.com +rr10---sn-gvnuxaxjvh-304z.googlevideo.com +rr14---sn-n8v7kn7r.googlevideo.com +rr16---sn-axq7sn76.googlevideo.com +rr1---sn-8ph2xajvh-5xge.googlevideo.com +rr1---sn-gvnuxaxjvh-5gie.googlevideo.com +rr12---sn-gvnuxaxjvh-bvwz.googlevideo.com +rr5---sn-n8v7knez.googlevideo.com +rr1---sn-u5uuxaxjvhg0-ocje.googlevideo.com +rr2---sn-q4fl6ndl.googlevideo.com +rr5---sn-gvnuxaxjvh-n8vk.googlevideo.com +rr4---sn-jvhnu5g-c35d.googlevideo.com +rr1---sn-q4fl6n6y.googlevideo.com +rr2---sn-hgn7ynek.googlevideo.com +rr1---sn-xguxaxjvh-gufl.googlevideo.com +YTB_FILE_END_7f3a9c + chmod 644 /usr/share/ytbypass/test-domains.txt +} + +do_bytetube_install() { + NEW_BYEDPI=0 + NEW_HEV=0 + command -v fw4 >/dev/null 2>&1 || { echo "ОШИБКА: нужен firewall4 (OpenWrt 22.03+); hev-socks5-tunnel в пакетах — с 24.10"; return 1; } + command -v nft >/dev/null 2>&1 || { echo "ОШИБКА: не найден nft"; return 1; } + if ip rule add pref 8999 fwmark 0x10000/0x10000 lookup 89 2>/dev/null; then + ip rule del pref 8999 2>/dev/null + else + echo "ОШИБКА: ваш ip не поддерживает fwmark с маской. Установите ip-full (замените ip-tiny на ip-full) и попробуйте снова" + return 1 + fi + + _ensure_deps + echo "==> Обновляю списки пакетов" + $UPDATE >/dev/null 2>&1 + + if ! _pkg_is_installed kmod-tun; then + echo "==> Ставлю kmod-tun" + $INSTALL kmod-tun >/dev/null 2>&1 || { echo "ОШИБКА: kmod-tun не установлен"; return 1; } + fi + _bytetube_ensure_dnsmasq_full || return 1 + + if ! _pkg_is_installed hev-socks5-tunnel; then + echo "==> Ставлю hev-socks5-tunnel" + $INSTALL hev-socks5-tunnel >/dev/null 2>&1 || { echo "ОШИБКА: hev-socks5-tunnel не найден в репозитории (есть в feeds OpenWrt 24.10+)"; return 1; } + NEW_HEV=1 + fi + _bytetube_install_byedpi || return 1 + + for p in ca-bundle curl; do + _pkg_is_installed "$p" || $INSTALL "$p" >/dev/null 2>&1 || echo "!! не удалось поставить $p — тест стратегий работать не будет (остальное — да)" + done + + if [ "$NEW_BYEDPI" = 1 ] && [ -x /etc/init.d/byedpi ]; then + /etc/init.d/byedpi stop >/dev/null 2>&1; /etc/init.d/byedpi disable >/dev/null 2>&1 + fi + if [ "$NEW_HEV" = 1 ] && [ -x /etc/init.d/hev-socks5-tunnel ]; then + /etc/init.d/hev-socks5-tunnel stop >/dev/null 2>&1; /etc/init.d/hev-socks5-tunnel disable >/dev/null 2>&1 + fi + + echo "==> Устанавливаю ByeTube (сервис)" + _bytetube_install_payload + rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache + /etc/init.d/rpcd reload >/dev/null 2>&1 + + /etc/init.d/ytbypass enable >/dev/null 2>&1 + echo "==> Запускаю" + /etc/init.d/ytbypass restart >/dev/null 2>&1 + sleep 4 + nslookup youtube.com 127.0.0.1 >/dev/null 2>&1 + sleep 1 + + local ST + ST=$(/usr/bin/ytbypass status 2>/dev/null) + _bt_show() { + if [ "$(jsonfilter -s "$ST" -e "@.$1" 2>/dev/null)" = "true" ]; then + echo " [ok] $2" + else + echo " [--] $2" + fi + } + echo + echo "==> Состояние:" + _bt_show byedpi "ByeDPI (ciadpi)" + _bt_show hev "hev-socks5-tunnel" + _bt_show tun "интерфейс ytb0" + _bt_show nft "правила nftables" + _bt_show route "policy routing" + _bt_show dns "dnsmasq -> nftset" + _bt_show fw "firewall forward" + echo "==> Готово, ByeTube установлен" +} + +do_bytetube_uninstall() { + echo "==> Останавливаю и удаляю ByeTube" + [ -x /usr/bin/ytbypass ] && /usr/bin/ytbypass test stop >/dev/null 2>&1 + if [ -x /etc/init.d/ytbypass ]; then + /etc/init.d/ytbypass stop >/dev/null 2>&1 + /etc/init.d/ytbypass disable >/dev/null 2>&1 + fi + if [ -x /usr/libexec/ytbypass/net.sh ]; then + /usr/libexec/ytbypass/net.sh purge >/dev/null 2>&1 + . /usr/libexec/ytbypass/common.sh + rm -f "$(dnsmasq_confdir)/ytbypass.conf" + fi + rm -rf /etc/config/ytbypass /etc/init.d/ytbypass /etc/hotplug.d/firewall/90-ytbypass \ + /usr/bin/ytbypass /usr/libexec/ytbypass /usr/share/ytbypass \ + /usr/share/nftables.d/chain-pre/forward/50-ytbypass.nft \ + /var/etc/ytbypass /var/run/ytbypass.started /tmp/ytbypass-test \ + /etc/ytbypass /lib/upgrade/keep.d/ytbypass + /etc/init.d/dnsmasq restart >/dev/null 2>&1 + /etc/init.d/firewall reload >/dev/null 2>&1 + rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache + /etc/init.d/rpcd reload >/dev/null 2>&1 + echo "==> Готово." +} + +do_bytetube_purge() { + do_bytetube_uninstall + echo "==> Удаляю пакеты byedpi и hev-socks5-tunnel" + $DELETE byedpi >/dev/null 2>&1 + $DELETE hev-socks5-tunnel >/dev/null 2>&1 + echo "==> Готово." +} + +bytetube_action() { + local action="$1" + case "$action" in + install|update) job_start bytetube_install do_bytetube_install ;; + remove) job_start bytetube_remove do_bytetube_uninstall ;; + purge) job_start bytetube_remove do_bytetube_purge ;; + *) echo '{"error":"неизвестное действие"}'; return 1 ;; + esac +} + + _doh_file="/etc/config/https-dns-proxy" do_doh_install() { @@ -3055,6 +4606,9 @@ case "$cmd" in exclusions_status) exclusions_status ;; exclusions_toggle) exclusions_toggle "$1" ;; exclusions_clear) exclusions_clear ;; + exclusions_file_get) exclusions_file_get ;; + exclusions_file_set) exclusions_file_set "$1" ;; + exclusions_file_restore) exclusions_file_restore ;; tg_status) tg_status ;; tg_action) tg_action "$1" "$2" ;; tg_restart_all) tg_restart_all ;; @@ -3087,6 +4641,8 @@ case "$cmd" in mixomo_warp_action) mixomo_warp_action "$1" ;; mixomo_warp_integrate_action) mixomo_warp_integrate_action ;; mixomo_warp_config_set) mixomo_warp_config_set "$1" ;; + bytetube_installed) bytetube_installed ;; + bytetube_action) bytetube_action "$1" ;; *) echo '{"error":"неизвестная команда"}'; exit 1 ;; esac ZM_INSTALLER_EOF @@ -3135,6 +4691,9 @@ list_methods() { 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 "exclusions_file_get"; json_close_object + json_add_object "exclusions_file_set"; json_add_string "content" "string"; json_close_object + json_add_object "exclusions_file_restore"; 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 @@ -3167,6 +4726,8 @@ list_methods() { json_add_object "mixomo_warp_action"; json_add_string "endpoint_mode" "string"; json_close_object json_add_object "mixomo_warp_integrate_action"; json_close_object json_add_object "mixomo_warp_config_set"; json_add_string "content" "string"; json_close_object + json_add_object "bytetube_installed"; json_close_object + json_add_object "bytetube_action"; json_add_string "action" "string"; json_close_object json_dump } @@ -3209,6 +4770,9 @@ call_method() { exclusions_status) "$BACKEND" exclusions_status ;; exclusions_toggle) json_get_var ip ip; "$BACKEND" exclusions_toggle "$ip" ;; exclusions_clear) "$BACKEND" exclusions_clear ;; + exclusions_file_get) "$BACKEND" exclusions_file_get ;; + exclusions_file_set) json_get_var content content; "$BACKEND" exclusions_file_set "$content" ;; + exclusions_file_restore) "$BACKEND" exclusions_file_restore ;; 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 ;; @@ -3241,6 +4805,8 @@ call_method() { mixomo_warp_action) json_get_var endpoint_mode endpoint_mode; "$BACKEND" mixomo_warp_action "$endpoint_mode" ;; mixomo_warp_integrate_action) "$BACKEND" mixomo_warp_integrate_action ;; mixomo_warp_config_set) json_get_var content content; "$BACKEND" mixomo_warp_config_set "$content" ;; + bytetube_installed) "$BACKEND" bytetube_installed ;; + bytetube_action) json_get_var action action; "$BACKEND" bytetube_action "$action" ;; *) echo '{"error":"unknown method"}'; return 1 ;; esac } @@ -3265,11 +4831,16 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "status", "job_status", "log_tail", "system_info", "strategy_list_v", "strategy_list_flowseal", "strategy_list_youtube", "discord_status", "hosts_status", "hosts_file_get", "doh_status", "game_status", - "system_status", "mirror_status", "exclusions_status", "tg_status", "tgws_status", + "system_status", "mirror_status", "exclusions_status", "exclusions_file_get", "tg_status", "tgws_status", "test_status", "test_results", "zm_update_status", "mixomo_status", "mixomo_config_get", "mixomo_warp_status", - "zapret_latest_version" + "zapret_latest_version", "bytetube_installed" ] + }, + "uci": [ "ytbypass" ], + "file": { + "/usr/bin/ytbypass": [ "exec" ], + "/etc/init.d/ytbypass": [ "exec" ] } }, "write": { @@ -3282,13 +4853,15 @@ cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' "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", + "mirror_set", "exclusions_toggle", "exclusions_clear", "exclusions_file_set", "exclusions_file_restore", "tg_action", "tg_restart_all", "tgws_action", "test_action", "zm_update_action", "mixomo_action", "mixomo_config_set", "mixomo_subscription_set", "mixomo_magitrickle_list_set", "mixomo_autorestart_set", "mixomo_ui_action", - "mixomo_warp_action", "mixomo_warp_integrate_action", "mixomo_warp_config_set" + "mixomo_warp_action", "mixomo_warp_integrate_action", "mixomo_warp_config_set", + "bytetube_action" ] - } + }, + "uci": [ "ytbypass" ] } } } @@ -3313,10 +4886,15 @@ cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF "action": { "type": "view", "path": "zapret-manager/dashboard" } }, "admin/services/zapret-manager/strategy": { - "title": "Настройки Zapret", + "title": "Zapret", "order": 20, "action": { "type": "view", "path": "zapret-manager/strategy" } }, + "admin/services/zapret-manager/zapret2": { + "title": "Zapret2", + "order": 25, + "action": { "type": "view", "path": "zapret-manager/zapret2" } + }, "admin/services/zapret-manager/hosts": { "title": "Hosts", "order": 40, @@ -3332,15 +4910,20 @@ cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF "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/mixomo": { "title": "Mixomo", "order": 57, "action": { "type": "view", "path": "zapret-manager/mixomo" } + }, + "admin/services/zapret-manager/bytetube": { + "title": "ByeTube", + "order": 58, + "action": { "type": "view", "path": "zapret-manager/bytetube" } + }, + "admin/services/zapret-manager/system": { + "title": "Система", + "order": 60, + "action": { "type": "view", "path": "zapret-manager/system" } } } ZM_INSTALLER_EOF @@ -3386,6 +4969,9 @@ var callMirrorSet = rpc.declare({ object: 'zapret-manager', method: 'mirror_set' 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 callExclusionsFileGet = rpc.declare({ object: 'zapret-manager', method: 'exclusions_file_get', expect: {} }); +var callExclusionsFileSet = rpc.declare({ object: 'zapret-manager', method: 'exclusions_file_set', params: ['content'], expect: {} }); +var callExclusionsFileRestore = rpc.declare({ object: 'zapret-manager', method: 'exclusions_file_restore', 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: {} }); @@ -3418,6 +5004,7 @@ var callMixomoWarpStatus = rpc.declare({ object: 'zapret-manager', method: 'mixo var callMixomoWarpAction = rpc.declare({ object: 'zapret-manager', method: 'mixomo_warp_action', params: ['endpoint_mode'], expect: {} }); var callMixomoWarpIntegrateAction = rpc.declare({ object: 'zapret-manager', method: 'mixomo_warp_integrate_action', expect: {} }); var callMixomoWarpConfigSet = rpc.declare({ object: 'zapret-manager', method: 'mixomo_warp_config_set', params: ['content'], expect: {} }); +var callBytetubeInstalled = rpc.declare({ object: 'zapret-manager', method: 'bytetube_installed', expect: {} }); function detectMissingThemeVar() { if (document.documentElement.hasAttribute('data-zm-theme-checked')) return; @@ -3520,7 +5107,8 @@ function pollJob(job, logEl, onDone, onTick) { finished = true; clearInterval(timer); delete _activePolls[job]; - toast('Роутер не отвечает — операция может ещё выполняться в фоне. Обновите страницу через полминуты, чтобы проверить результат.', 'warning', 25000); + renderLog(logEl, '==> ОШИБКА: роутер не отвечает, не удалось получить статус операции.'); + onDone(false); } }); }, 1200); @@ -3617,6 +5205,9 @@ return baseclass.extend({ exclusionsStatus: callExclusionsStatus, exclusionsToggle: callExclusionsToggle, exclusionsClear: callExclusionsClear, + exclusionsFileGet: callExclusionsFileGet, + exclusionsFileSet: callExclusionsFileSet, + exclusionsFileRestore: callExclusionsFileRestore, tgStatus: callTgStatus, tgAction: callTgAction, tgRestartAll: callTgRestartAll, @@ -3648,7 +5239,8 @@ return baseclass.extend({ mixomoWarpStatus: callMixomoWarpStatus, mixomoWarpAction: callMixomoWarpAction, mixomoWarpIntegrateAction: callMixomoWarpIntegrateAction, - mixomoWarpConfigSet: callMixomoWarpConfigSet + mixomoWarpConfigSet: callMixomoWarpConfigSet, + bytetubeInstalled: callBytetubeInstalled }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/zapret-manager/common.js' @@ -3669,28 +5261,35 @@ return view.extend({ 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 {}; }), zm.zmUpdateStatus().catch(function() { return {}; }), - zm.mixomoStatus().catch(function() { return {}; }) + zm.mixomoStatus().catch(function() { return {}; }), + zm.tgStatus().catch(function() { return {}; }), + zm.tgwsStatus().catch(function() { return {}; }), + zm.bytetubeInstalled().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 zmUpdate = all[6] || {}; - var mixomoData = all[7] || {}; + var sysInfo = all[4] || {}; + var zmUpdate = all[5] || {}; + var mixomoData = all[6] || {}; + var tgData = all[7] || {}; + var tgwsData = all[8] || {}; + var bytetubeData = all[9] || {}; 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, mixomo) { + function row(label, node) { + return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); + } + + function renderOverview(d, doh, hosts, sys, tg, tgws, mixomo, bytetube) { var hostsEnabled = (hosts.items || []).filter(function(it) { return it.enabled; }).length; var hostsTotal = (hosts.items || []).length; @@ -3699,122 +5298,48 @@ return view.extend({ if (sys.ipv6_enabled) sysFlags.push('IPv6 в Zapret включён'); if (sys.flow_offloading_fix) sysFlags.push('Flow Offloading fix'); - var mixomoRunning = mixomo.mihomo_running === true && mixomo.hev_running === true && mixomo.magitrickle_running === true; + var tgInstalled = !!(tg && (tg.mtproto === 'installed' || tg.socks5 === 'installed' || tg.rust === 'installed')) + || !!(tgws && tgws.installed === 'installed'); + var mixomoInstalled = mixomo && mixomo.mihomo === 'installed'; + var bytetubeInstalled = bytetube && bytetube.installed === true; + + var items = []; + items.push(row('Zapret', d.zapret === 'installed' + ? zm.badge(d.zapret_running === true, 'запущен', 'остановлен') + : zm.badge(false, '', 'не установлен'))); + if (d.zapret === 'installed' && d.zapret_version) items.push(row('Версия Zapret', E('span', {}, d.zapret_version))); + if (d.zapret === 'installed' && d.strategy) items.push(row('Стратегия', E('span', {}, d.strategy))); + items.push(row('Zapret2', d.zapret2 === 'installed' + ? zm.badge(d.zapret2_running === true, 'запущен', 'остановлен') + : zm.badge(false, '', 'не установлен'))); + items.push(row('DNS over HTTPS', doh.installed + ? zm.badge(true, DOH_LABELS[doh.current] || doh.current || 'установлен, провайдер не определён', '') + : zm.badge(false, '', 'не установлен'))); + items.push(row('Домены в hosts', hosts.geohide + ? zm.badge(true, 'GeoHide ' + hosts.geohide.toUpperCase(), '') + : hostsTotal + ? zm.badge(hostsEnabled > 0, hostsEnabled + ' из ' + hostsTotal + ' включено', 'ничего не включено') + : E('span', {}, '—'))); + if (sysFlags.length) items.push(row('Система', E('span', { 'style': 'overflow-wrap:anywhere' }, sysFlags.join(', ')))); + items.push(row('TG WS Proxy', zm.badge(tgInstalled, 'установлен', 'не установлен'))); + items.push(row('Mixomo', zm.badge(mixomoInstalled, 'установлен', 'не установлен'))); + items.push(row('ByeTube', zm.badge(bytetubeInstalled, 'установлен', 'не установлен'))); + + var half = Math.ceil(items.length / 2); return E('div', { 'class': 'zm-card', 'style': 'margin-bottom:4px' }, [ - E('h3', {}, 'Обзор'), - E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'Zapret Manager LuCI'), - E('span', {}, 'v' + (zmUpdate.current || '?')) - ]), - 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([]), - mixomo.mihomo === 'installed' ? E('div', { 'class': 'zm-row' }, [ - E('span', { 'class': 'zm-label' }, 'Mixomo'), - zm.badge(mixomoRunning, 'запущен', 'остановлен') - ]) : 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([]) + E('div', { 'class': 'bt-cols' }, [ + E('div', { 'class': 'bt-col' }, items.slice(0, half)), + E('div', { 'class': 'bt-col' }, items.slice(half)) + ]) ]); } - function renderCards(d) { + function renderCards() { 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('h3', {}, 'Система'), E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Модель:'), E('span', {}, sysInfo.model || '—') ]), @@ -3835,8 +5360,6 @@ return view.extend({ ]) ]); - cards.appendChild(zCard); - cards.appendChild(z2Card); cards.appendChild(sysCard); } @@ -3846,20 +5369,23 @@ return view.extend({ zm.dohStatus().catch(function() { return {}; }), zm.hostsStatus().catch(function() { return { items: [] }; }), zm.systemStatus().catch(function() { return {}; }), - zm.mixomoStatus().catch(function() { return {}; }) + zm.mixomoStatus().catch(function() { return {}; }), + zm.tgStatus().catch(function() { return {}; }), + zm.tgwsStatus().catch(function() { return {}; }), + zm.bytetubeInstalled().catch(function() { return {}; }) ]).then(function(res) { overviewEl.innerHTML = ''; - overviewEl.appendChild(renderOverview(res[0], res[1], res[2], res[3], res[4])); + overviewEl.appendChild(renderOverview(res[0], res[1], res[2], res[3], res[5], res[6], res[4], res[7])); }); } - overviewEl.appendChild(renderOverview(data, dohData, hostsData, sysData, mixomoData)); - renderCards(data); + overviewEl.appendChild(renderOverview(data, dohData, hostsData, sysData, tgData, tgwsData, mixomoData, bytetubeData)); + renderCards(); var updateEl = E('div', {}); wrap.appendChild(updateEl); wrap.appendChild(E('div', { 'class': 'zm-header' }, [ - E('h2', {}, 'Zapret Manager'), - E('span', { 'class': 'zm-header-by' }, 'by StressOzz'), + E('h2', {}, 'Zapret Manager LuCI'), + E('span', { 'class': 'zm-header-by' }, 'by StressOzz · v' + (zmUpdate.current || '?')), 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') @@ -3867,13 +5393,7 @@ return view.extend({ ])); 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; var zmUpdateBusy = false; @@ -3903,64 +5423,6 @@ return view.extend({ renderZmUpdate(); 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 @@ -4092,7 +5554,7 @@ cat > '/www/luci-static/resources/view/zapret-manager/hosts.js' << 'ZM_INSTALLER 'require zapret-manager.common as zm'; var LABELS = { - nalog: 'Налог.ру', + nalog: 'nalog.ru', ntc: 'ntc.party', instagram: 'Instagram & Facebook', librusec: 'lib.rus.ec', @@ -4100,7 +5562,7 @@ var LABELS = { twitch: 'Twitch', telegram: 'Telegram Web', spotify: 'Spotify', - spotifyext: 'Spotify (расширенный)', + rutor: 'rutor.info', scell: 'Supercell (Clash, Brawl Stars)', githubraw: 'githubusercontent.com', github: 'GitHub', @@ -4829,6 +6291,7 @@ var TABS = [ { id: 'youtube', label: 'YouTube' }, { id: 'game', label: 'Игры' }, { id: 'discord', label: 'Discord' }, + { id: 'domains_exclude', label: 'Домены исключения' }, { id: 'exclusions', label: 'Исключение устройств' } ]; @@ -4859,17 +6322,21 @@ return view.extend({ zm.strategyListYoutube(), zm.gameStatus(), zm.discordStatus(), - zm.exclusionsStatus() + zm.exclusionsStatus(), + zm.zapretLatestVersion().catch(function() { return {}; }) ]); }, render: function(all) { var view = this; var statusData = all[0], vListData = all[1], testData = all[2], ytInitialData = all[3], gameData = all[4], discordData = all[5], exclusionsData = all[6]; + var latestVersion = (all[7] && all[7].version) || ''; var wrap = E('div', { 'class': 'zm-wrap' }); + var mainSection = E('div', {}); var activeTab = 'strategy'; + var zapretInstalled = statusData.zapret === 'installed'; - var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin-bottom:14px' }); + var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin:10px 0' }); var panels = {}; TABS.forEach(function(t) { panels[t.id] = E('div', { 'style': t.id === activeTab ? '' : 'display:none' }); @@ -4895,6 +6362,89 @@ return view.extend({ var logEl = E('pre', { 'class': 'zm-log' }); var busy = false; + var zBusy = false; + var zLogEl = E('pre', { 'class': 'zm-log' }); + var zBannerEl = E('div', {}); + var zCardWrap = E('div', {}); + + function renderZCard(d) { + zCardWrap.innerHTML = ''; + var zActions = []; + if (d.zapret === 'installed') { + zActions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { doZapretAction('remove'); } + }, 'Удалить')); + if (/^[0-9]+\.[0-9]+$/.test(latestVersion) && d.zapret_version && latestVersion !== d.zapret_version) { + zActions.push(E('button', { + 'class': 'cbi-button', + 'click': function() { doZapretAction('update'); } + }, 'Обновить до ' + latestVersion)); + } + zActions.push(E('button', { + 'class': 'cbi-button', + 'click': function() { doZapretAction(d.zapret_running ? 'stop' : 'start'); } + }, d.zapret_running ? 'Остановить' : 'Запустить')); + } else { + zActions.push(E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { doZapretAction('install'); } + }, 'Установить и настроить')); + } + + var fields = [ + E('span', {}, [ E('span', { 'class': 'zm-label' }, 'Статус: '), + d.zapret === 'installed' + ? zm.badge(d.zapret_running === true, 'запущен', 'остановлен') + : zm.badge(false, '', 'не установлен') + ]) + ]; + if (d.zapret_version) fields.push(E('span', {}, [ E('span', { 'class': 'zm-label' }, 'Версия: '), E('span', {}, d.zapret_version) ])); + if (d.strategy) fields.push(E('span', {}, [ E('span', { 'class': 'zm-label' }, 'Стратегия: '), E('span', {}, d.strategy) ])); + + zCardWrap.appendChild(E('div', { 'class': 'zm-card', 'style': 'margin-bottom:10px' }, [ + E('h3', {}, 'Zapret'), + E('div', { 'class': 'zm-row', 'style': 'justify-content:space-between; width:100%' }, [ + E('div', { 'style': 'display:flex; gap:22px; flex-wrap:wrap; align-items:center' }, fields), + E('div', { 'class': 'zm-actions', 'style': 'margin:0' }, zActions) + ]) + ])); + } + + function doZapretAction(action) { + if (zBusy) { 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) zBusy = true; + + zm.zapretAction(action).then(function(res) { + if (job && res && res.started) { + zm.pollJob(job, zLogEl, function(ok) { + zBusy = false; + zm.toast(ok ? 'Готово' : 'Операция завершилась с ошибкой', ok ? 'info' : 'error'); + zm.status().then(function(d) { status = d; renderZCard(d); renderBanner(); renderV(); }); + if (ok && (action === 'install' || action === 'remove')) { + zBannerEl.innerHTML = ''; + zBannerEl.appendChild(zm.refreshBanner('Пункт меню Zapret в LuCI мог измениться — выйдите и зайдите заново.')); + } + }); + } else if (res) { + zBusy = false; + status = res; + renderZCard(res); + renderBanner(); + renderV(); + } + }).catch(function() { zBusy = false; }); + } + + renderZCard(status); + mainSection.appendChild(zCardWrap); + mainSection.appendChild(zLogEl); + mainSection.appendChild(zBannerEl); + var currentBanner = E('div', { 'class': 'zm-current-banner' }); panels.strategy.appendChild(currentBanner); @@ -5448,6 +6998,55 @@ return view.extend({ panels.discord.appendChild(fakeCard); })(); + (function buildDomainsExcludePanel() { + var editorCard = E('div', { 'class': 'zm-card' }); + var contentEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false' }); + var editorBusy = false; + + function refreshFile() { + zm.exclusionsFileGet().then(function(res) { + if (res.error) { zm.toast(res.error, 'error'); return; } + contentEl.value = res.content || ''; + }); + } + + editorCard.appendChild(E('h3', {}, 'Домены исключения')); + editorCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Редактирование /opt/zapret/ipset/zapret-hosts-user-exclude.txt — домены, для которых Zapret не применяется. Сохранение перезапускает Zapret.')); + editorCard.appendChild(contentEl); + editorCard.appendChild(E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + if (editorBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + editorBusy = true; + zm.toast('Сохраняем и перезапускаем Zapret', 'warning'); + zm.exclusionsFileSet(contentEl.value).then(function(res) { + editorBusy = false; + if (res.error) { zm.toast(res.error, 'error'); return; } + zm.toast('Список исключений сохранён, Zapret перезапущен', 'info'); + }).catch(function() { editorBusy = false; }); + } + }, 'Сохранить и применить'), + E('button', { + 'class': 'cbi-button', + 'click': function() { + if (editorBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + editorBusy = true; + zm.toast('Восстанавливаем список исключений', 'warning'); + zm.exclusionsFileRestore().then(function(res) { + editorBusy = false; + if (res.error) { zm.toast(res.error, 'error'); return; } + contentEl.value = res.content || ''; + zm.toast('Список исключений восстановлен', 'info'); + }).catch(function() { editorBusy = false; }); + } + }, 'Восстановить исключения') + ])); + + refreshFile(); + panels.domains_exclude.appendChild(editorCard); + })(); + (function buildExclusionsPanel() { var data = exclusionsData; @@ -5546,15 +7145,115 @@ return view.extend({ panels.exclusions.appendChild(card); })(); - renderTabBar(); - wrap.appendChild(tabBar); - TABS.forEach(function(t) { wrap.appendChild(panels[t.id]); }); + if (zapretInstalled) { + renderTabBar(); + mainSection.appendChild(tabBar); + TABS.forEach(function(t) { mainSection.appendChild(panels[t.id]); }); + } + wrap.appendChild(mainSection); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/strategy.js' +mkdir -p /www/luci-static/resources/view/zapret-manager +chmod 0755 /www/luci-static/resources/view/zapret-manager +cat > '/www/luci-static/resources/view/zapret-manager/zapret2.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require zapret-manager.common as zm'; + +return view.extend({ + load: function() { + zm.injectCss(); + return zm.status(); + }, + + render: function(data) { + var view = this; + var wrap = E('div', { 'class': 'zm-wrap' }); + var cards = E('div', { 'class': 'zm-cards' }); + var logEl = E('pre', { 'class': 'zm-log' }); + var bannerEl = E('div', {}); + + function renderCards(d) { + cards.innerHTML = ''; + + var z2Actions = []; + if (d.zapret2 === 'installed') { + z2Actions.push(E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': function() { view.doAction2('remove'); } + }, 'Удалить')); + z2Actions.push(E('button', { + 'class': 'cbi-button', + 'click': function() { view.doAction2(d.zapret2_running ? 'stop' : 'start'); } + }, d.zapret2_running ? 'Остановить' : 'Запустить')); + } else { + z2Actions.push(E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { 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) + ]); + + cards.appendChild(z2Card); + } + + renderCards(data); + wrap.appendChild(cards); + wrap.appendChild(logEl); + wrap.appendChild(bannerEl); + + this.logEl = logEl; + this.bannerEl = bannerEl; + this.renderCards = renderCards; + + return wrap; + }, + + 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); }); + 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); + } + }).catch(function() { view.busy2 = false; }); + } +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/zapret2.js' + mkdir -p /www/luci-static/resources/view/zapret-manager chmod 0755 /www/luci-static/resources/view/zapret-manager cat > '/www/luci-static/resources/view/zapret-manager/style.css' << 'ZM_INSTALLER_EOF' @@ -5748,6 +7447,16 @@ html.zm-theme-dark .zm-config-editor { border-color: rgba(255,255,255,.14); } background: rgba(110,118,129,.08); border-color: rgba(110,118,129,.2); } +.bt-cols { display: grid; grid-template-columns: 1fr 1fr; column-gap: 44px; } +.bt-col { min-width: 0; } +.bt-col .zm-label { flex: 0 0 170px; } +@media (max-width: 860px) { + .bt-cols { grid-template-columns: 1fr; } + .bt-col .zm-row { justify-content: space-between; } + .bt-col .zm-label { flex: 0 1 auto; } + .bt-col .zm-row > :last-child { margin-left: auto; } +} + .cbi-page-actions { display: none !important; } ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css' @@ -5927,7 +7636,7 @@ return view.extend({ var uninstallLog = E('pre', { 'class': 'zm-log' }); var uninstallCard = E('div', { 'class': 'zm-card' }, [ - E('h3', {}, 'Удалить Zapret Manager из LuCI'), + E('h3', {}, 'Удалить Zapret Manager LuCI'), E('p', { 'class': 'zm-hint' }, 'Уберёт только веб-интерфейс (эту панель) — саму программу-оболочку из LuCI. ' + 'Zapret, Zapret2, DNS over HTTPS и TG WS Proxy, если они были установлены через ' + @@ -5938,16 +7647,16 @@ return view.extend({ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { - if (!confirm('Удалить веб-интерфейс Zapret Manager из LuCI?\n\nСам Zapret и остальные установленные через панель компоненты не пострадают. Действие необратимо — панель придётся ставить заново.')) { + 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.renderLog(uninstallLog, '==> Удаляем веб-интерфейс Zapret Manager LuCI'); + 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'); + zm.toast('Zapret Manager LuCI удалён', 'info'); setTimeout(function() { location.href = L.url('admin/logout'); }, 2500); }).catch(function() { zm.renderLog(uninstallLog, '==> Готово (соединение прервано — это ожидаемо, панель уже удалена). Выходим из LuCI'); @@ -6232,6 +7941,1265 @@ return view.extend({ ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/tgproxy.js' + +mkdir -p /www/luci-static/resources/ytbypass +cat > '/www/luci-static/resources/ytbypass/common.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require baseclass'; +'require fs'; +'require uci'; + +var CTL = '/usr/bin/ytbypass'; +var INIT = '/etc/init.d/ytbypass'; +var CONF = 'ytbypass'; + +function callJson(args) { + return fs.exec(CTL, args).then(function(r) { + var out = (r.stdout || '').trim(); + try { return JSON.parse(out); } + catch (e) { return { error: out || r.stderr || 'нет ответа' }; } + }).catch(function(e) { return { error: e.message }; }); +} + +function callText(args) { + return fs.exec(CTL, args).then(function(r) { return r.stdout || ''; }) + .catch(function() { return ''; }); +} + +function detectMissingThemeVar() { + if (document.documentElement.hasAttribute('data-zm-theme-checked')) return; + document.documentElement.setAttribute('data-zm-theme-checked', '1'); + try { + var declared = getComputedStyle(document.documentElement).getPropertyValue('--background-color-medium').trim(); + if (declared) return; + + var el = document.body, bg = '', hops = 0; + while (el && hops < 6) { + var c = getComputedStyle(el).backgroundColor; + if (c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent') { bg = c; break; } + el = el.parentElement; + hops++; + } + var m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return; + var luminance = (0.299 * (+m[1]) + 0.587 * (+m[2]) + 0.114 * (+m[3])) / 255; + if (luminance < 0.5) document.documentElement.classList.add('zm-theme-dark'); + } catch (e) {} +} + +function injectCss() { + detectMissingThemeVar(); + if (document.getElementById('bt-css')) return; + var l = document.createElement('link'); + l.id = 'bt-css'; + l.rel = 'stylesheet'; + l.href = L.resource('view/ytbypass/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 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 span(cls, text) { + var s = document.createElement('span'); + s.className = cls; + s.textContent = text; + return s; +} + +function scoreClass(ok, total, controlOk) { + if (ok === total) return 'bt-c-ok'; + if (controlOk !== null && controlOk !== undefined) return ok > controlOk ? 'bt-c-warn' : 'bt-c-bad'; + return ok > 0 ? 'bt-c-warn' : 'bt-c-bad'; +} + +function chipClass(ok, total, controlOk) { + return scoreClass(ok, total, controlOk).replace('bt-c-', 'bt-chip bt-chip-'); +} + +function logLine(line, ctx, nameOf) { + var div = document.createElement('div'); + var m, p; + + if ((m = line.match(/^==> \[(\d+)\/(\d+)\] (.*)$/))) { + div.appendChild(span('zm-log-arrow', '==> ')); + div.appendChild(span('bt-c-key', '[' + m[1] + '/' + m[2] + '] ')); + div.appendChild(span('bt-c-white', m[3])); + p = nameOf ? nameOf(m[3]) : null; + if (p) div.appendChild(span('bt-c-dim', ' ← ' + p)); + } + else if ((m = line.match(/^==> Результат: (\d+)\/(\d+)$/))) { + ctx.ctrl = +m[1]; + div.appendChild(span('zm-log-arrow', '==> ')); + div.appendChild(span('bt-c-dim', 'Результат контрольного замера: ')); + div.appendChild(span('bt-c-white', m[1] + '/' + m[2])); + } + else if ((m = line.match(/^(\s+)результат: (\d+)\/(\d+)$/))) { + div.appendChild(span('bt-c-dim', m[1] + 'результат: ')); + div.appendChild(span(scoreClass(+m[2], +m[3], ctx.ctrl), m[2] + '/' + m[3])); + } + else if (/^\s+(пропуск|прервано)/.test(line)) { + div.className = 'zm-log-msg-warn'; + div.textContent = line; + } + else if ((m = line.match(/^==> Лучшая стратегия: (.*?)\s*→\s*(\d+)\/(\d+)\s*$/))) { + div.appendChild(span('zm-log-arrow', '==> ')); + div.appendChild(span('zm-log-msg-ok', 'Лучшая стратегия: ')); + div.appendChild(span('bt-c-white', m[1] + ' ')); + div.appendChild(span('bt-c-dim', '→ ')); + div.appendChild(span(scoreClass(+m[2], +m[3], ctx.ctrl), m[2] + '/' + m[3])); + } + else if (/^==> (Тест завершён|Готово)/.test(line)) { + div.appendChild(span('zm-log-arrow', '==> ')); + div.appendChild(span('zm-log-msg-ok', line.slice(4))); + } + else if (/^==> (Тест остановлен|Пропущено)/.test(line)) { + div.appendChild(span('zm-log-arrow', '==> ')); + div.appendChild(span('zm-log-msg-warn', line.slice(4))); + } + else if (/^==> Результаты$/.test(line)) { + div.appendChild(span('zm-log-arrow', '==> ')); + div.appendChild(span('zm-log-msg-info', 'Результаты')); + } + else if ((m = line.match(/^(.*?)\s*→\s*(\d+)\/(\d+)\s*$/))) { + if (/^Контрольный тест/.test(m[1])) { + ctx.ctrl = +m[2]; + div.appendChild(span('bt-c-dim', m[1] + ' → ')); + div.appendChild(span('bt-c-white', m[2] + '/' + m[3])); + } else { + div.appendChild(span('bt-c-white', m[1] + ' ')); + div.appendChild(span('bt-c-dim', '→ ')); + div.appendChild(span(scoreClass(+m[2], +m[3], ctx.ctrl), m[2] + '/' + m[3])); + } + } + else if (/^ОШИБКА/.test(line)) { + div.className = 'zm-log-msg-error'; + div.textContent = line; + } + else if (/^==> /.test(line)) { + div.appendChild(span('zm-log-arrow', '==> ')); + div.appendChild(span('zm-log-msg-info', line.slice(4))); + } + else { + div.className = 'zm-log-code'; + div.textContent = line; + } + return div; +} + +function renderTestLog(logEl, text, nameOf) { + var ctx = { ctrl: null }; + while (logEl.firstChild) logEl.removeChild(logEl.firstChild); + (text || '').split('\n').forEach(function(l) { + if (l !== '') logEl.appendChild(logLine(l, ctx, nameOf)); + }); + logEl.scrollTop = logEl.scrollHeight; +} + +function parseResults(text) { + var rows = [], control = null; + (text || '').split('\n').forEach(function(line) { + var m = line.match(/^(.*?)\s*→\s*(\d+)\/(\d+)\s*$/); + if (!m) return; + if (/^Контрольный тест/.test(m[1])) + control = { ok: +m[2], total: +m[3] }; + else + rows.push({ opts: m[1], ok: +m[2], total: +m[3] }); + }); + rows.forEach(function(r, i) { r.i = i; }); + rows.sort(function(a, b) { return (b.ok - a.ok) || (a.i - b.i); }); + return { control: control, rows: rows }; +} + +/* ---- настройки: читаются/пишутся напрямую через UCI (как штатный Save & Apply) ---- */ + +function b2n(v, def) { + if (v === undefined || v === null || v === '') return def; + return (v === '1' || v === 1 || v === true) ? 1 : 0; +} + +function config() { + return uci.load(CONF).then(function() { + var domain = uci.get(CONF, 'main', 'domain'); + return { + enabled: b2n(uci.get(CONF, 'main', 'enabled'), 0) === 1, + byedpi_opts: uci.get(CONF, 'main', 'byedpi_opts') || '', + byedpi_port: uci.get(CONF, 'main', 'byedpi_port') || '1088', + quic: uci.get(CONF, 'main', 'quic') || 'block', + ipv6: b2n(uci.get(CONF, 'main', 'ipv6'), 1) === 1, + default_domains: b2n(uci.get(CONF, 'main', 'default_domains'), 1) === 1, + domain: Array.isArray(domain) ? domain : (domain ? [ domain ] : []) + }; + }).catch(function(e) { return { error: e.message }; }); +} + +function configSet(pairs) { + return uci.load(CONF).then(function() { + Object.keys(pairs).forEach(function(k) { + var v = pairs[k]; + if (k === 'enabled' || k === 'ipv6' || k === 'default_domains') + v = String(v ? 1 : 0); + else if (Array.isArray(v)) { + /* пусто -> опция удаляется совсем, иначе список сохранится пустой строкой */ + if (!v.length) { uci.unset(CONF, 'main', k); return; } + } else { + v = String(v); + } + uci.set(CONF, 'main', k, v); + }); + return uci.save().then(function() { + return uci.apply(); + }).then(function() { + return { ok: true }; + }); + }).catch(function(e) { return { error: e.message }; }); +} + +return baseclass.extend({ + injectCss: injectCss, + toast: toast, + badge: badge, + span: span, + chipClass: chipClass, + scoreClass: scoreClass, + renderTestLog: renderTestLog, + parseResults: parseResults, + callJson: callJson, + callText: callText, + + status: function() { return callJson([ 'status' ]); }, + config: config, + configSet: configSet, + service: function(action) { + return fs.exec(INIT, [ action ]).then(function() { return { ok: true }; }) + .catch(function(e) { return { error: e.message }; }); + }, + flush: function() { + return fs.exec(CTL, [ 'flush' ]).then(function() { return { ok: true }; }) + .catch(function(e) { return { error: e.message }; }); + }, + setStrategy: function(opts) { return callJson([ 'set-strategy', opts ]); }, + listGet: function(kind) { return callText([ 'test', 'list', 'get', kind ]); }, + listSet: function(kind, text) { return callJson([ 'test', 'list', 'set', kind, text ]); }, + listReset: function(kind) { return callJson([ 'test', 'list', 'reset', kind ]); }, + testStatus: function() { return callJson([ 'test', 'status' ]); }, + testStart: function() { return callJson([ 'test', 'start' ]); }, + testStop: function() { return callJson([ 'test', 'stop' ]); }, + testClear: function() { return callJson([ 'test', 'clear' ]); }, + testLog: function() { return callText([ 'test', 'log' ]); }, + testResults: function() { return callText([ 'test', 'results' ]); } +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/ytbypass/common.js' + +cat > '/www/luci-static/resources/ytbypass/presets.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require baseclass'; + +/* + * Готовые стратегии ByeDPI (параметры ciadpi). + * name — короткое имя для списка, чтобы не читать длинную командную строку; + * опции подставляются в поле «Параметры ByeDPI». + * Под своего провайдера лучшую стратегию подбирает вкладка «Тест стратегий». + */ +var LIST = [ + { + id: 'p1', + label: 'Стратегия 1', + name: '1 · Каскад disorder/split + tlsrec + md5sig, авто-режим -As (по умолчанию)', + opts: '-d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -r1+s -S -a1 -As -d1 -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -S -a1' + }, + { + id: 'p2', + label: 'Стратегия 2', + name: '2 · Короткая: OOB + tlsrec у SNI', + opts: '-o1 -a1 -r-5+se' + }, + { + id: 'p3', + label: 'Стратегия 3', + name: '3 · Fake SNI google.com + disorder/OOB (TTL 4)', + opts: '-n "google.com" -Qr -d5+sm -f3+sm -o2 -t4 -a1' + }, + { + id: 'p4', + label: 'Стратегия 4', + name: '4 · Каскад disorder/split без tlsrec и авто-режима', + opts: '-d1 -s1+s -d3+s -s6+s -d9+s -s12+s -d15+s -s20+s -d25+s -s30+s -d35+s -a1' + }, + { + id: 'p5', + label: 'Стратегия 5', + name: '5 · Fake + disoob + tlsrec (TTL 5 и 15)', + opts: '-f1 -t5 -n "google.com" -q3+h -Qr -f2 -q1 -r1+s -t15 -q1 -o2 -a1' + }, + { + id: 'p6', + label: 'Стратегия 6', + name: '6 · OOB + tlsrec + авто-режим -At,r,s, fake google.com', + opts: '-o1 -r-5+se -a1 -At,r,s -d1 -n "google.com" -Qr -f-1 -a1' + } +]; + +/* для сравнения: без кавычек, пробелы схлопнуты (так же, как в бэкенде) */ +function clean(s) { + return String(s == null ? '' : s).replace(/["']/g, '').replace(/\s+/g, ' ').trim(); +} + +/* для хранения: только пробелы, кавычки остаются как ввёл пользователь */ +function norm(s) { + return String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); +} + +function find(opts) { + var c = clean(opts); + if (!c) return null; + for (var i = 0; i < LIST.length; i++) + if (clean(LIST[i].opts) === c) return LIST[i]; + return null; +} + +function byId(id) { + for (var i = 0; i < LIST.length; i++) + if (LIST[i].id === id) return LIST[i]; + return null; +} + +return baseclass.extend({ + list: LIST, + DEFAULT: LIST[0].opts, + clean: clean, + norm: norm, + find: find, + byId: byId +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/ytbypass/presets.js' + +mkdir -p /www/luci-static/resources/view/ytbypass +cat > '/www/luci-static/resources/view/ytbypass/style.css' << 'ZM_INSTALLER_EOF' +.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; +} + +html.zm-theme-dark .zm-card { + background: #1c2128; + border-color: rgba(255,255,255,.10); + box-shadow: 0 1px 3px rgba(0,0,0,.25), 0 1px 2px rgba(0,0,0,.2); +} +.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; } + +.zm-grid { display: flex; flex-wrap: wrap; gap: 9px; } +.zm-grid-devices { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 9px; } +.zm-grid-devices .zm-tile { flex: none; min-width: 0; width: 100%; box-sizing: border-box; } + +.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); +} +html.zm-theme-dark .zm-tile:not(.zm-active):not(.zm-tile-off) { + background: #22272e; + border-color: rgba(255,255,255,.12); +} +.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-off { + border-color: rgba(207,34,46,.35); background: rgba(207,34,46,.08); + color: #cf222e; font-weight: 600; +} +.zm-tile.zm-tile-off::before { content: "✗ "; } +.zm-tile.zm-tile-off:hover { border-color: #cf222e; } +.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; } + +.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-config-editor { + width: 100%; box-sizing: border-box; min-height: 420px; + background: #0d1117; color: #e6edf3; + font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", monospace; + font-size: 13px; line-height: 1.6; + border: 1px solid rgba(255,255,255,.1); border-radius: 10px; + padding: 14px 16px; margin: 10px 0; + white-space: pre; overflow: auto; resize: vertical; +} +html.zm-theme-dark .zm-config-editor { border-color: rgba(255,255,255,.14); } + +.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; } + + +#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; } + +.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); +} + +.bt-cols { display: grid; grid-template-columns: 1fr 1fr; column-gap: 44px; } +.bt-col { min-width: 0; } +.bt-col .zm-label { flex: 0 0 170px; } +@media (max-width: 860px) { + .bt-cols { grid-template-columns: 1fr; } + .bt-col .zm-row { justify-content: space-between; } + .bt-col .zm-label { flex: 0 1 auto; } + .bt-col .zm-row > :last-child { margin-left: auto; } +} +.bt-current { flex-direction: column; align-items: stretch; gap: 8px; padding: 14px 18px; } +.bt-current-label { font-size: 13px; font-weight: 700; } +.bt-current-cmd { + background: #0d1117; color: #7ee787; + font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", monospace; + font-size: 14px; line-height: 1.65; + border-radius: 8px; padding: 12px 14px; + white-space: normal; user-select: all; +} +.bt-panel { display: block; max-height: none; min-height: 0; margin-top: 10px; } +.bt-table { width: 100%; table-layout: fixed; border-collapse: collapse; border-spacing: 0; margin: 0; background: transparent; } +.bt-table td { padding: 8px 8px 8px 0; border: 0; border-top: 1px solid rgba(255,255,255,.10); vertical-align: top; background: transparent; color: inherit; white-space: normal; overflow-wrap: anywhere; } +.bt-table tr:first-child td { border-top: 0; } +.bt-td-n { width: 2.4em; color: #8b949e; } +.bt-td-s { width: 6.4em; } +.bt-td-a { width: 9em; text-align: right; padding-right: 0 !important; } +@media (max-width: 760px) { + .bt-table, .bt-table tbody { display: block; } + .bt-table tr { display: grid; grid-template-columns: auto 1fr auto; column-gap: 10px; align-items: center; padding: 9px 0; border-top: 1px solid rgba(255,255,255,.10); } + .bt-table tr:first-child { border-top: 0; } + .bt-table td { display: block; width: auto; border: 0 !important; padding: 0 !important; } + .bt-table td.bt-td-c { grid-column: 1 / -1; grid-row: 2; margin-top: 7px; } +} +.bt-cmd { color: #e6edf3; white-space: normal; } +.bt-tok { white-space: nowrap; } +.bt-name { color: #8b949e; margin-bottom: 2px; } +.bt-chip { display: inline-block; min-width: 4.4em; text-align: center; padding: 1px 8px; border-radius: 5px; font-weight: 700; color: #0d1117; } +.bt-chip-ok { background: #3fb950; } +.bt-chip-warn { background: #ffa657; } +.bt-chip-bad { background: #ff7b72; } +.bt-chip-off { background: #8b949e; } +.bt-c-ok { color: #3fb950; font-weight: 700; } +.bt-c-warn { color: #ffa657; font-weight: 700; } +.bt-c-bad { color: #ff7b72; font-weight: 700; } +.bt-c-dim { color: #8b949e; } +.bt-c-white { color: #e6edf3; } +.bt-c-key { color: #e3c04a; font-weight: 700; } +.bt-c-cur { color: #56d4dd; font-weight: 700; } +.bt-input { width: 110px; box-sizing: border-box; } +.cbi-page-actions { display: none !important; } +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/ytbypass/style.css' + +cat > '/www/luci-static/resources/view/zapret-manager/bytetube.js' << 'ZM_INSTALLER_EOF' +'use strict'; +'require view'; +'require poll'; +'require rpc'; +'require zapret-manager.common as zm'; +'require ytbypass.common as bt'; +'require ytbypass.presets as presets'; + +var callBytetubeInstalled = rpc.declare({ object: 'zapret-manager', method: 'bytetube_installed', expect: {} }); +var callBytetubeAction = rpc.declare({ object: 'zapret-manager', method: 'bytetube_action', params: [ 'action' ], expect: {} }); + +var TABS = [ + { id: 'main', label: 'ByeTube' }, + { id: 'strategy', label: 'Стратегия' }, + { id: 'test', label: 'Тест стратегий' }, + { id: 'domains', label: 'Домены' } +]; + +/* ---- домены: по одному в строке ---- */ +var DOMAIN_RE = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/; + +function normDomain(s) { + return String(s).trim().toLowerCase() + .replace(/^[a-z][a-z0-9+.-]*:\/\//, '') + .replace(/[\/?#].*$/, '') + .replace(/^\*?\./, ''); +} + +function parseDomains(text) { + var list = [], bad = [], seen = {}; + String(text == null ? '' : text).split(/[\s,;]+/).forEach(function(tok) { + if (!tok) return; + var d = normDomain(tok); + if (!d || d.indexOf('.') < 0 || !DOMAIN_RE.test(d)) { bad.push(tok); return; } + if (!seen[d]) { seen[d] = true; list.push(d); } + }); + return { list: list, bad: bad }; +} + +function fill(node, kids) { + while (node.firstChild) node.removeChild(node.firstChild); + kids.forEach(function(k) { + if (k === null || k === undefined || k === '') return; + node.appendChild(typeof k === 'string' ? document.createTextNode(k) : k); + }); + return node; +} + +function row(label, node) { + return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, [ label ]), node ]); +} + +function tile(label, cls, onclick, title) { + var attrs = { 'class': 'zm-tile' + (cls ? ' ' + cls : ''), 'click': onclick }; + if (title) attrs.title = title; + return E('div', attrs, [ label ]); +} + +function btn(label, cls, onclick) { + return E('button', { 'class': 'cbi-button' + (cls ? ' ' + cls : ''), 'click': onclick }, [ label ]); +} + +function cmdNodes(text) { + var out = []; + String(text).split(' ').forEach(function(tok, i) { + if (i) out.push(' '); + out.push(E('span', { 'class': 'bt-tok' }, [ tok ])); + }); + return out; +} + +function hint(text) { + return E('p', { 'class': 'zm-hint' }, [ text ]); +} + +function renderNotInstalled() { + var wrap = E('div', { 'class': 'zm-wrap' }); + var busy = false; + var logEl = E('pre', { 'class': 'zm-log' }); + var card = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, [ 'ByeTube' ]), + E('p', { 'class': 'zm-hint' }, [ + 'Обходит блокировку только доменов YouTube (ByeDPI + hev-socks5-tunnel), остальной трафик идёт напрямую. ' + + 'Устанавливает пакеты byedpi и hev-socks5-tunnel, при необходимости заменяет dnsmasq на dnsmasq-full.' + ]), + E('div', { 'class': 'zm-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } + busy = true; + zm.toast('Устанавливаем ByeTube (byedpi + hev-socks5-tunnel), может занять пару минут', 'warning'); + callBytetubeAction('install').then(function(res) { + if (res && res.error) { busy = false; zm.toast(res.error, 'error'); return; } + if (res && res.started) { + zm.pollJob('bytetube_install', logEl, function(ok) { + busy = false; + zm.toast(ok ? 'ByeTube установлен' : 'Ошибка установки — смотрите журнал', ok ? 'info' : 'error'); + if (ok) location.reload(); + }); + } else { + busy = false; + } + }).catch(function() { busy = false; }); + } + }, [ 'Установить' ]) + ]) + ]); + wrap.appendChild(card); + wrap.appendChild(logEl); + return wrap; +} + +function renderInstalled(all) { + var st = all[0] || {}; + var cfg = all[1] || {}; + var tst = all[2] || {}; + var texts = { 'strategies': all[3] || '', 'domains': all[4] || '' }; + var busy = false; + var activeTab = 'main'; + var running = tst.running === true; + var wasRunning = running; + var editors = {}; + + var wrap = E('div', { 'class': 'zm-wrap' }); + var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin-bottom:14px' }); + var panels = {}; + TABS.forEach(function(t) { + panels[t.id] = E('div', { 'style': t.id === activeTab ? '' : 'display:none' }); + }); + + function renderTabBar() { + fill(tabBar, TABS.map(function(t) { + return E('button', { + 'class': 'cbi-button' + (t.id === activeTab ? ' cbi-button-positive' : ''), + 'click': function() { + activeTab = t.id; + TABS.forEach(function(t2) { panels[t2.id].style.display = t2.id === activeTab ? '' : 'none'; }); + renderTabBar(); + } + }, [ t.label ]); + })); + } + + function guard() { + if (busy) { + bt.toast('Дождитесь завершения текущей операции', 'warning'); + return true; + } + return false; + } + + function isWorking() { + return !!(st.enabled && st.byedpi && st.hev && st.tun && st.nft && st.rule && st.route && st.dns && st.fw); + } + + var portDirty = false; + var portInput = E('input', { + 'class': 'cbi-input-text bt-input', + 'type': 'text', + 'value': String(cfg.byedpi_port || 1088), + 'inputmode': 'numeric' + }); + portInput.addEventListener('input', function() { portDirty = true; }); + + var statusCard = E('div', { 'class': 'zm-card' }); + var optionsCard = E('div', { 'class': 'zm-card' }); + var bannerEl = E('div', { 'class': 'zm-current-banner' }); + var presetsCard = E('div', { 'class': 'zm-card' }); + var domainsToggleCard = E('div', { 'class': 'zm-card' }); + var extraDomainsCard = E('div', { 'class': 'zm-card' }); + + function refreshState() { + return Promise.all([ bt.status(), bt.config() ]).then(function(r) { + if (r[0] && !r[0].error) st = r[0]; + if (r[1] && !r[1].error) cfg = r[1]; + renderMain(); + renderStrategy(); + renderDomainsToggle(); + renderExtraDomains(); + renderResults(resultsText); + }); + } + + function applyConfig(pairs, okMsg) { + if (guard()) return; + busy = true; + bt.toast('Применяем настройки', 'warning'); + return bt.configSet(pairs).then(function(res) { + busy = false; + if (res.error) { + bt.toast(res.error, 'error'); + return null; + } + bt.toast(okMsg || 'Настройки применены', 'info'); + return refreshState(); + }); + } + + function serviceAction(action, okMsg) { + if (guard()) return; + busy = true; + bt.toast('Выполняем', 'warning'); + bt.service(action).then(function(res) { + busy = false; + if (res.error) { bt.toast(res.error, 'error'); return; } + bt.toast(okMsg, 'info'); + refreshState(); + }); + } + + function renderMain() { + var items = []; + items.push(row('Служба', isWorking() + ? bt.badge(true, 'работает', '') + : bt.badge(false, '', st.enabled ? 'запущена не полностью' : 'выключена'))); + if (st.binaries === false) + items.push(row('Пакеты', bt.badge(false, '', 'не найден byedpi или hev-socks5-tunnel'))); + items.push(row('ByeDPI (ciadpi)', bt.badge(st.byedpi, 'запущен', 'не запущен'))); + items.push(row('hev-socks5-tunnel', bt.badge(st.hev, 'запущен', 'не запущен'))); + items.push(row('Интерфейс ytb0', bt.badge(st.tun, 'поднят', 'нет'))); + items.push(row('Маркировка nftables', bt.badge(st.nft, 'активна', 'нет'))); + items.push(row('Policy routing', bt.badge(st.rule && st.route, 'активен', 'нет'))); + items.push(row('dnsmasq → nftset', st.dnsmasq_nftset === false + ? bt.badge(false, '', 'нужен dnsmasq-full') + : bt.badge(st.dns, 'домены загружены', 'нет'))); + items.push(row('Firewall forward', bt.badge(st.fw, 'разрешён', 'нет'))); + items.push(row('IP в наборах', E('span', {}, [ 'IPv4: ' + (st.ips4 || 0) + (st.ipv6 ? ', IPv6: ' + (st.ips6 || 0) : '') ]))); + var half = Math.ceil(items.length / 2); + var kids = [ E('div', { 'class': 'bt-cols' }, [ + E('div', { 'class': 'bt-col' }, items.slice(0, half)), + E('div', { 'class': 'bt-col' }, items.slice(half)) + ]) ]; + kids.push(E('div', { 'class': 'zm-actions' }, [ + cfg.enabled + ? btn('Выключить', 'cbi-button-remove', function() { + applyConfig({ enabled: 0 }, 'ByeTube выключен'); + }) + : btn('Включить', 'cbi-button-positive', function() { + applyConfig({ enabled: 1 }, 'ByeTube включён'); + }), + btn('Перезапустить', 'cbi-button-apply', function() { serviceAction('restart', 'Служба перезапущена'); }), + btn('Очистить наборы IP', '', function() { + if (guard()) return; + bt.flush().then(function() { + bt.toast('Наборы очищены. Перезапустите браузер или обновите DNS-кэш на клиентах.', 'info'); + refreshState(); + }); + }) + ])); + fill(statusCard, kids); + + var quic = cfg.quic || 'block'; + if (!portDirty) portInput.value = String(cfg.byedpi_port || 1088); + + fill(optionsCard, [ + E('h3', {}, [ 'Настройки' ]), + row('QUIC (UDP/443)', E('div', { 'class': 'zm-grid' }, [ + tile('Блокировать', quic === 'block' ? 'zm-active' : '', function() { + if (quic !== 'block') applyConfig({ quic: 'block' }, 'QUIC блокируется'); + }), + tile('Через прокси', quic === 'proxy' ? 'zm-active' : '', function() { + if (quic !== 'proxy') applyConfig({ quic: 'proxy' }, 'QUIC идёт через прокси'); + }) + ])), + hint('Блокировка QUIC заставляет клиента быстро откатиться на TCP, который обрабатывает ByeDPI (рекомендуется).'), + row('IPv6', E('div', { 'class': 'zm-grid' }, [ + tile(cfg.ipv6 ? 'IPv6 включён' : 'IPv6 выключен', cfg.ipv6 ? 'zm-active' : 'zm-tile-off', function() { + applyConfig({ ipv6: cfg.ipv6 ? 0 : 1 }, cfg.ipv6 ? 'IPv6 выключен' : 'IPv6 включён'); + }) + ])), + row('Порт SOCKS5 ByeDPI', E('div', { 'class': 'zm-actions', 'style': 'margin:0' }, [ + portInput, + btn('Сохранить', 'cbi-button-positive', function() { + var v = String(portInput.value).trim(); + if (!/^\d+$/.test(v) || +v < 1 || +v > 65535) { + bt.toast('Порт должен быть числом от 1 до 65535', 'error'); + return; + } + portDirty = false; + applyConfig({ byedpi_port: v }, 'Порт сохранён'); + }) + ])), + hint('Локальный порт 127.0.0.1, отдельный экземпляр — штатная служба byedpi не затрагивается.') + ]); + } + + function currentPreset() { + return presets.find(cfg.byedpi_opts); + } + + function setStrategy(opts, label) { + if (guard()) return; + busy = true; + bt.toast('Применяем стратегию', 'warning'); + bt.setStrategy(opts).then(function(res) { + busy = false; + if (res.error) { bt.toast(res.error, 'error'); return; } + bt.toast((label || 'Стратегия') + ' применена', 'info'); + refreshState(); + }); + } + + var customTa = E('textarea', { + 'class': 'zm-config-editor', + 'spellcheck': 'false', + 'wrap': 'soft', + 'placeholder': 'Параметры ciadpi одной строкой', + 'style': 'min-height:130px;white-space:pre-wrap' + }, [ '' ]); + + function renderStrategy() { + var cur = currentPreset(); + fill(bannerEl, cfg.byedpi_opts + ? [ E('div', { 'class': 'bt-current-label' }, [ 'Сейчас применено' ]), E('div', { 'class': 'bt-current-cmd' }, cmdNodes(cfg.byedpi_opts)) ] + : [ E('span', {}, [ 'Стратегия ещё не выбрана' ]) ]); + bannerEl.className = 'zm-current-banner bt-current' + (cfg.byedpi_opts ? '' : ' zm-current-empty'); + + fill(presetsCard, [ + E('h3', {}, [ 'Готовые стратегии' ]), + E('div', { 'class': 'zm-grid' }, presets.list.map(function(p) { + return tile(p.label || p.name, cur && cur.id === p.id ? 'zm-active' : '', function() { + setStrategy(p.opts, p.label || p.name); + }, p.name + '\n' + p.opts); + })), + hint('Стратегия зависит от провайдера — лучшую под вашего провайдера найдёт вкладка «Тест стратегий».') + ]); + } + + var customCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, [ 'Своя стратегия' ]), + hint('Параметры командной строки ciadpi одной строкой. Кавычки (-n "google.com") служба обрабатывает сама.'), + customTa, + E('div', { 'class': 'zm-actions' }, [ + btn('Применить', 'cbi-button-positive', function() { + var v = presets.norm(customTa.value); + if (!v) { bt.toast('Стратегия не может быть пустой', 'error'); return; } + setStrategy(v, 'Своя стратегия'); + }), + btn('Подставить текущую', '', function() { customTa.value = cfg.byedpi_opts || ''; }) + ]) + ]); + + /* ---- редакторы списков теста: strategies / domains ---- */ + function makeEditor(o) { + var ta = E('textarea', { + 'class': 'zm-config-editor', + 'spellcheck': 'false', + 'wrap': o.wrap ? 'soft' : 'off', + 'style': 'min-height:' + o.height + 'px;' + (o.wrap ? 'white-space:pre-wrap' : '') + }, [ texts[o.kind] ]); + var meta = E('p', { 'class': 'zm-hint' }); + var ed = { ta: ta, locked: false }; + var saveBtn = btn('Сохранить', 'cbi-button-positive', onSave); + var resetBtn = btn('Сбросить к встроенному', '', onReset); + + ed.node = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, [ o.title ]), + hint(o.hint), + ta, + meta, + E('div', { 'class': 'zm-actions' }, [ saveBtn, resetBtn ]) + ]); + + ed.sync = function() { + var info = o.info(); + fill(meta, [ o.meta(info) ]); + ta.disabled = ed.locked; + saveBtn.disabled = ed.locked; + resetBtn.disabled = ed.locked || !info.custom; + }; + + function reload() { + return bt.listGet(o.kind).then(function(t) { + texts[o.kind] = t; + ta.value = t; + }); + } + + function onSave() { + if (!ta.value.trim()) { + bt.toast('Список пуст. Чтобы вернуть встроенный, нажмите «Сбросить к встроенному».', 'error'); + return; + } + bt.listSet(o.kind, ta.value).then(function(res) { + if (res.error) { bt.toast(res.error, 'error'); return; } + o.apply(res); + reload().then(function() { + ed.sync(); + bt.toast('Список сохранён', 'info'); + }); + }); + } + + function onReset() { + if (!confirm('Вернуть встроенный список? Ваши правки будут удалены.')) return; + bt.listReset(o.kind).then(function(res) { + if (res.error) { bt.toast(res.error, 'error'); return; } + o.apply(res); + reload().then(function() { + ed.sync(); + bt.toast('Возвращён встроенный список', 'info'); + }); + }); + } + + return ed; + } + + function listMeta(unit) { + return function(info) { + return (info.custom ? 'Свой список' : 'Встроенный список') + ' · ' + info.count + ' ' + unit; + }; + } + + editors['strategies'] = makeEditor({ + kind: 'strategies', title: 'Стратегии для теста', height: 260, wrap: true, + hint: 'Одна стратегия (параметры ciadpi) в строке.', + info: function() { return { count: tst.strategies || 0, custom: !!tst.strategies_custom }; }, + meta: listMeta('стратегий'), + apply: function(res) { tst.strategies = res.count; tst.strategies_custom = res.custom; } + }); + editors['domains'] = makeEditor({ + kind: 'domains', title: 'Домены для проверки', height: 220, wrap: false, + hint: 'Один домен в строке; можно вставлять ссылки. Нужны только для проверки доступности при тесте и на маршрутизацию не влияют.', + info: function() { return { count: tst.domains || 0, custom: !!tst.domains_custom }; }, + meta: listMeta('доменов'), + apply: function(res) { tst.domains = res.count; tst.domains_custom = res.custom; } + }); + + function syncEditors() { + Object.keys(editors).forEach(function(k) { editors[k].sync(); }); + } + + /* ---- вкладка «Домены»: встроенный список + свои дополнительные ---- */ + function renderDomainsToggle() { + var on = !!cfg.default_domains; + fill(domainsToggleCard, [ + E('h3', {}, [ 'Домены YouTube' ]), + row('Использовать встроенный список', E('div', { 'class': 'zm-grid' }, [ + tile(on ? 'Список используется' : 'Список выключен', on ? 'zm-active' : 'zm-tile-off', function() { + applyConfig({ default_domains: on ? 0 : 1 }, on ? 'Список выключен' : 'Список включён'); + }) + ])), + hint('Сервисы Google часто делят IP-адреса, поэтому часть трафика Google (не только YouTube) тоже пойдёт через обход, а QUIC (UDP/443) к этим адресам будет блокироваться.') + ]); + } + + var extraTa = E('textarea', { + 'class': 'zm-config-editor', + 'spellcheck': 'false', + 'wrap': 'off', + 'placeholder': 'example.com\nanother.org', + 'style': 'min-height:220px' + }, [ (cfg.domain || []).join('\n') ]); + var extraDirty = false; + extraTa.addEventListener('input', function() { extraDirty = true; }); + + function renderExtraDomains() { + if (!extraDirty) extraTa.value = (cfg.domain || []).join('\n'); + fill(extraDomainsCard, [ + E('h3', {}, [ 'Дополнительные домены' ]), + hint('По одному домену в строке; поддомены подхватываются автоматически. Добавляются к встроенному списку. Сейчас доменов: ' + (cfg.domain || []).length + '.'), + extraTa, + E('div', { 'class': 'zm-actions' }, [ + btn('Сохранить', 'cbi-button-positive', function() { + var r = parseDomains(extraTa.value); + if (r.bad.length) { + bt.toast('Некорректный домен: ' + r.bad[0], 'error'); + return; + } + extraDirty = false; + applyConfig({ domain: r.list }, 'Домены сохранены'); + }) + ]) + ]); + } + + var testActions = E('div', { 'class': 'zm-actions' }); + var testLog = E('pre', { 'class': 'zm-log' }); + var testResults = E('div', { 'class': 'zm-card' }); + + function nameOf(opts) { + var p = presets.find(opts); + return p ? p.name : null; + } + + function renderTestActions() { + var kids = []; + if (tst.curl === false) { + kids.push(bt.badge(false, '', 'не установлен curl')); + kids.push(hint('Нужен для теста: apk add curl (или opkg install curl).')); + } else if (running) { + kids.push(bt.badge(true, 'тест выполняется', '')); + kids.push(btn('Остановить тест', 'cbi-button-remove', doStop)); + } else { + kids.push(btn('Запустить тест', 'cbi-button-positive', doStart)); + if (tst.has_results) kids.push(btn('Очистить результаты', '', doClear)); + } + fill(testActions, kids); + editors['strategies'].locked = running; + editors['domains'].locked = running; + syncEditors(); + } + + var resultsText = ''; + + function renderResults(text) { + resultsText = text || ''; + var res = bt.parseResults(resultsText); + if (!res.rows.length) { + fill(testResults, [ E('h3', {}, [ 'Результаты' ]), hint(running ? 'Тест выполняется…' : 'Пока нет результатов — запустите тест.') ]); + return; + } + var controlOk = res.control ? res.control.ok : null; + var curClean = presets.clean(cfg.byedpi_opts); + + var rows = res.rows.map(function(r, i) { + var p = presets.find(r.opts); + var isCur = presets.clean(r.opts) === curClean; + var body = []; + if (p || isCur) { + var nm = E('div', { 'class': 'bt-name' }, [ p ? p.name : '' ]); + if (isCur) nm.appendChild(bt.span('bt-c-cur', (p ? ' ' : '') + '(текущая)')); + body.push(nm); + } + body.push(E('div', { 'class': 'bt-cmd' }, cmdNodes(r.opts))); + return E('tr', {}, [ + E('td', { 'class': 'bt-td-n' }, [ String(i + 1) ]), + E('td', { 'class': 'bt-td-s' }, [ E('span', { 'class': bt.chipClass(r.ok, r.total, controlOk) }, [ r.ok + '/' + r.total ]) ]), + E('td', { 'class': 'bt-td-c' }, body), + E('td', { 'class': 'bt-td-a' }, [ + isCur ? '' : btn('Применить', 'cbi-button-apply', function() { + var q = 'Применить стратегию и перезапустить службу?\n\n' + (p ? p.name + '\n' : '') + r.opts; + if (confirm(q)) setStrategy(r.opts, p ? (p.label || p.name) : 'Стратегия'); + }) + ]) + ]); + }); + + fill(testResults, [ + E('h3', {}, [ 'Результаты' ]), + E('div', { 'class': 'zm-log zm-show bt-panel' }, [ + E('table', { 'class': 'bt-table' }, [ E('tbody', {}, rows) ]) + ]) + ]); + } + + function refreshResults() { + return bt.testResults().then(renderResults); + } + + function refreshLog() { + return bt.testLog().then(function(t) { + if (t) testLog.classList.add('zm-show'); + else testLog.classList.remove('zm-show'); + bt.renderTestLog(testLog, t, nameOf); + }); + } + + function doStart() { + bt.testStart().then(function(res) { + if (res.error) { bt.toast(res.error, 'error'); return; } + running = true; + wasRunning = true; + tst.has_results = false; + renderResults(''); + renderTestActions(); + refreshLog(); + bt.toast('Тест запущен', 'info'); + }); + } + + function doStop() { + bt.testStop().then(function(res) { + if (res.error) { bt.toast(res.error, 'error'); return; } + bt.toast('Останавливаю тест…', 'warning'); + }); + } + + function doClear() { + bt.testClear().then(function(res) { + if (res.error) { bt.toast(res.error, 'error'); return; } + tst.has_results = false; + renderTestActions(); + renderResults(''); + testLog.classList.remove('zm-show'); + }); + } + + function tick() { + return bt.testStatus().then(function(s) { + if (!s || s.error) return; + tst = s; + running = s.running === true; + renderTestActions(); + if (running || wasRunning) refreshLog(); + if (wasRunning && !running) { + wasRunning = false; + refreshResults(); + bt.toast('Тест завершён', 'info'); + } + }); + } + + var testCard = E('div', { 'class': 'zm-card' }, [ + E('h3', {}, [ 'Тест стратегий ByeDPI' ]), + hint('Каждая стратегия запускается во временном экземпляре ByeDPI на отдельном порту, а домены проверяются через него. Основной сервис и трафик клиентов не затрагиваются, настройки не меняются. Сначала делается контрольный замер без обхода. Тест идёт в фоне — вкладку можно закрыть.'), + testActions, + testLog + ]); + + panels['main'].appendChild(statusCard); + panels['main'].appendChild(optionsCard); + + panels['strategy'].appendChild(bannerEl); + panels['strategy'].appendChild(presetsCard); + panels['strategy'].appendChild(customCard); + + panels['test'].appendChild(testCard); + panels['test'].appendChild(testResults); + panels['test'].appendChild(editors['strategies'].node); + panels['test'].appendChild(editors['domains'].node); + + panels['domains'].appendChild(domainsToggleCard); + panels['domains'].appendChild(extraDomainsCard); + + wrap.appendChild(tabBar); + TABS.forEach(function(t) { wrap.appendChild(panels[t.id]); }); + + renderTabBar(); + renderMain(); + renderStrategy(); + renderDomainsToggle(); + renderExtraDomains(); + renderTestActions(); + refreshLog(); + if (running) renderResults(''); + else refreshResults(); + + poll.add(function() { + return Promise.all([ refreshState(), tick() ]); + }, 5); + + return wrap; +} + +return view.extend({ + load: function() { + return callBytetubeInstalled().catch(function() { return { installed: false }; }).then(function(inst) { + if (!inst || inst.installed !== true) return { installed: false }; + bt.injectCss(); + return Promise.all([ + bt.status(), + bt.config(), + bt.testStatus(), + bt.listGet('strategies'), + bt.listGet('domains') + ]).then(function(all) { + return { installed: true, all: all }; + }); + }); + }, + + render: function(result) { + zm.injectCss(); + if (!result || result.installed !== true) return renderNotInstalled(); + return renderInstalled(result.all); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); +ZM_INSTALLER_EOF +chmod 0644 '/www/luci-static/resources/view/zapret-manager/bytetube.js' + rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null || true /etc/init.d/rpcd reload >/dev/null 2>&1 || /etc/init.d/rpcd restart >/dev/null 2>&1 @@ -6241,4 +9209,3 @@ 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 echo -e "Zapret Manager ${GREEN}для ${NC}LuCI ${GREEN}установлен!${NC}\n" -