#!/bin/sh # Version: 2.14 set -e GREEN="\033[1;32m"; CYAN="\033[1;36m"; YELLOW="\033[1;33m"; MAGENTA="\033[1;35m"; BLUE="\033[0;34m"; NC="\033[0m"; DGRAY="\033[38;5;244m" echo -e "\n${MAGENTA}Устанавливаем Zapret Manager для LuCI${NC}" if [ -f /tmp/zapret-manager-luci/redbtn_deep.pid ]; then _zm_kill_tree() { for _c in $(cat "/proc/$1/task/$1/children" 2>/dev/null); do _zm_kill_tree "$_c"; done kill -9 "$1" 2>/dev/null || true } _zm_kill_tree "$(cat /tmp/zapret-manager-luci/redbtn_deep.pid 2>/dev/null)" for _p in $(ps w | grep "[q]num=8397" | awk '{print $1}'); do kill "$_p" 2>/dev/null || true; done nft delete table inet zm_rb_ztest >/dev/null 2>&1 || true fi for _zm_pid in /tmp/zapret-manager-luci/*.pid; do [ -f "$_zm_pid" ] || continue _zm_job="$(basename "$_zm_pid" .pid)" case "$_zm_job" in versions|sysinfo|*_download|redbtn_deep) continue ;; esac if kill -0 "$(cat "$_zm_pid" 2>/dev/null)" 2>/dev/null && ! grep -q '^__DONE__' "/tmp/zapret-manager-luci/$_zm_job.log" 2>/dev/null; then echo -e "${YELLOW}Сейчас в панели идёт операция ($_zm_job) — дождитесь её окончания и запустите установку снова${NC}" exit 1 fi done rm -rf \ /usr/lib/zapret-manager* \ /etc/zapret_manager_expert_mode* \ /tmp/zapret-manager \ /tmp/zapret-manager-luci* \ /tmp/zm_uninstall_panel.sh \ /tmp/luci-indexcache* \ /tmp/luci-modulecache/* 2>/dev/null rm -f \ /www/luci-static/resources/view/zapret-manager/test.js \ /www/luci-static/resources/view/zapret-manager/redbtn.js \ /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 \ /www/luci-static/resources/view/zapret-manager/forkop.js 2>/dev/null mkdir -p /opt/zapret-manager-luci chmod 0755 /opt/zapret-manager-luci cat > '/opt/zapret-manager-luci/backend.sh.zm-new' << 'ZM_INSTALLER_EOF' #!/bin/sh umask 022 CONF="/etc/config/zapret" ZM_VERSION="2.14" 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" MT_URL_ITDOG="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/MagiTrickle/configAD.yaml" MT_URL_IH1="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/MagiTrickle/config.yaml" MT_URL_IH2="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/MagiTrickle/configOLD.yaml" EXCLUDE_URL="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/zapret-hosts-user-exclude.txt" FLOWSEAL_ZIP="${GH_MAIN}/Flowseal/zapret-discord-youtube/archive/refs/heads/main.zip" FLOWSEAL_FAKE_RAW="${GH_MAIN}/Flowseal/zapret-discord-youtube/raw/refs/heads/main/bin" STR_URL="${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/StrYoutube" JOBS_DIR="/tmp/zapret-manager-luci" ZM_STATE_DIR="/opt/zapret-manager-luci/state" CRON_FILE="/etc/crontabs/root" MIHOMO_DIR="/etc/mihomo" MIHOMO_BIN="/usr/bin/mihomo" MIHOMO_CONF="/etc/mihomo/config.yaml" MAGITRICKLE_CONF="/etc/magitrickle/state/config.yaml" MIXOMO_CRON_CMD="/etc/init.d/mihomo restart" HOSTS_FILE="/etc/hosts" EXPERT_MODE_FILE="/opt/zapret-manager-luci/expert_mode" PORTS_UDP="88,1024-2407,2409-4499,4502-19293,19345-49999,50101-65535" PORTS_TCP="2099,2802,2302,2502,3478-3480,3724,6000-8000,8085,8090,8100,8903,8904,25565,27015-27030,27036-27037,35500-35600,50001,60442" mkdir -p "$JOBS_DIR" "$ZM_STATE_DIR" 2>/dev/null if command -v opkg >/dev/null 2>&1; then PKG="opkg"; PKG_ADD="opkg install"; PKG_DEL="opkg remove"; PKG_UPD="opkg update" TG_ARCH="$(opkg print-architecture 2>/dev/null | awk '{print $2}' | tail -n1)" RAZ="ipk" else PKG="apk"; PKG_ADD="apk add --allow-untrusted"; PKG_DEL="apk del"; PKG_UPD="apk update" TG_ARCH="$(apk --print-arch 2>/dev/null)" RAZ="apk" fi INSTALL="_zm_pkg_add"; DELETE="_zm_pkg_del"; UPDATE="_zm_pkg_update" esc() { printf '%s' "$1" | tr -d '\000-\010\013\014\016-\037' | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' | tr '\n' ' ' } ZM_JESC_AWK='function jesc(s, o, i, c, n) { o = ""; n = length(s) for (i = 1; i <= n; i++) { c = substr(s, i, 1) if (c == "\\") o = o "\\\\" else if (c == "\"") o = o "\\\"" else if (c == "\t") o = o "\\t" else if (c != "\r") o = o c } return o } ' esc_ml() { printf '%s' "$1" | tr -d '\000-\010\013\014\016-\037' | sed 's/\r$//; s/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g' | sed ':a;N;$!ba;s/\n/\\n/g' } _zm_zone_sec() { local z for z in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=zone\$/\1/p"); do [ "$(uci -q get "firewall.$z.name")" = "$1" ] && { echo "$z"; return 0; } done return 1 } _zm_zone_of_net() { local z for z in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=zone\$/\1/p"); do case " $(uci -q get "firewall.$z.network") " in *" $1 "*) uci -q get "firewall.$z.name"; return 0 ;; esac done return 1 } _zm_lan_zone() { local f w _zm_zone_of_net lan && return 0 _zm_zone_sec lan >/dev/null && { echo lan; return 0; } w="$(_zm_wan_zone)" for f in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=forwarding\$/\1/p"); do [ "$(uci -q get "firewall.$f.dest")" = "$w" ] && { uci -q get "firewall.$f.src"; return 0; } done echo lan } _zm_wan_zone() { _zm_zone_of_net wan || echo wan; } _zm_lan_nets() { local s n="" s="$(_zm_zone_sec "$(_zm_lan_zone)")" && n="$(uci -q get "firewall.$s.network")" echo "${n:-lan}" } _zm_lan_devs() { local s n d out="" for n in $(_zm_lan_nets); do d="$(ifstatus "$n" 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null)" [ -n "$d" ] || d="$(ifstatus "$n" 2>/dev/null | jsonfilter -e '@.device' 2>/dev/null)" [ -n "$d" ] || d="$(uci -q get "network.$n.device")" [ -n "$d" ] && [ -d "/sys/class/net/$d" ] || continue case " $out " in *" $d "*) ;; *) out="$out $d" ;; esac done if s="$(_zm_zone_sec "$(_zm_lan_zone)")"; then for d in $(uci -q get "firewall.$s.device"); do [ -d "/sys/class/net/$d" ] || continue case " $out " in *" $d "*) ;; *) out="$out $d" ;; esac done fi [ -n "$out" ] || out="br-lan" echo $out } _zm_lan_ip() { local n ip d for n in lan $(_zm_lan_nets); do ip="$(ifstatus "$n" 2>/dev/null | jsonfilter -e "@['ipv4-address'][0].address" 2>/dev/null)" [ -n "$ip" ] && { echo "$ip"; return 0; } done ip="$(uci -q get network.lan.ipaddr 2>/dev/null | awk '{ print $1 }' | cut -d/ -f1)" [ -n "$ip" ] && { echo "$ip"; return 0; } for d in $(_zm_lan_devs); do ip="$(ip -4 -o addr show dev "$d" 2>/dev/null | awk '{ print $4; exit }' | cut -d/ -f1)" [ -n "$ip" ] && { echo "$ip"; return 0; } done return 1 } _pkg_is_installed() { if [ "$PKG" = "apk" ]; then apk info -e "$1" >/dev/null 2>&1 else opkg list-installed 2>/dev/null | grep -q "^$1 "; fi } _ensure_deps() { local need="" command -v curl >/dev/null 2>&1 || need="$need curl" command -v unzip >/dev/null 2>&1 || need="$need unzip" [ -z "$need" ] && return 0 echo "==> Не хватает программ:$need — устанавливаем" $UPDATE $INSTALL $need } _zm_kids() { local f a b c d r for f in /proc/[0-9]*/stat; do { read -r a b c d r < "$f"; } 2>/dev/null || continue [ "$d" = "$1" ] && echo "$a" done } _zm_kill_tree() { local c [ -n "$1" ] || return 0 kill -STOP "$1" 2>/dev/null for c in $(_zm_kids "$1"); do _zm_kill_tree "$c"; done kill -9 "$1" 2>/dev/null } _zm_pkg_unlock() { pidof opkg >/dev/null 2>&1 || rm -f /var/lock/opkg.lock 2>/dev/null return 0 } _zm_dots() { local p="$1" tmo="${2:-900}" i=0 o="${ZM_JOB_LOG:-/dev/null}" shown=0 [ -n "$ZM_IN_WAIT" ] && o=/dev/null while kill -0 "$p" 2>/dev/null; do sleep 1 i=$((i + 1)) if [ "$i" -ge "$tmo" ]; then _zm_kill_tree "$p" [ "$shown" = 1 ] && echo >> "$o" shown=0 echo "!! Шаг не завершился за $tmo с — прерываем его, чтобы ничего не зависло" >> "${ZM_JOB_LOG:-/dev/null}" [ -n "${ZM_TMO_FLAG:-}" ] && : > "$ZM_TMO_FLAG" _zm_pkg_unlock break fi if [ "$i" -ge 20 ]; then [ "$shown" = 0 ] && { printf 'Подождите ' >> "$o"; shown=1; } [ $((i % 5)) = 0 ] && printf '.' >> "$o" fi done [ "$shown" = 1 ] && echo >> "$o" return 0 } _zm_run() { local tmo="$1" p rc t="/tmp/zm-run.$$.$RANDOM" shift ( ZM_IN_WAIT=1; export ZM_IN_WAIT; "$@" ) > "$t" 2>&1 & p=$! _zm_dots "$p" "$tmo" wait "$p" 2>/dev/null rc=$? cat "$t" 2>/dev/null rm -f "$t" return $rc } _zm_pkg_fmt() { awk ' { sub(/\r$/, "") } /^Downloading / { next } /^Updated list of available packages in / { n = $0; sub(/.*\//, "", n); print " · обновлён список: " n; next } /^Installing [^ ]+ \(/ { v = $3; gsub(/[()]/, "", v); print " · ставим " $2 " " v; next } /^Upgrading [^ ]+ / { print " · обновляем " $2; next } /^Configuring / { n = $2; sub(/\.$/, "", n); print " · настраиваем " n; next } /^Removing package / { print " · удаляем " $3; next } /^Package .* is up to date/ { print " · " $2 " уже стоит последней версии"; next } /^\([0-9]+\/[0-9]+\) Installing / { v = $4; gsub(/[()]/, "", v); print " · ставим " $3 " " v; next } /^\([0-9]+\/[0-9]+\) Upgrading / { print " · обновляем " $3; next } /^\([0-9]+\/[0-9]+\) (Purging|Deleting) / { print " · удаляем " $3; next } /^OK: [0-9]+ distinct packages available/ { print " · доступно пакетов: " $2; next } /^fetch / { next } /ubus call service delete .*Not found/ { next } /[Ee]rror|ERROR|Collected errors|^ \* |[Uu]nknown package|not found|No such|unable|cannot|failed|Failed|unsatisfiable|breaks:/ { l = $0; sub(/^[ *]+/, "", l); if (l != "" && !seen[l]++) print " ! " l; next } ' } _zm_pkg_names() { local a out="" for a; do case "$a" in -*) continue ;; esac a="${a##*/}" out="$out${out:+, }$a" done echo "$out" } _zm_pkg_update() { local try=1 rc t0 while :; do t0="$(date +%s)" echo " → Скачиваем свежий список пакетов из репозитория" _zm_run 150 $PKG_UPD > "/tmp/zm-pkg.$$" 2>&1 rc=$? _zm_pkg_fmt < "/tmp/zm-pkg.$$" rm -f "/tmp/zm-pkg.$$" if [ "$rc" = 0 ]; then echo " ✓ Список пакетов обновлён за $(( $(date +%s) - t0 )) с" return 0 fi _zm_pkg_unlock [ "$try" -ge 2 ] && break _zm_dns_ok downloads.openwrt.org || _zm_net_heal echo " ↻ Не получилось — пробуем ещё раз" try=$((try + 1)) sleep 3 done echo " ✗ Список пакетов не обновился — проверьте интернет и доступ к downloads.openwrt.org" return 1 } _zm_pkg_add() { local rc names p miss="" names="$(_zm_pkg_names "$@")" echo " → Устанавливаем: $names" _zm_run 300 $PKG_ADD "$@" > "/tmp/zm-pkg.$$" 2>&1 rc=$? _zm_pkg_fmt < "/tmp/zm-pkg.$$" rm -f "/tmp/zm-pkg.$$" _zm_pkg_unlock if [ "$rc" = 0 ]; then echo " ✓ Установлено: $names" else for p; do case "$p" in -*|*/*|*.ipk|*.apk) continue ;; esac; _pkg_is_installed "$p" || miss="$miss${miss:+, }$p"; done echo " ✗ Не установилось: ${miss:-$names}" fi return $rc } _zm_ca_have() { ls /etc/ssl/certs/*.crt >/dev/null 2>&1; } _zm_pkg_del() { local rc p have="" names ca=0 _zm_ca_have && ca=1 for p; do case "$p" in -*) have="$have $p"; continue ;; esac _pkg_is_installed "$p" && have="$have $p" done names="$(_zm_pkg_names $have)" [ -n "$names" ] || return 0 echo " → Удаляем пакеты: $names" _zm_run 180 $PKG_DEL $have > "/tmp/zm-pkg.$$" 2>&1 rc=$? _zm_pkg_fmt < "/tmp/zm-pkg.$$" rm -f "/tmp/zm-pkg.$$" _zm_pkg_unlock if [ "$rc" = 0 ]; then echo " ✓ Удалено: $names" else echo " ✗ Не удалось удалить: $names"; fi [ "$ca" = 1 ] && _zm_ca_keep return $rc } _zm_ca_keep() { _zm_ca_have && return 0 echo " → Возвращаем корневые сертификаты (ca-bundle) — без них не работает HTTPS" _zm_run 180 $PKG_ADD ca-bundle >/dev/null 2>&1 || { _zm_run 150 $PKG_UPD >/dev/null 2>&1; _zm_run 180 $PKG_ADD ca-bundle >/dev/null 2>&1; } if _zm_ca_have; then echo " ✓ Сертификаты на месте" else echo " ! Не удалось поставить ca-bundle — установите его вручную, иначе HTTPS-загрузки не заработают"; fi return 0 } _job_running() { local pid="$JOBS_DIR/$1.pid" [ -f "$pid" ] && kill -0 "$(cat "$pid" 2>/dev/null)" 2>/dev/null || return 1 ! grep -q '^__DONE__' "$JOBS_DIR/$1.log" 2>/dev/null } job_start() { local name="$1"; shift local log="$JOBS_DIR/$name.log" local pid="$JOBS_DIR/$name.pid" if _job_running "$name"; then printf '{"started":true,"job":"%s","already_running":true}\n' "$name" return 0 fi : > "$log" ( ZM_JOB_LOG="$log"; export ZM_JOB_LOG; "$@" >>"$log" 2>&1; rc=$?; _zm_net_restore >>"$log" 2>&1; [ -n "$(tail -c 1 "$log" 2>/dev/null)" ] && echo >>"$log"; echo "__DONE__ $rc" >>"$log" ) & echo $! > "$pid" printf '{"started":true,"job":"%s"}\n' "$name" } _zm_wait() { local p rc t="/tmp/zm-wait.$$.$RANDOM" tmo=600 shift ( ZM_IN_WAIT=1; export ZM_IN_WAIT; "$@" ) > "$t.o" 2> "$t.e" & p=$! _zm_dots "$p" "$tmo" wait "$p" 2>/dev/null rc=$? cat "$t.o" [ "$rc" = 0 ] || cat "$t.e" >&2 rm -f "$t.o" "$t.e" return $rc } _zm_quiet() { local p rc t="/tmp/zm-quiet.$$.$RANDOM" tmo=600 shift ( ZM_IN_WAIT=1; export ZM_IN_WAIT; "$@" ) > "$t" 2>&1 & p=$! _zm_dots "$p" "$tmo" wait "$p" 2>/dev/null rc=$? [ "$rc" = 0 ] || tail -n 8 "$t" | sed 's/^/ /' rm -f "$t" return $rc } _zm_sleep() { sleep "$1" & _zm_dots $! "$(( $1 + 5 ))" wait $! 2>/dev/null return 0 } _zm_cron_drop() { local pat="$1" what="$2" grep -qiE "$pat" "$CRON_FILE" 2>/dev/null || return 0 echo " → Убираем задания $what из расписания (cron)" grep -viE "$pat" "$CRON_FILE" > "$CRON_FILE.zm" 2>/dev/null if [ $? -le 1 ] && [ -f "$CRON_FILE.zm" ]; then cat "$CRON_FILE.zm" > "$CRON_FILE"; fi rm -f "$CRON_FILE.zm" /etc/init.d/cron restart >/dev/null 2>&1 echo " ✓ Расписание очищено" } _zm_dns_scrub() { local rx="$1" s v ch=0 sc for s in $(uci -q show dhcp | sed -n "s/^dhcp\.\([^.=]*\)=dnsmasq\$/\1/p"); do sc=0 for v in $(uci -q get "dhcp.$s.server"); do printf '%s' "$v" | grep -qE "$rx" && { uci -q del_list "dhcp.$s.server=$v"; ch=1; sc=1; } done if [ "$sc" = 1 ] && [ -z "$(uci -q get "dhcp.$s.server")" ] && [ "$(uci -q get "dhcp.$s.noresolv")" = 1 ]; then uci -q delete "dhcp.$s.noresolv" fi done [ "$ch" = 1 ] && { uci -q commit dhcp; echo " ✓ Из настроек DNS роутера убраны оставшиеся ссылки"; } return 0 } _zm_svc_off() { [ -x "/etc/init.d/$1" ] || return 0 echo " → Останавливаем службу $1 и убираем её из автозапуска" _zm_run 60 "/etc/init.d/$1" stop >/dev/null 2>&1 "/etc/init.d/$1" disable >/dev/null 2>&1 return 0 } ZM_RESOLV_BAK="$JOBS_DIR/resolv.conf.zm-bak" _zm_dns_ok() { nslookup "$1" >/dev/null 2>&1; } _zm_host_ok() { curl -s -o /dev/null --connect-timeout 6 --max-time 12 "https://$1/" >/dev/null 2>&1 case $? in 0|22|35|47|52|56|60) return 0 ;; esac return 1 } ZM_REBOOT_HINT="$JOBS_DIR/reboot.hint" _zm_reboot_hint() { mkdir -p "$JOBS_DIR" printf '%s\n' "$1" > "$ZM_REBOOT_HINT" } _zm_inet_ok() { nslookup ya.ru 127.0.0.1 >/dev/null 2>&1 || nslookup cloudflare.com 127.0.0.1 >/dev/null 2>&1 || return 1 _zm_host_ok ya.ru || _zm_host_ok www.cloudflare.com || _zm_host_ok www.google.com } _zm_inet_wait() { local t=0 while [ "$t" -lt "$1" ]; do _zm_inet_ok && return 0 sleep 3; t=$((t + 3)) done return 1 } _zm_net_recover() { _rb_say "Проверяем интернет и DNS после удаления" /etc/init.d/firewall restart >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 if _zm_inet_wait 15; then echo " ✓ Интернет и DNS на роутере работают" return 0 fi _rb_warn "Интернет не отвечает — перезапускаем сеть (панель может на полминуты потерять связь с роутером)" _zm_run 90 /etc/init.d/network restart >/dev/null 2>&1 sleep 5 /etc/init.d/dnsmasq restart >/dev/null 2>&1 if _zm_inet_wait 45; then echo " ✓ После перезапуска сети интернет работает" return 0 fi _rb_warn "Интернет так и не появился — перезагрузите роутер" return 1 } _zm_after_remove() { if _zm_net_recover; then _zm_reboot_hint "$1 удалён. Желательно перезагрузить роутер — так сбросятся оставшиеся соединения и правила." else _zm_reboot_hint "$1 удалён, но интернет на роутере не отвечает. Перезагрузите роутер." fi echo " Желательно перезагрузить роутер — кнопка «Перезагрузить роутер» появилась вверху страницы" } system_reboot() { local j j="$(_zm_busy_job)" && { printf '{"error":"%s"}\n' "$(esc "Сейчас идёт операция: $j — дождитесь её окончания")"; return 1; } rm -f "$ZM_REBOOT_HINT" ( sleep 2; sync; reboot ) >/dev/null 2>&1 /dev/null && return 0 done return 1 } _zm_net_own() { [ -n "$ZM_JOB_LOG" ] || return 0 grep -qxF "$ZM_JOB_LOG" "$ZM_RESOLV_BAK.owners" 2>/dev/null || echo "$ZM_JOB_LOG" >> "$ZM_RESOLV_BAK.owners" } _zm_net_others() { local o [ -f "$ZM_RESOLV_BAK.owners" ] || return 1 while read -r o; do [ -n "$o" ] && [ "$o" != "$ZM_JOB_LOG" ] || continue _job_running "$(basename "$o" .log)" && return 0 done < "$ZM_RESOLV_BAK.owners" return 1 } _zm_net_orphan() { [ -f "$ZM_RESOLV_BAK" ] || return 1 if [ -s "$ZM_RESOLV_BAK.owners" ]; then _zm_net_others && return 1 return 0 fi [ -z "$(_zm_busy_job)" ] } _zm_net_heal() { if [ -f "$ZM_RESOLV_BAK" ]; then _zm_net_own; return 0; fi local src=/tmp/resolv.conf [ -s "$src" ] || src=/etc/resolv.conf [ -s "$src" ] || return 1 cat "$src" > "$ZM_RESOLV_BAK.new" 2>/dev/null && [ -s "$ZM_RESOLV_BAK.new" ] || { rm -f "$ZM_RESOLV_BAK.new"; return 1; } mv -f "$ZM_RESOLV_BAK.new" "$ZM_RESOLV_BAK" rm -f "$ZM_RESOLV_BAK.owners" _zm_net_own printf 'nameserver 1.1.1.1\nnameserver 9.9.9.9\n' > /tmp/resolv.conf 2>/dev/null || printf 'nameserver 1.1.1.1\nnameserver 9.9.9.9\n' > /etc/resolv.conf echo " → DNS роутера не отвечает — на время операции берём 1.1.1.1 и 9.9.9.9" } _zm_net_restore() { [ -f "$ZM_RESOLV_BAK" ] || return 0 if [ "$1" != force ] && [ -n "$ZM_JOB_LOG" ]; then local lk="$ZM_RESOLV_BAK.lock" i=0 while ! mkdir "$lk" 2>/dev/null; do i=$((i + 1)); [ "$i" -ge 10 ] && { rmdir "$lk" 2>/dev/null; continue; } sleep 1 done if [ -s "$ZM_RESOLV_BAK.owners" ] && ! grep -qxF "$ZM_JOB_LOG" "$ZM_RESOLV_BAK.owners"; then rmdir "$lk"; return 0; fi grep -vxF "$ZM_JOB_LOG" "$ZM_RESOLV_BAK.owners" > "$ZM_RESOLV_BAK.o2.$$" 2>/dev/null mv -f "$ZM_RESOLV_BAK.o2.$$" "$ZM_RESOLV_BAK.owners" if _zm_net_others; then rmdir "$lk"; return 0; fi rmdir "$lk" fi mv -f "$ZM_RESOLV_BAK" "$ZM_RESOLV_BAK.r" 2>/dev/null || return 0 if [ -s "$ZM_RESOLV_BAK.r" ]; then if [ -e /tmp/resolv.conf ]; then cat "$ZM_RESOLV_BAK.r" > /tmp/resolv.conf; else cat "$ZM_RESOLV_BAK.r" > /etc/resolv.conf; fi fi rm -f "$ZM_RESOLV_BAK.r" "$ZM_RESOLV_BAK.owners" echo " ✓ Обычный DNS роутера возвращён" } _zm_net_prepare() { local h bad="" _fk_say "Проверяем доступ к источникам: $*" for h in "$@"; do _zm_dns_ok "$h" || bad="$bad $h"; done if [ -n "$bad" ]; then _zm_net_heal for h in $bad; do _zm_dns_ok "$h" || { echo "ОШИБКА: не удаётся узнать адрес $h — проверьте интернет на роутере"; _zm_net_restore; return 1; }; done fi for h in "$@"; do if ! _zm_host_ok "$h"; then echo "ОШИБКА: $h не отвечает — возможно, он недоступен у провайдера. Ничего не меняли" _zm_net_restore return 1 fi done echo " ✓ Источники доступны" return 0 } _fk_watch() { local f="$ZM_STATE_DIR/fk.watch" n=0 last=0 now wait _fk_installed && _fk_enabled || { rm -f "$f"; return 0; } if _fk_up; then rm -f "$f" _job_running forkop || _fk_heal _job_running forkop || _fk_dns_watch return 0 fi _job_running forkop && return 0 [ -n "$(_fk_blocker)" ] && return 0 { read -r n last < "$f"; } 2>/dev/null n="${n:-0}"; last="${last:-0}" now="$(date +%s)" wait=$((120 * (1 << (n > 4 ? 4 : n)))) [ $((now - last)) -lt "$wait" ] && return 0 logger -t zapret-manager "Forkozz включён, но sing-box или его правила не работают — перезапускаем (попытка $((n + 1)))" mkdir -p "$ZM_STATE_DIR" echo "$((n + 1)) $now" > "$f" _zm_run 180 /etc/init.d/netshift restart >/dev/null 2>&1 return 0 } _fk_heal() { local s first=1 for s in $(_fk_uc secnames 2>/dev/null); do case "$s" in *[!A-Za-z0-9_]*|'') continue ;; esac if [ "$first" = 1 ]; then _fk_heal_sec "$s" ""; first=0; else _fk_heal_sec "$s" ".$s"; fi done return 0 } _fk_heal_sec() { local sec="$1" f="$JOBS_DIR/fk.heal$2" r n=0 name to r="$(_fk_uc heal "$sec" 2>/dev/null)" [ "$(printf '%s' "$r" | jsonfilter -e '@.dead' 2>/dev/null)" = true ] || { rm -f "$f"; return 0; } _zm_inet_ok || return 0 n="$(cat "$f" 2>/dev/null)"; n=$(( ${n:-0} + 1 )) echo "$n" > "$f" [ "$n" -ge 2 ] || return 0 [ "$n" -gt 2 ] && [ $((n % 15)) -ne 0 ] && return 0 r="$(_fk_uc heal "$sec" switch 2>/dev/null)" if [ "$(printf '%s' "$r" | jsonfilter -e '@.changed' 2>/dev/null)" = true ]; then name="$(printf '%s' "$r" | jsonfilter -e '@.name' 2>/dev/null)" to="$(printf '%s' "$r" | jsonfilter -e '@.to' 2>/dev/null)" logger -t zapret-manager "Forkozz: сервер «$name» не отвечает — переключились на ${to:-автовыбор}" rm -f "$f" fi return 0 } # DNS failover: NetShift uses one DNS server, Forkozz keeps the list and switches to the first one that answers. _fk_dns_watch() { local f="$ZM_STATE_DIR/fk.dnsfo" t cur v best="" tmp="$JOBS_DIR/fk-dnsfo.$$" ok cnt=0 want="" cur_ok=0 l [ "$(uci -q get netshift.settings.dns_via_outbound)" = 1 ] && { rm -f "$f"; return 0; } set -- $(uci -q get netshift.settings.zm_dns_servers) [ $# -gt 1 ] || { rm -f "$f"; return 0; } t="$(uci -q get netshift.settings.dns_type)"; cur="$(uci -q get netshift.settings.dns_server)" mkdir -p "$JOBS_DIR" for v in "$@"; do ok=0 _fk_dns_probe "$t" "$v" "$tmp" IFS='|' read -r l ok l l < "$tmp" 2>/dev/null rm -f "$tmp" [ "$ok" = 1 ] && { best="$v"; break; } done [ -n "$best" ] && [ "$best" != "$cur" ] || { rm -f "$f"; return 0; } ok=0 _fk_dns_probe "$t" "$cur" "$tmp" IFS='|' read -r l ok l l < "$tmp" 2>/dev/null rm -f "$tmp" [ "$ok" = 1 ] && cur_ok=1 { read -r want cnt < "$f"; } 2>/dev/null [ "$want" = "$best" ] && cnt=$(( ${cnt:-0} + 1 )) || cnt=1 echo "$best $cnt" > "$f" [ "$cnt" -ge 2 ] || return 0 rm -f "$f" uci -q set netshift.settings.dns_server="$best" && uci -q commit netshift if [ "$cur_ok" = 1 ]; then logger -t zapret-manager "Forkozz: DNS $best снова отвечает — возвращаемся к нему" else logger -t zapret-manager "Forkozz: DNS $cur не отвечает — переключились на $best"; fi ( _zm_run 180 /usr/bin/netshift reload >/dev/null 2>&1 & ) return 0 } zm_watch() { rpcd_watch _zm_net_orphan && _zm_net_restore force >/dev/null 2>&1 _fk_watch _st_legacy_check _st_vpn_watch _tg_watch return 0 } _zm_job_skip() { case "$1" in versions|sysinfo|*_download|redbtn_deep) return 0 ;; esac; return 1; } _zm_cancel_one() { local j="$1" pid log="$JOBS_DIR/$1.log" pid="$(cat "$JOBS_DIR/$j.pid" 2>/dev/null)" [ -n "$pid" ] && _zm_kill_tree "$pid" grep -q '^__DONE__' "$log" 2>/dev/null && return 0 [ -n "$(tail -c 1 "$log" 2>/dev/null)" ] && echo >> "$log" echo "!! Операция остановлена вручную — все её процессы завершены" >> "$log" echo "__DONE__ 130" >> "$log" case "$j" in forkop) [ -d /usr/lib/netshift.zm-old ] && _fk_restore_old >> "$log" 2>&1 rm -rf "$JOBS_DIR/netshift-src" "$JOBS_DIR/forkop-import" ;; steer) rm -f "$ST_PHASE_FILE" 2>/dev/null; rmdir "$ST_RUN/cat.lock" 2>/dev/null ;; strategy_test) nft delete table inet zm_rb_ztest >/dev/null 2>&1 ;; install_zapret) _nochange_restore zapret >> "$log" 2>&1 ;; install_zapret2) _nochange_restore zapret2 >> "$log" 2>&1 ;; awg|mixomo_warp) ip link del "$AWG_TEST_IF" >/dev/null 2>&1; rm -f "$AWG_RUN/try.conf" ;; esac } jobs_cancel() { local f j list="" sep="" for f in "$JOBS_DIR"/*.pid; do [ -f "$f" ] || continue j="$(basename "$f" .pid)" _zm_job_skip "$j" && continue [ -n "$1" ] && [ "$1" != "$j" ] && continue _job_running "$j" || continue _zm_cancel_one "$j" list="$list$sep\"$j\""; sep="," done local busy="" for f in "$JOBS_DIR"/*.pid; do [ -f "$f" ] || continue _job_running "$(basename "$f" .pid)" && { busy=1; break; } done [ -z "$busy" ] && rm -f /tmp/zm-run.* /tmp/zm-wait.* /tmp/zm-quiet.* /tmp/zm-pkg.* 2>/dev/null _zm_net_orphan && _zm_net_restore force >/dev/null 2>&1 _zm_pkg_unlock printf '{"ok":true,"stopped":[%s]}\n' "$list" } rpcd_watch() { local rc if ! pidof rpcd >/dev/null 2>&1; then logger -t zapret-manager "rpcd не запущен — запускаем, чтобы вернуть вход в LuCI" /etc/init.d/rpcd restart >/dev/null 2>&1 return 0 fi ubus -t 5 list session >/dev/null 2>&1 || { logger -t zapret-manager "rpcd не отвечает — перезапускаем"; /etc/init.d/rpcd restart >/dev/null 2>&1; return 0; } ubus -t 15 call session access '{"ubus_rpc_session":"00000000000000000000000000000000","scope":"ubus","object":"session","function":"access"}' >/dev/null 2>&1 rc=$? if [ "$rc" = 7 ]; then logger -t zapret-manager "rpcd завис — перезапускаем, чтобы вернуть вход в LuCI" /etc/init.d/rpcd restart >/dev/null 2>&1 return 0 fi _rpcd_plugin_watch return 0 } _rpcd_plugin_watch() { local pl=/usr/libexec/rpcd/zapret-manager want have sig st stamp=/tmp/zm-rpcd-plugin.stamp [ -x "$pl" ] || return 0 _rpcd_plugin_state "$pl" && { rm -f "$stamp"; return 0; } sleep 5 _rpcd_plugin_state "$pl" st=$? [ "$st" = 0 ] && { rm -f "$stamp"; return 0; } sig="$(date -r "$pl" +%s 2>/dev/null)" if [ "$st" = 1 ]; then case "$(cat "$stamp" 2>/dev/null)" in "$sig.x3") return 0 ;; "$sig.x2") echo "$sig.x3" > "$stamp" ;; "$sig.x1") echo "$sig.x2" > "$stamp" ;; *) echo "$sig.x1" > "$stamp" ;; esac logger -t zapret-manager "rpcd не видит панель (Object not found) — перезапускаем rpcd" else [ "$(cat "$stamp" 2>/dev/null)" = "$sig.m" ] && return 0 echo "$sig.m" > "$stamp" logger -t zapret-manager "rpcd держит старый список методов панели — перезапускаем rpcd" fi /etc/init.d/rpcd restart >/dev/null 2>&1 return 0 } _rpcd_plugin_state() { local want have ubus -t 5 list zapret-manager >/dev/null 2>&1 || return 1 want="$(grep -c 'json_add_object' "$1" 2>/dev/null)" have="$(ubus -v list zapret-manager 2>/dev/null | grep -c '^[[:space:]]*"')" [ "${want:-0}" -gt 0 ] 2>/dev/null || return 0 [ "${have:-0}" -gt 0 ] 2>/dev/null || return 0 [ "$have" = "$want" ] && return 0 return 2 } ui_theme_get() { local t t="$(cat "$ZM_STATE_DIR/ui.theme" 2>/dev/null)" printf '{"theme":"%s"}\n' "$(esc "$t")" } ui_theme_set() { case "$1" in micro|light|dark|ink|bento|minimal|retro|depth|auto) ;; *) echo '{"error":"неизвестная тема"}'; return 1 ;; esac mkdir -p "$ZM_STATE_DIR" /www/zm printf '%s\n' "$1" > "$ZM_STATE_DIR/ui.theme" printf '%s\n' "$1" > /www/zm/theme.txt chmod 0644 /www/zm/theme.txt 2>/dev/null printf '{"ok":true}\n' } job_status() { local name="$1" local pid="$JOBS_DIR/$name.pid" local log="$JOBS_DIR/$name.log" local running="false" done="false" rc="" _job_running "$name" && running="true" if [ -f "$log" ] && grep -q '^__DONE__' "$log"; then done="true" rc=$(grep '^__DONE__' "$log" | tail -1 | awk '{print $2}') fi printf '{"running":%s,"done":%s,"rc":"%s"}\n' "$running" "$done" "$rc" } log_tail() { local name="$1" local log="$JOBS_DIR/$name.log" [ -f "$log" ] || { echo '{"lines":""}'; return; } printf '{"lines":"%s"}\n' "$(esc_ml "$(tail -n 300 "$log" | grep -v '^__DONE__')")" } zapret_restart() { [ -n "$ZM_NO_RESTART" ] && return 0 chmod -R a+rX /opt/zapret/ipset /opt/zapret/files 2>/dev/null [ -x /opt/zapret/sync_config.sh ] && /opt/zapret/sync_config.sh >/dev/null 2>&1 /etc/init.d/zapret restart >/dev/null 2>&1 } _cpu_stat() { awk '/^cpu / { t = 0; for (i = 2; i <= NF; i++) t += $i; print t, $5 + $6; exit }' /proc/stat 2>/dev/null; } _cpu_load() { local f="$JOBS_DIR/cpu.stat" now prev t1 i1 t2 i2 mkdir -p "$JOBS_DIR" now="$(_cpu_stat)" [ -n "$now" ] || return 0 if [ -s "$f" ] && [ -z "$(find "$f" -mmin +1 2>/dev/null)" ]; then prev="$(cat "$f")" else prev="$now" sleep 1 now="$(_cpu_stat)" fi set -- $prev $now t1=$1; i1=$2; t2=$3; i2=$4 if [ $((t2 - t1)) -lt 50 ] 2>/dev/null && [ -s "$f.pct" ]; then cat "$f.pct"; return 0; fi [ "$t2" -gt "$t1" ] 2>/dev/null || { echo 0; return 0; } echo "$now" > "$f" echo $(( (100 * ((t2 - t1) - (i2 - i1)) + (t2 - t1) / 2) / (t2 - t1) )) | tee "$f.pct" } _cpu_temp() { local z t typ best="" v for z in /sys/class/thermal/thermal_zone*; do [ -r "$z/temp" ] || continue t=$(cat "$z/temp" 2>/dev/null) case "$t" in ''|*[!0-9-]*) continue ;; esac typ=$(cat "$z/type" 2>/dev/null) case "$typ" in *cpu*|*CPU*|*soc*|*SOC*) best="$t"; break ;; esac { [ -z "$best" ] || [ "$t" -gt "$best" ]; } && best="$t" done if [ -z "$best" ]; then for z in /sys/class/hwmon/hwmon*/temp1_input; do [ -r "$z" ] || continue t=$(cat "$z" 2>/dev/null) case "$t" in ''|*[!0-9-]*) continue ;; esac best="$t"; break done fi [ -n "$best" ] || return 0 if [ "$best" -gt 1000 ] 2>/dev/null; then v=$(( (best + 50) / 100 )); echo "$((v / 10)).$((v % 10))" else echo "$best"; fi } _inet_ping() { local h out ms for h in ya.ru vk.ru; do out=$(ping -4 -c 1 -W 2 "$h" 2>/dev/null) ms=$(echo "$out" | sed -n 's/.*time=\([0-9.]*\).*/\1/p' | head -n1) [ -n "$ms" ] && { echo "$(date +%s) ok ${ms%%.*}"; return 0; } done echo "$(date +%s) fail" } _inet_state() { local f="$ZM_STATE_DIR/inet" t r ms age read -r t r ms 2>/dev/null < "$f" age=$(( $(date +%s) - ${t:-0} )) if [ "$age" -ge 10 ] || [ "$age" -lt 0 ]; then mkdir -p "$ZM_STATE_DIR" if mkdir "$f.lock" 2>/dev/null; then ( _inet_ping > "$f.new" 2>/dev/null && mv -f "$f.new" "$f"; rmdir "$f.lock" ) >/dev/null 2>&1 & elif [ -n "$(find "$f.lock" -mmin +1 2>/dev/null)" ]; then rmdir "$f.lock" 2>/dev/null fi fi [ -n "$r" ] && [ "$age" -lt 120 ] && echo "$r $ms" } system_info() { local model arch owrt df_out tmp_used tmp_free root_used root_free model=$(cat /tmp/sysinfo/model 2>/dev/null) arch=$(grep DISTRIB_ARCH /etc/openwrt_release 2>/dev/null | cut -d"'" -f2) owrt=$(grep '^DISTRIB_RELEASE=' /etc/openwrt_release 2>/dev/null | cut -d"'" -f2) local target target=$(grep '^DISTRIB_TARGET=' /etc/openwrt_release 2>/dev/null | cut -d"'" -f2) df_out=$(df -h /tmp / 2>/dev/null) tmp_used=$(echo "$df_out" | awk 'NR==2{print $3}') tmp_free=$(echo "$df_out" | awk 'NR==2{print $4}') root_used=$(echo "$df_out" | awk 'NR==3{print $3}') root_free=$(echo "$df_out" | awk 'NR==3{print $4}') local inet inet_ms set -- $(_inet_state) inet="$1"; inet_ms="$2" printf '{"model":"%s","arch":"%s","openwrt":"%s","target":"%s","hostname":"%s","kernel":"%s","tmp_used":"%s","tmp_free":"%s","root_used":"%s","root_free":"%s","cpu_temp":"%s","cpu_load":"%s","inet":"%s","inet_ms":"%s"}\n' \ "$(esc "$model")" "$(esc "$arch")" "$(esc "$owrt")" "$(esc "$target")" "$(esc "$(cat /proc/sys/kernel/hostname 2>/dev/null)")" "$(esc "$(uname -r 2>/dev/null)")" \ "$(esc "$tmp_used")" "$(esc "$tmp_free")" "$(esc "$root_used")" "$(esc "$root_free")" "$(_cpu_temp)" "$(_cpu_load)" "$inet" "$inet_ms" } status() { local zr="not_installed" zr_running="false" zr_ver="" _test_recover if [ -f /etc/init.d/zapret ]; then zr="installed" if [ "$PKG" = "opkg" ]; then zr_ver=$(opkg list-installed zapret 2>/dev/null | awk '{sub(/-r[0-9]+$/,"",$3); print $3}') else zr_ver=$(apk info -v 2>/dev/null | grep '^zapret-' | head -n1 | cut -d- -f2 | sed 's/-r[0-9]\+$//') fi pgrep -f "/opt/zapret/" >/dev/null 2>&1 && zr_running="true" fi local zr2="not_installed" zr2_running="false" if [ -f /etc/init.d/zapret2 ]; then zr2="installed" /etc/init.d/zapret2 status >/dev/null 2>&1 && zr2_running="true" fi local strat="" fs_marker="" if [ -f "$CONF" ]; then strat=$(sed -n "/^[[:space:]]*option NFQWS_OPT '\$/,/^[[:space:]]*'\$/p" "$CONF" | grep '^#' | sed 's/^#[[:space:]]*//; s/[[:space:]]*$//; /^udp443$/d' | tr '\n' ' ' | sed 's/ $//') _udp443_on && strat="${strat:+$strat }QUIC" fs_marker=$(grep -m1 '^# ZMFS:' "$CONF" | sed 's/^# ZMFS://') sed -n "/^[[:space:]]*option NFQWS_OPT '\$/,/^[[:space:]]*'\$/p" "$CONF" | grep -qi '^#[[:space:]]*customstart' && strat="Custom" fi printf '{"pkg":"%s","zapret":"%s","zapret_running":%s,"zapret_version":"%s","zapret2":"%s","zapret2_running":%s,"strategy":"%s","flowseal":"%s","yv_off":%s,"quic_yt":%s,"expert":%s}\n' \ "$PKG" "$zr" "$zr_running" "$(esc "$zr_ver")" "$zr2" "$zr2_running" "$(esc "$strat")" "$(esc "$fs_marker")" \ "$([ -f /opt/zapret-manager-luci/yv_off ] && echo true || echo false)" \ "$([ -f "$CONF" ] && _udp443_on && echo true || echo false)" \ "$([ -f "$EXPERT_MODE_FILE" ] && echo true || echo false)" } _zapret_latest_version() { local ver ver=$(curl -fsSI --connect-timeout 4 --max-time 6 \ "https://github.com/remittor/zapret-openwrt/releases/latest" 2>/dev/null \ | tr -d '\r' | awk -F': ' 'tolower($1)=="location"{print $2}' | tail -n1 | sed 's#.*/v##') echo "$ver" | grep -qE '^[0-9]+\.[0-9]+$' && echo "$ver" || echo "" } zapret_latest_version() { printf '{"version":"%s"}\n' "$(esc "$(_zapret_latest_version)")" } _do_install_zapret_core() { _ensure_deps if [ -f /etc/init.d/zapret2 ] && [ ! -f "$EXPERT_MODE_FILE" ]; then echo "ОШИБКА: установлен Zapret2, он несовместим с Zapret — сначала удалите Zapret2" return 1 fi echo "==> Определяем версию Zapret" local ver arch url tmp ver="$(_zapret_latest_version)" [ -z "$ver" ] && { echo "!! Не удалось определить версию, использую фиксированную 72.20260307"; ver="72.20260307"; } arch="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" url="${GH_MAIN}/remittor/zapret-openwrt/releases/download/v${ver}/zapret_v${ver}_${arch}.zip" tmp="$JOBS_DIR/install_tmp"; rm -rf "$tmp"; mkdir -p "$tmp"; cd "$tmp" || return 1 echo "==> Обновляем список пакетов" $UPDATE if [ -f /etc/init.d/zapret ]; then echo "==> Останавливаем текущий Zapret" /etc/init.d/zapret stop >/dev/null 2>&1 for p in $(pgrep -f "/opt/zapret/" 2>/dev/null); do kill -9 "$p" 2>/dev/null; done fi echo "==> Скачиваем $url" local attempt=1 max_attempts=5 while [ "$attempt" -le "$max_attempts" ]; do rm -f zapret.zip wget -q --timeout=20 -U "Mozilla/5.0" -O zapret.zip "$url" >&2 || echo "!! Попытка $attempt из $max_attempts: архив не скачался" command -v unzip >/dev/null 2>&1 || { echo "==> Устанавливаем unzip"; $INSTALL unzip >&2; } if [ -s zapret.zip ] && unzip -tq zapret.zip >/dev/null 2>&1; then break fi attempt=$((attempt + 1)) done if [ "$attempt" -gt "$max_attempts" ]; then echo "ОШИБКА: не удалось скачать целый архив за $max_attempts попыток — проверьте соединение с GitHub" return 1 fi unzip -o zapret.zip >/dev/null 2>&1 echo "==> Устанавливаем пакеты" if [ "$PKG" = "apk" ]; then for p in apk/zapret*; do [ -f "$p" ] || continue echo "$p" | grep -q luci && continue echo " - $(basename "$p")"; $INSTALL "$p" >&2 || { echo "ОШИБКА: $p"; return 1; } done for p in apk/luci*; do [ -f "$p" ] || continue echo " - $(basename "$p")"; $INSTALL "$p" >&2 done else for p in zapret_*.ipk; do [ -f "$p" ] || continue echo " - $(basename "$p")"; $INSTALL "$p" >&2 || { echo "ОШИБКА: $p"; return 1; } done for p in luci-app-zapret_*.ipk; do [ -f "$p" ] || continue echo " - $(basename "$p")"; $INSTALL "$p" >&2 done fi _nochange_restore zapret echo "==> Добавляем домены в исключения" _nochange_fetch /opt/zapret/ipset/zapret-hosts-user-exclude.txt "$EXCLUDE_URL" || echo "!! Список исключений не скачался — оставлен прежний" do_add_fake_flow echo "==> Включаем автозапуск и custom.d-скрипты" /etc/init.d/zapret enable >/dev/null 2>&1 sed -i "/DISABLE_CUSTOM/s/'1'/'0'/" "$CONF" 2>/dev/null cd /; rm -rf "$tmp" echo "==> Готово, Zapret установлен ($ver)" } do_install_zapret() { local rc _nochange_save zapret /opt/zapret/ipset "$CONF" _do_install_zapret_core; rc=$? _nochange_restore zapret return $rc } do_install_zapret2() { local rc _nochange_save zapret2 /opt/zapret2/ipset /etc/config/zapret2 _do_install_zapret2_core; rc=$? _nochange_restore zapret2 return $rc } do_install_zapret_full() { do_install_zapret || return 1 [ -f /etc/init.d/zapret ] || return 1 if _nochange_strategy; then echo "==> Стратегия помечена #nochange — оставляем её как есть" else echo "==> Применяем базовую стратегию v7" strategy_set_v v7 >/dev/null fi echo "==> Добавляем домены в hosts" local b blocks="ai instagram ntc librusec telegram twitch scell spotify rutor" for b in $blocks; do local content line content="$(_hosts_block "$b")" while IFS= read -r line; do [ -z "$line" ] && continue _hosts_has_line "$line" || echo "$line" >> "$HOSTS_FILE" done <<-HOSTBLOCK $content HOSTBLOCK done /etc/init.d/dnsmasq restart >/dev/null 2>&1 if ! _nochange_strategy; then echo "==> Настраиваем игровую стратегию Gv1" game_set 1 >/dev/null fi echo "==> Готово, Zapret установлен и настроен" } do_remove_zapret() { echo "==> Останавливаем Zapret" _zm_svc_off zapret for p in $(pgrep -f "/opt/zapret/" 2>/dev/null); do kill -9 "$p" 2>/dev/null; done nft delete table inet zapret >/dev/null 2>&1 echo " ✓ Процессы nfqws остановлены, правила файрвола сняты" echo "==> Удаляем пакеты Zapret" $DELETE luci-i18n-zapret-ru luci-app-zapret zapret echo "==> Удаляем файлы, стратегии и списки" rm -rf /opt/zapret "$CONF" /etc/init.d/zapret /etc/rc.d/*[0-9]zapret /etc/firewall.zapret /etc/hotplug.d/*/*zapret \ /tmp/zapret /tmp/zapret.* "$YV_OFF_FLAG" "$DV_OFF_FLAG" "$JOBS_DIR/flowseal_strategies.txt" "$JOBS_DIR/youtube_strategies.txt" echo " ✓ Файлы удалены" crontab -l 2>/dev/null | awk '{ l = tolower($0) } l ~ /zapret/ && l !~ /zapret-manager|zapret2/ { next } { print }' | crontab - 2>/dev/null echo " ✓ Задания Zapret убраны из расписания" /etc/init.d/firewall reload >/dev/null 2>&1 echo "==> Готово, Zapret удалён полностью" } zapret_action() { local action="$1" case "$action" in install) if [ ! -f /etc/init.d/zapret ] && [ -f /etc/init.d/zapret2 ] && [ ! -f "$EXPERT_MODE_FILE" ]; then echo '{"error":"Стоит Zapret2 — он несовместим с Zapret. Чтобы поставить Zapret, удалите Zapret2."}'; return 1 fi job_start install_zapret do_install_zapret_full ;; update) job_start install_zapret do_install_zapret ;; remove) job_start remove_zapret do_remove_zapret ;; start) /etc/init.d/zapret start >/dev/null 2>&1; zapret_restart; status ;; stop) /etc/init.d/zapret stop >/dev/null 2>&1 for p in $(pgrep -f "/opt/zapret/" 2>/dev/null); do kill -9 "$p" 2>/dev/null; done status ;; *) echo '{"error":"неизвестное действие"}' ;; esac } _do_install_zapret2_core() { _ensure_deps local arch arch="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" if [ "$arch" != "aarch64_cortex-a53" ]; then echo "ОШИБКА: Zapret2 поддерживает только архитектуру aarch64_cortex-a53 (у вас: $arch)" return 1 fi if [ -f /etc/init.d/zapret ] && [ ! -f "$EXPERT_MODE_FILE" ]; then echo "ОШИБКА: установлен основной Zapret, он несовместим с Zapret2 — сначала удалите Zapret" return 1 fi local base_url raz if [ "$PKG" = "apk" ]; then base_url="https://packages.routerich.ru/25.12/mediatek/filogic/routerich/"; raz="apk" else base_url="https://packages.routerich.ru/24.10/mediatek/filogic/routerich/"; raz="ipk" fi local tmp="$JOBS_DIR/install_z2_tmp"; rm -rf "$tmp"; mkdir -p "$tmp"; cd "$tmp" || return 1 echo "==> Ищем актуальный пакет Zapret2 в $base_url" curl -fsS --connect-timeout 8 --max-time 15 "$base_url" -o index.html \ || { echo "ОШИБКА: не удалось получить список пакетов"; return 1; } local main_file luci_file if [ "$PKG" = "apk" ]; then main_file=$(grep -oE 'zapret2-[0-9][^"]*\.apk' index.html | head -n1) luci_file=$(grep -oE 'luci-app-zapret2-[0-9][^"]*\.apk' index.html | head -n1) else main_file=$(grep -oE 'zapret2_[0-9][^"]*_aarch64_cortex-a53\.ipk' index.html | head -n1) luci_file=$(grep -oE 'luci-app-zapret2_[0-9][^"]*_all\.ipk' index.html | head -n1) fi [ -z "$main_file" ] && { echo "ОШИБКА: пакет zapret2 не найден в репозитории"; return 1; } echo "==> Скачиваем $main_file" curl -fsSL --max-time 30 "${base_url}${main_file}" -o "$main_file" || { echo "ОШИБКА скачивания $main_file"; return 1; } if [ -n "$luci_file" ]; then echo "==> Скачиваем $luci_file" curl -fsSL --max-time 30 "${base_url}${luci_file}" -o "$luci_file" fi echo "==> Обновляем список пакетов" $UPDATE echo "==> Устанавливаем" $INSTALL ./*."$raz" || { echo "ОШИБКА установки"; return 1; } _nochange_restore zapret2 echo "==> Добавляем домены в исключения" mkdir -p /opt/zapret2/ipset _nochange_fetch /opt/zapret2/ipset/zapret_hosts_user_exclude.txt "$EXCLUDE_URL" echo "==> Настраиваем стратегии" mkdir -p /opt/zapret2/init.d/openwrt/custom.d wget -q --timeout=20 -U "Mozilla/5.0" -O /opt/zapret2/init.d/openwrt/custom.d/50-discord_media.sh \ "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/50-discord_media.sh" \ || echo "!! Не удалось загрузить 50-discord_media.sh" if _nochange_in /etc/config/zapret2; then echo " · Стратегия Zapret2 помечена #nochange — оставляем как есть" else _nochange_fetch /etc/config/zapret2 "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/zapret2" || echo "!! Не удалось загрузить zapret2" fi _nochange_fetch /opt/zapret2/ipset/zapret_hosts_discord.txt "${GH_RAW}/StressOzz/Zapret-Manager/refs/heads/main/files/Zapret2/zapret_hosts_discord.txt" \ || echo "!! Не удалось загрузить zapret_hosts_discord.txt" echo "==> Запускаем Zapret2" /etc/init.d/zapret2 enable >/dev/null 2>&1 /etc/init.d/zapret2 restart >/dev/null 2>&1 cd /; rm -rf "$tmp" echo "==> Готово, Zapret2 установлен" } do_remove_zapret2() { echo "==> Останавливаем Zapret2" _zm_svc_off zapret2 for p in $(pgrep -f "/opt/zapret2/" 2>/dev/null); do kill -9 "$p" 2>/dev/null; done nft delete table inet zapret2 >/dev/null 2>&1 echo " ✓ Процессы остановлены, правила файрвола сняты" echo "==> Удаляем пакеты Zapret2" $DELETE luci-i18n-zapret2-ru luci-app-zapret2 zapret2 echo "==> Удаляем файлы и настройки" rm -rf /etc/config/zapret2 /opt/zapret2 /etc/init.d/zapret2 /etc/rc.d/*zapret2 /etc/hotplug.d/*/*zapret2 /tmp/zapret2* echo " ✓ Файлы удалены" _zm_cron_drop 'zapret2' 'Zapret2' /etc/init.d/firewall reload >/dev/null 2>&1 echo "==> Готово, Zapret2 удалён полностью" } zapret2_action() { local action="$1" case "$action" in install|update) if [ ! -f /etc/init.d/zapret2 ] && [ -f /etc/init.d/zapret ] && [ ! -f "$EXPERT_MODE_FILE" ]; then echo '{"error":"Стоит Zapret — он несовместим с Zapret2. Чтобы поставить Zapret2, удалите Zapret."}'; return 1 fi job_start install_zapret2 do_install_zapret2 ;; remove) job_start remove_zapret2 do_remove_zapret2 ;; start) /etc/init.d/zapret2 start >/dev/null 2>&1; status ;; stop) /etc/init.d/zapret2 stop >/dev/null 2>&1; status ;; *) echo '{"error":"неизвестное действие"}' ;; esac } strategy_v1() { printf '%s\n' "#v1" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=split2" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin"; } strategy_v2() { printf '%s\n' "#v2" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=ts" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } strategy_v3() { printf '%s\n' "#v3" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=hostfakesplit" "--dpi-desync-hostfakesplit-mod=host=ozon.ru" "--dpi-desync-repeats=4" "--dpi-desync-fooling=ts,md5sig" "--dpi-desync-badseq-increment=0"; } strategy_v4() { printf '%s\n' "#v4" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=582" "--dpi-desync-split-pos=1" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin"; } strategy_v5() { printf '%s\n' "#v5" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,fakeddisorder" "--dpi-desync-split-pos=1" "--dpi-desync-fake-tls=/opt/zapret/files/fake/stun.bin" "--dpi-desync-fake-tls-mod=none" "--dpi-desync-fakedsplit-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fooling=badseq,badsum" "--dpi-desync-badseq-increment=0"; } strategy_v6() { printf '%s\n' "#v6" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=hostfakesplit" "--dpi-desync-hostfakesplit-mod=host=i2.photo.2gis.com" "--dpi-desync-hostfakesplit-midhost=host-2" "--dpi-desync-split-seqovl=726" "--dpi-desync-fooling=badsum,badseq" "--dpi-desync-badseq-increment=0"; } strategy_v7() { printf '%s\n' "#v7" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=654" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=badseq,badsum" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin" "--dpi-desync-fake-tls=/opt/zapret/files/fake/stun.bin" "--dpi-desync-badseq-increment=0"; } strategy_v8() { printf '%s\n' "#v8" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake" "--dpi-desync-fooling=ts" "--dpi-desync-fake-tls=/opt/zapret/files/fake/4pda.bin" "--dpi-desync-fake-tls-mod=none"; } strategy_v9() { printf '%s\n' "#v9" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=hostfakesplit" "--dpi-desync-fooling=badseq,badsum" "--dpi-desync-hostfakesplit-mod=host=ozon.ru" "--dpi-desync-badseq-increment=0"; } strategy_v10() { printf '%s\n' "#v10" "--filter-tcp=443" "--hostlist-exclude=/opt/zapret/ipset/zapret-hosts-user-exclude.txt" "--dpi-desync=fake,split2" "--dpi-desync-split-pos=2" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-hostfakesplit-mod=host=maxcdn.bootstrapcdn.com" "--dpi-desync-fake-tls-mod=rnd,sni=maxcdn.bootstrapcdn.com" "--dpi-desync-fooling=ts"; } NOCHANGE_RX='#[[:space:]]*nochange' NOCHANGE_MSG="включено «Не изменять стратегию» (#nochange) — панель её не меняет. Выключите этот переключатель в «Редактировать текущую стратегию», чтобы разрешить изменения" _nochange_in() { [ -f "$1" ] && grep -qiE "$NOCHANGE_RX" "$1"; } _nochange_strategy() { _nfq_opt_body | grep -qiE "$NOCHANGE_RX"; } _nochange_guard() { _nochange_strategy || return 0 printf '{"error":"%s","nochange":true}\n' "$(esc "$NOCHANGE_MSG")" return 1 } _nochange_fetch() { local f="$1" url="$2" tmp="$1.zm-new" if _nochange_in "$f"; then [ -n "$ZM_JOB_LOG" ] && echo " · $(basename "$f"): помечен #nochange — не обновляем" >&2 return 0 fi mkdir -p "$(dirname "$f")" if wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$url" 2>/dev/null && [ -s "$tmp" ]; then mv -f "$tmp" "$f" else rm -f "$tmp"; return 1; fi } _nochange_save() { local key="$1" keep="$ZM_STATE_DIR/nochange.$1" p f shift [ -d "$keep" ] && _nochange_restore "$key" >/dev/null 2>&1 for p in "$@"; do if [ -d "$p" ]; then set -- "$p"/*; else set -- "$p"; fi for f in "$@"; do [ -f "$f" ] && _nochange_in "$f" || continue mkdir -p "$keep" cp -p "$f" "$keep/$(echo "$f" | sed 's#/#%#g')" || continue echo " · $(basename "$f"): помечен #nochange — сохраним как есть" done done return 0 } _nochange_restore() { local keep="$ZM_STATE_DIR/nochange.$1" f dst [ -d "$keep" ] || return 0 for f in "$keep"/*; do [ -f "$f" ] || continue dst="$(basename "$f" | sed 's#%#/#g')" mkdir -p "$(dirname "$dst")" cp -p "$f" "$dst" && echo " ✓ $(basename "$dst") оставлен как был (#nochange)" done rm -rf "$keep" } _add_gp_domains() { _nochange_in /opt/zapret/ipset/zapret-hosts-google.txt && return 0 local f="/opt/zapret/ipset/zapret-hosts-google.txt" tmp mkdir -p "$(dirname "$f")" tmp="$f.tmp" { [ -f "$f" ] && cat "$f" printf '%s\n' "gvt1.com" "googleplay.com" "play.google.com" "beacons.gvt2.com" \ "play.googleapis.com" "play-fe.googleapis.com" "lh3.googleusercontent.com" \ "android.clients.google.com" "connectivitycheck.gstatic.com" \ "play-lh.googleusercontent.com" "play-games.googleusercontent.com" \ "prod-lt-playstoregatewayadapter-pa.googleapis.com" "youtubei.youtube.com" } | sort -u > "$tmp" mv "$tmp" "$f" } _refresh_exclude_file() { _nochange_fetch /opt/zapret/ipset/zapret-hosts-user-exclude.txt "$EXCLUDE_URL" } YV_OFF_FLAG="/opt/zapret-manager-luci/yv_off" DV_OFF_FLAG="/opt/zapret-manager-luci/dv_off" DISCORD_RX='^--filter-l7=discord,stun$|^--filter-udp=19294-19344,50000-50100$|^--filter-tcp=2053,2083,2087,2096,8443$|^--hostlist-domains=discord[.]media$|^#[ \t]*Dv[0-9]+$' YV_RX='^#[ \t]*Yv[0-9]+$|^--hostlist=/opt/zapret/ipset/zapret-hosts-google[.]txt$' NFQ_MARK_RX='^[ \t]*#[ \t]*((Yv|Dv|Gv)[0-9]|udp443[ \t]*$)' UDP443_MARK_RX='^[ \t]*#[ \t]*udp443[ \t]*$' _nfq_drop_profiles() { local rx="$1" force="$2" keep="$3" old="$JOBS_DIR/nfq_drop_old" new="$JOBS_DIR/nfq_drop_new" rc mkdir -p "$JOBS_DIR" awk "/^[[:space:]]*option NFQWS_OPT '\$/ { f = 1 } f" "$CONF" > "$old" [ -s "$old" ] || { rm -f "$old"; return 1; } grep -q "^[[:space:]]*'[[:space:]]*\$" "$old" || { rm -f "$old"; return 1; } awk -v RX="$rx" -v KP="$keep" -v MK="$NFQ_MARK_RX" -v UQ="$UDP443_MARK_RX" -v q="'" ' function emit(l) { o[++no] = l } function flush( i, drop, real, udp) { if (n == 0 && np == 0) return drop = 0; real = 0; udp = 0 for (i = 1; i <= np; i++) if (pc[i] ~ RX) drop = 1 for (i = 1; i <= n; i++) { if (b[i] ~ RX) drop = 1 if (b[i] !~ /^[ \t]*#/) real++ if (b[i] == "--filter-udp=443") udp = 1 } if (drop && KP != "") { for (i = 1; i <= np; i++) if (pc[i] ~ KP) drop = 0 for (i = 1; i <= n; i++) if (b[i] ~ KP) drop = 0 } if (drop) removed = 1 else if (real > 0) { for (i = 1; i <= np; i++) if (udp || pc[i] !~ UQ) emit(pc[i]) if (printed) emit("--new") for (i = 1; i <= n; i++) if (udp || b[i] !~ UQ) emit(b[i]) printed = 1 } n = 0; np = 0 } function hold_in( i) { for (i = 1; i <= nh; i++) b[++n] = h[i]; nh = 0 } NR == 1 { head = $0; next } done { tail[++nt] = $0; next } $0 ~ ("^[ \t]*" q "[ \t]*$") { hold_in(); flush() print head for (i = 1; i <= nn; i++) print nm[i] for (i = 1; i <= no; i++) print o[i] print done = 1; next } /^[ \t]*$/ { next } $0 == "--new" { flush(); for (k = 1; k <= nh; k++) pc[++np] = h[k]; nh = 0; next } /^[ \t]*#/ { if ($0 ~ MK) h[++nh] = $0 else if (!(($0) in seen)) { seen[$0] = 1; nm[++nn] = $0 } next } { hold_in(); b[++n] = $0 } END { if (!done) exit 1 for (i = 1; i <= nt; i++) print tail[i] exit (removed ? 0 : 3) }' "$old" > "$new" rc=$? if [ "$rc" = 0 ] || { [ "$rc" = 3 ] && [ -n "$force" ]; }; then sed -i "/^[[:space:]]*option NFQWS_OPT '/,\$d" "$CONF" cat "$new" >> "$CONF" fi rm -f "$old" "$new" return $rc } _nfq_normalize() { _nfq_drop_profiles '^#ZM_NEVER_MATCHES$' force; return 0; } _nfq_opt_body() { sed -n "/^[[:space:]]*option NFQWS_OPT '\$/,/^[[:space:]]*'\$/p" "$CONF" 2>/dev/null; } _udp443_on() { _nfq_opt_body | grep -qx -- '--filter-udp=443'; } _udp443_add() { _udp443_on && return 0 sed -i '/^[[:space:]]*#[[:space:]]*udp443[[:space:]]*$/d' "$CONF" sed -i "/^[[:space:]]*option NFQWS_OPT '/a\\#udp443\\n--filter-udp=443\\n--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt\\n--dpi-desync=fake\\n--dpi-desync-repeats=11\\n--dpi-desync-fake-quic=/opt/zapret/files/fake/quic_initial_www_google_com.bin\\n--new" "$CONF" _add_ports_if_missing NFQWS_PORTS_UDP 443 } youtube_quic_set() { local mode="$1" [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } case "$mode" in on) _udp443_on && { printf '{"ok":true,"quic":true}\n'; return 0; } _add_gp_domains _udp443_add _nfq_normalize ;; off) _udp443_on || { sed -i '/^[[:space:]]*#[[:space:]]*udp443[[:space:]]*$/d' "$CONF"; printf '{"ok":true,"quic":false}\n'; return 0; } _nfq_drop_profiles '^--filter-udp=443$' case $? in 0|3) ;; *) echo '{"error":"блок стратегий Zapret (NFQWS_OPT) записан не так, как его пишет панель, — блок QUIC не тронут"}'; return 1 ;; esac sed -i '/^[[:space:]]*#[[:space:]]*udp443[[:space:]]*$/d' "$CONF" ;; *) echo '{"error":"неизвестное действие"}'; return 1 ;; esac zapret_restart printf '{"ok":true,"quic":%s}\n' "$(_udp443_on && echo true || echo false)" } _add_yv_default() { [ -f "$YV_OFF_FLAG" ] && return 0 if ! grep -q "^#Yv" "$CONF" && ! grep -q "^#general" "$CONF"; then sed -i "/^[[:space:]]*option NFQWS_OPT '/a\\#Yv08\\n--filter-tcp=443\\n--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt\\n--dpi-desync=hostfakesplit\\n--dpi-desync-hostfakesplit-mod=host=google.com\\n--dpi-desync-fooling=ts\\n--new" "$CONF" fi } _discord_str_add() { [ -f "$DV_OFF_FLAG" ] && return 0 if ! grep -q "option NFQWS_PORTS_UDP.*19294-19344,50000-50100" "$CONF"; then sed -i "/^[[:space:]]*option NFQWS_PORTS_UDP '/s/'\$/,19294-19344,50000-50100'/" "$CONF" fi if ! grep -q "option NFQWS_PORTS_TCP.*2053,2083,2087,2096,8443" "$CONF"; then sed -i "/^[[:space:]]*option NFQWS_PORTS_TCP '/s/'\$/,2053,2083,2087,2096,8443'/" "$CONF" fi if ! grep -q -- "--filter-udp=19294-19344,50000-50100" "$CONF"; then local last_line1 last_line1=$(grep -n "^'\$" "$CONF" | tail -n1 | cut -d: -f1) [ -n "$last_line1" ] && sed -i "${last_line1},\$d" "$CONF" printf "%s\n" "--new" "--filter-udp=19294-19344,50000-50100" "--filter-l7=discord,stun" \ "--dpi-desync=fake" "--dpi-desync-fake-discord=/opt/zapret/files/fake/stun.bin" \ "--dpi-desync-fake-stun=/opt/zapret/files/fake/stun.bin" "--dpi-desync-repeats=6" \ "#Dv1" "--new" "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" \ "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=652" "--dpi-desync-split-pos=2" \ "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" \ "'" >> "$CONF" fi } _ts_warning() { grep -q "=ts" "$CONF" 2>/dev/null && echo true || echo false; } strategy_list_v() { printf '{"items":[%s]}\n' "$( i=1 while [ "$i" -le 10 ]; do printf '{"id":"v%s","label":"Стратегия v%s"}' "$i" "$i" [ "$i" -lt 10 ] && printf ',' i=$((i + 1)) done )" } strategy_set_v() { local version="$1" yv dv gv xt q echo "$version" | grep -qE '^v([1-9]|10)$' || { echo '{"error":"некорректная версия"}'; return 1; } [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } _udp443_on && q=1 yv=$(grep -oE '^#[[:space:]]*Yv[0-9]+$' "$CONF" | head -n1 | sed 's/^#[[:space:]]*//') dv=$(grep -oE '^#[[:space:]]*Dv[0-9]+$' "$CONF" | head -n1 | sed 's/^#[[:space:]]*Dv//') gv=$(grep -oE '^#Gv[1-4](Xtreme)?$' "$CONF" | head -n1 | sed 's/^#Gv//; s/Xtreme$//') grep -q "^#Gv[0-9]\+Xtreme\$" "$CONF" && xt=1 _game_xtreme_undo_opts _remove_ports_if_present NFQWS_PORTS_UDP "$PORTS_UDP" _remove_ports_if_present NFQWS_PORTS_TCP "$PORTS_TCP" sed -i '/^# ZMFS:/d' "$CONF" sed -i "/^[[:space:]]*option NFQWS_OPT '/,\$d" "$CONF" { echo " option NFQWS_OPT '"; strategy_"$version"; echo "'"; } >> "$CONF" _add_gp_domains _refresh_exclude_file _add_yv_default _discord_str_add ZM_NO_RESTART=1 if [ -n "$yv" ] && [ "$yv" != Yv08 ] && [ ! -f "$YV_OFF_FLAG" ] && [ ! -s "$(_yv_file)" ]; then do_yv_download >/dev/null 2>&1 fi if [ -n "$yv" ] && [ "$yv" != Yv08 ] && [ ! -f "$YV_OFF_FLAG" ] && [ -s "$(_yv_file)" ] && grep -qxF "#$yv" "$(_yv_file)"; then strategy_set_youtube "$yv" >/dev/null fi [ -n "$dv" ] && [ "$dv" != 1 ] && [ ! -f "$DV_OFF_FLAG" ] && discord_set_dv "$dv" >/dev/null if [ -n "$gv" ]; then game_set "$gv" >/dev/null [ -n "$xt" ] && game_toggle_xtreme >/dev/null fi [ -n "$q" ] && _udp443_add ZM_NO_RESTART="" _nfq_normalize zapret_restart printf '{"ok":true,"strategy":"%s","ts_warning":%s}\n' "$version" "$(_ts_warning)" } _flowseal_file() { echo "$JOBS_DIR/flowseal_strategies.txt"; } do_add_fake_flow() { [ -d /opt/zapret ] || return 0 [ -f "$CONF" ] || return 0 local f msg=0 for f in stun2.bin quic_initial_tencent_com.bin quic_initial_steamcommunity_com.bin \ tls_clienthello_sochi_park.bin quic_initial_4pda_to.bin quic_initial_5ka_ru.bin \ tls_clienthello_5ka_ru.bin quic_initial_rutube_ru.bin; do if [ ! -f "/opt/zapret/files/fake/$f" ]; then [ "$msg" = 0 ] && { echo "==> Скачиваем дополнительные fake-файлы"; msg=1; } wget -q --timeout=20 -U "Mozilla/5.0" -O "/opt/zapret/files/fake/$f" "${FLOWSEAL_FAKE_RAW}/$f" \ || echo "!! Не удалось загрузить файл $f" fi done } do_flowseal_download() { _ensure_deps local out zip tmp attempt=1 max_attempts=5 out="$(_flowseal_file)"; zip="$JOBS_DIR/flowseal.zip"; tmp="$JOBS_DIR/flowseal_src" echo "==> Скачиваем список стратегий Flowseal" rm -rf "$tmp" "$zip"; : > "$out" command -v unzip >/dev/null 2>&1 || $INSTALL unzip >&2 while [ "$attempt" -le "$max_attempts" ]; do rm -f "$zip" wget -q --timeout=20 -U "Mozilla/5.0" -O "$zip" "$FLOWSEAL_ZIP" >&2 if [ -s "$zip" ] && unzip -tq "$zip" >/dev/null 2>&1; then break fi attempt=$((attempt + 1)) done if [ "$attempt" -gt "$max_attempts" ]; then echo "ОШИБКА: не удалось скачать целый архив за $max_attempts попыток — проверьте соединение с GitHub" return 1 fi mkdir -p "$tmp"; unzip -oq "$zip" -d "$tmp" || { echo "ОШИБКА распаковки"; return 1; } local base="$tmp/zapret-discord-youtube-main" echo "==> Разбираем .bat-стратегии" find "$base" -type f -name 'general*.bat' ! -name 'general (ALT5).bat' | while read -r F; do MATCH=$(grep -E '^--filter-udp=19294-19344,50000-50100|^--filter-tcp=%GameFilterTCP%|^--filter-udp=%GameFilterUDP%|^--filter-tcp=2053,2083,2087,2096,8443|^--filter-tcp=443 --hostlist="%LISTS%list-google.txt"|^--filter-tcp=80,443 --hostlist="%LISTS%list-general.txt"' "$F") [ -z "$MATCH" ] && continue NAME=$(basename "$F" .bat) { echo "#$NAME"; echo "$MATCH" | sed 's/--/\n--/g' | sed '/^$/d' | sed 's/[[:space:]]*$//'; echo; } >> "$out" done echo "==> Приводим пути и плейсхолдеры к реальным (как в оригинальном download_strategies)" sed -i '/--hostlist="%LISTS%list-general.txt"/d' "$out" sed -i '/--ipset="%LISTS%ipset-all.txt"/d' "$out" sed -i '/--hostlist="%LISTS%list-general-user.txt"/d' "$out" sed -i '/--ipset-exclude="%LISTS%ipset-exclude.txt"/d' "$out" sed -i '/--ipset-exclude="%LISTS%ipset-exclude-user.txt"/d' "$out" sed -i '/--hostlist-exclude="%LISTS%list-exclude-user.txt"/d' "$out" sed -i 's|"%LISTS%list-exclude.txt"|/opt/zapret/ipset/zapret-hosts-user-exclude.txt|g' "$out" sed -i 's|"%LISTS%list-google.txt"|/opt/zapret/ipset/zapret-hosts-google.txt|g' "$out" sed -i 's/--new[[:space:]]\^/--new/g' "$out" sed -i 's|"%BIN%tls_clienthello_www_google_com.bin"|/opt/zapret/files/fake/tls_clienthello_www_google_com.bin|g' "$out" sed -i 's|"%BIN%tls_clienthello_sochi_park.bin"|/opt/zapret/files/fake/tls_clienthello_sochi_park.bin|g' "$out" sed -i 's|"%BIN%stun.bin"|/opt/zapret/files/fake/stun.bin|g' "$out" sed -i 's|"%BIN%tls_clienthello_4pda_to.bin"|/opt/zapret/files/fake/4pda.bin|g' "$out" sed -i 's|"%BIN%quic_initial_www_google_com.bin"|/opt/zapret/files/fake/quic_initial_www_google_com.bin|g' "$out" sed -i 's|"%BIN%stun2.bin"|/opt/zapret/files/fake/stun2.bin|g' "$out" sed -i 's|"%BIN%quic_initial_tencent_com.bin"|/opt/zapret/files/fake/quic_initial_tencent_com.bin|g' "$out" sed -i 's|"%BIN%quic_initial_steamcommunity_com.bin"|/opt/zapret/files/fake/quic_initial_steamcommunity_com.bin|g' "$out" sed -i 's|"%BIN%quic_initial_4pda_to.bin"|/opt/zapret/files/fake/quic_initial_4pda_to.bin|g' "$out" sed -i 's|"%BIN%ACTIVE_DISCORD_UDP.bin"|/opt/zapret/files/fake/quic_initial_steamcommunity_com.bin|g' "$out" sed -i 's|"%BIN%ACTIVE_GAME_UDP.bin"|/opt/zapret/files/fake/quic_initial_4pda_to.bin|g' "$out" sed -i 's|"%BIN%tls_clienthello_max_ru.bin"|/opt/zapret/files/fake/tls_clienthello_www_onetrust_com.bin|g' "$out" sed -i 's|"%BIN%quic_initial_5ka_ru.bin"|/opt/zapret/files/fake/quic_initial_5ka_ru.bin|g' "$out" sed -i 's|"%BIN%quic_initial_rutube_ru.bin"|/opt/zapret/files/fake/quic_initial_rutube_ru.bin|g' "$out" sed -i 's|\^!|/opt/zapret/files/fake/tls_clienthello_www_google_com.bin|g' "$out" sed -i "s|%GameFilterTCP%|$PORTS_TCP|g" "$out" sed -i "s|%GameFilterUDP%|$PORTS_UDP|g" "$out" sed -i 's/[[:space:]]\+$//' "$out" sed -i '/^[[:space:]]*$/d' "$out" sed -i '/^--new$/ { N; /^--new\n$/d; }' "$out" rm -rf "$tmp" "$zip" do_add_fake_flow echo "==> Готово, стратегий: $(grep -c '^#' "$out")" } strategy_list_flowseal() { local f; f="$(_flowseal_file)" if [ ! -s "$f" ] || [ "$1" = refresh ]; then job_start flowseal_download do_flowseal_download return fi printf '{"items":[%s]}\n' "$( grep '^#' "$f" | sed 's/^#//' | awk "$ZM_JESC_AWK"'{ v = jesc($0) printf "%s{\"id\":\"%s\",\"label\":\"%s\"}", (NR>1?",":""), v, v }' )" } strategy_set_flowseal() { local name="$1" f block q f="$(_flowseal_file)" [ -s "$f" ] || { echo '{"error":"список не загружен — сначала обновите"}'; return 1; } [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } block=$(awk -v n="#$name" '$0==n{flag=1; print; next} /^#/ && flag{exit} flag{print}' "$f") [ -z "$block" ] && { echo '{"error":"стратегия не найдена"}'; return 1; } _udp443_on && q=1 rm -f "$YV_OFF_FLAG" "$DV_OFF_FLAG" _game_xtreme_undo_opts _remove_ports_if_present NFQWS_PORTS_UDP "$PORTS_UDP" _remove_ports_if_present NFQWS_PORTS_TCP "$PORTS_TCP" sed -i '/^# ZMFS:/d' "$CONF" { printf '# ZMFS:%s\n' "$name"; cat "$CONF"; } > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" sed -i "/option NFQWS_OPT '/,\$d" "$CONF" { echo " option NFQWS_OPT '"; echo "$block"; echo "'"; } >> "$CONF" if ! grep -q "option NFQWS_PORTS_UDP.*19294-19344,50000-50100" "$CONF"; then sed -i "/^[[:space:]]*option NFQWS_PORTS_UDP '/s/'\$/,19294-19344,50000-50100'/" "$CONF" fi if ! grep -q "option NFQWS_PORTS_TCP.*2053,2083,2087,2096,8443" "$CONF"; then sed -i "/^[[:space:]]*option NFQWS_PORTS_TCP '/s/'\$/,2053,2083,2087,2096,8443'/" "$CONF" fi _add_gp_domains _refresh_exclude_file sed -i '/--new/{N;/--filter-tcp=2802/{s/--new/#Gv0\n--new/;};}' "$CONF" if printf '%s\n' "$block" | grep -qxF -e "--filter-udp=$PORTS_UDP" -e "--filter-tcp=$PORTS_TCP"; then _add_ports_if_missing NFQWS_PORTS_UDP "$PORTS_UDP" _add_ports_if_missing NFQWS_PORTS_TCP "$PORTS_TCP" fi [ -n "$q" ] && _udp443_add zapret_restart printf '{"ok":true,"strategy":"%s","ts_warning":%s}\n' "$(esc "$name")" "$(_ts_warning)" } _yv_file() { echo "$JOBS_DIR/youtube_strategies.txt"; } do_yv_download() { _ensure_deps local out; out="$(_yv_file)" echo "==> Скачиваем список стратегий для YouTube" curl -fsSL --connect-timeout 8 --max-time 15 "$STR_URL" -o "$out" || { echo "ОШИБКА скачивания"; return 1; } echo "==> Готово, стратегий: $(grep -c '^#Yv' "$out")" } strategy_list_youtube() { local f; f="$(_yv_file)" if [ ! -s "$f" ] || [ "$1" = refresh ]; then job_start youtube_download do_yv_download return fi printf '{"items":[%s]}\n' "$( grep -oE '^#Yv[0-9]+' "$f" | sed 's/^#//' | awk '{ printf "%s{\"id\":\"%s\"}", (NR>1?",":""), $0 }' )" } _yv_block_remove() { _nfq_drop_profiles "$YV_RX" "" '^--filter-udp=443$'; } strategy_set_youtube() { local name="$1" f selected if [ "$name" = off ]; then [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } _yv_block_remove case $? in 0) mkdir -p "$(dirname "$YV_OFF_FLAG")"; touch "$YV_OFF_FLAG"; zapret_restart; printf '{"ok":true,"strategy":"off","removed":true}\n' ;; 3) mkdir -p "$(dirname "$YV_OFF_FLAG")"; touch "$YV_OFF_FLAG"; printf '{"ok":true,"strategy":"off","removed":false}\n' ;; *) echo '{"error":"блок стратегий Zapret (NFQWS_OPT) записан не так, как его пишет панель, — YouTube-часть не тронута"}'; return 1 ;; esac return 0 fi f="$(_yv_file)" [ -s "$f" ] || { echo '{"error":"список не загружен — сначала обновите"}'; return 1; } [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } selected="#$name" grep -qxF "$selected" "$f" || { echo '{"error":"стратегия не найдена"}'; return 1; } rm -f "$YV_OFF_FLAG" local saved="$JOBS_DIR/yv_saved" newtmp="$JOBS_DIR/yv_new" finaltmp="$JOBS_DIR/yv_final" oldtmp="$JOBS_DIR/yv_old" local awk_strip="$JOBS_DIR/yv_strip.awk" awk_insert="$JOBS_DIR/yv_insert.awk" awk_dedup="$JOBS_DIR/yv_dedup.awk" local flag=0 : > "$saved" while IFS= read -r line; do [ "$line" = "$selected" ] && flag=1 && continue case "$line" in \#Yv[0-9]*) flag=0 ;; esac [ "$flag" -eq 1 ] && printf '%s\n' "$line" >> "$saved" done < "$f" local awk_cut="$JOBS_DIR/yv_cut.awk" cat > "$awk_cut" << 'AWK_CUT_EOF' /^[ \t]*option NFQWS_OPT '$/ { flag = 1 } flag { print } AWK_CUT_EOF awk -f "$awk_cut" "$CONF" > "$oldtmp" rm -f "$awk_cut" sed -i "/^[[:space:]]*option NFQWS_OPT '/,\$d" "$CONF" sed -i "/^[[:space:]]*#Yv[0-9]\+/d" "$oldtmp" cat > "$awk_strip" << 'AWK_STRIP_EOF' { if (skip) { if ($0 == "--new" || $0 ~ /^[ \t]*'$/) { skip = 0; next } if ($0 ~ /^[ \t]*$/) next next } if ($0 == "--filter-tcp=443") { getline n if (n == "--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt") { skip = 1; next } else { print $0; print n; next } } if ($0 == "--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt") has_google = 1 if ($0 ~ /^[ \t]*#Yv/) next print } AWK_STRIP_EOF awk -f "$awk_strip" "$oldtmp" > "$newtmp" cat > "$awk_insert" << 'AWK_INSERT_EOF' BEGIN { inserted = 0; has_google = 0 } $0 == "--hostlist=/opt/zapret/ipset/zapret-hosts-google.txt" { has_google = 1 } $0 == "--new" && !inserted { while ((getline l < savedfile) > 0) if (l !~ /^[ \t]*$/) print l print "--new" inserted = 1 next } $0 ~ /^[ \t]*option NFQWS_OPT '$/ && !has_google && !inserted { print print sel while ((getline l < savedfile) > 0) if (l !~ /^[ \t]*$/) print l print "--new" inserted = 1 next } { print } AWK_INSERT_EOF awk -v sel="$selected" -v savedfile="$saved" -f "$awk_insert" "$newtmp" > "$finaltmp" cat "$finaltmp" >> "$CONF" cat > "$awk_dedup" << 'AWK_DEDUP_EOF' { if ($0 == "--new") { if (prev != "--new") print } else print prev = $0 } AWK_DEDUP_EOF awk -f "$awk_dedup" "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" grep -qE "^[ \t]*'[ \t]*\$" "$CONF" || echo "'" >> "$CONF" _nfq_normalize _add_gp_domains zapret_restart rm -f "$saved" "$newtmp" "$finaltmp" "$oldtmp" "$awk_strip" "$awk_insert" "$awk_dedup" printf '{"ok":true,"strategy":"%s","ts_warning":%s}\n' "$(esc "$name")" "$(_ts_warning)" } Dv1() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=652" "--dpi-desync-split-pos=2" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } Dv2() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=ts" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } Dv3() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake" "--dpi-desync-repeats=6" "--dpi-desync-fooling=ts" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=none"; } Dv4() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=652" "--dpi-desync-split-pos=2" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } Dv5() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multisplit" "--dpi-desync-repeats=6" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=1000" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } Dv6() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } Dv7() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=multisplit" "--dpi-desync-split-pos=2,sniext+1" "--dpi-desync-split-seqovl=679" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } Dv8() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake" "--dpi-desync-fake-tls-mod=none" "--dpi-desync-repeats=6" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=2"; } Dv9() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,fakedsplit" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=2" "--dpi-desync-repeats=8" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } Dv10() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=10000000" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } Dv11() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multisplit" "--dpi-desync-split-seqovl=681" "--dpi-desync-split-pos=1" "--dpi-desync-fooling=ts" "--dpi-desync-repeats=8" "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } Dv12() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake" "--dpi-desync-repeats=6" "--dpi-desync-fooling=badseq" "--dpi-desync-badseq-increment=2" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } Dv13() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake" "--dpi-desync-repeats=6" "--dpi-desync-fooling=ts" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } Dv14() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,fakedsplit" "--dpi-desync-repeats=6" "--dpi-desync-fooling=ts" "--dpi-desync-fakedsplit-pattern=0x00" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin"; } Dv15() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,multidisorder" "--dpi-desync-split-pos=1,midsld" "--dpi-desync-repeats=11" "--dpi-desync-fooling=badseq" "--dpi-desync-fake-tls=0x00000000" "--dpi-desync-fake-tls=/opt/zapret/files/fake/tls_clienthello_www_google_com.bin" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com"; } Dv16() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=fake,hostfakesplit" "--dpi-desync-fake-tls-mod=rnd,dupsid,sni=www.google.com" "--dpi-desync-hostfakesplit-mod=host=www.google.com,altorder=1" "--dpi-desync-fooling=ts"; } Dv17() { printf '%s\n' "--filter-tcp=2053,2083,2087,2096,8443" "--hostlist-domains=discord.media" "--dpi-desync=hostfakesplit" "--dpi-desync-repeats=4" "--dpi-desync-fooling=ts" "--dpi-desync-hostfakesplit-mod=host=www.google.com"; } _discord_active() { [ -f "$CONF" ] || return 1 grep -qx -- '--filter-l7=discord,stun' "$CONF" || grep -qE '^[[:space:]]*--filter-tcp=2053,2083,2087,2096,8443$' "$CONF" } discord_status() { local dv="" fake="" active=false if [ -f "$CONF" ]; then dv=$(grep -oE '^#[[:space:]]*Dv[0-9]+' "$CONF" | head -n1 | sed 's/^#[[:space:]]*//') fake=$(grep -m1 -- '--dpi-desync-fake-discord=' "$CONF" | sed 's|.*fake/||') _discord_active && active=true fi printf '{"current":"%s","current_fake":"%s","active":%s,"available":["Dv1","Dv2","Dv3","Dv4","Dv5","Dv6","Dv7","Dv8","Dv9","Dv10","Dv11","Dv12","Dv13","Dv14","Dv15","Dv16","Dv17"]}\n' "$(esc "$dv")" "$(esc "$fake")" "$active" } discord_off() { [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } _nfq_drop_profiles "$DISCORD_RX" case $? in 0) sed -i -e "/^[[:space:]]*option NFQWS_PORTS_UDP '/s/,19294-19344,50000-50100//" \ -e "/^[[:space:]]*option NFQWS_PORTS_TCP '/s/,2053,2083,2087,2096,8443//" "$CONF" mkdir -p "$(dirname "$DV_OFF_FLAG")"; touch "$DV_OFF_FLAG" zapret_restart printf '{"ok":true,"dv":"off","removed":true}\n' ;; 3) mkdir -p "$(dirname "$DV_OFF_FLAG")"; touch "$DV_OFF_FLAG" printf '{"ok":true,"dv":"off","removed":false}\n' ;; *) echo '{"error":"блок стратегий Zapret (NFQWS_OPT) записан не так, как его пишет панель, — Discord-часть не тронута"}'; return 1 ;; esac } discord_set_dv() { local num="$1" strat [ "$num" = off ] && { discord_off; return; } echo "$num" | grep -qE '^(1[0-7]|[1-9])$' || { echo '{"error":"некорректный номер Dv"}'; return 1; } [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } strat="$(Dv"$num")" if ! grep -qx -- '--filter-l7=discord,stun' "$CONF" || ! grep -q -E '^[[:space:]]*--filter-tcp=2053,2083,2087,2096,8443' "$CONF"; then _nfq_drop_profiles "$DISCORD_RX" case $? in 0|3) ;; *) echo '{"error":"блок стратегий Zapret (NFQWS_OPT) записан не так, как его пишет панель, — Discord-часть не добавлена"}'; return 1 ;; esac rm -f "$DV_OFF_FLAG" _discord_str_add grep -q -E '^[[:space:]]*--filter-tcp=2053,2083,2087,2096,8443' "$CONF" || { echo '{"error":"не удалось добавить блок Discord — сначала выберите основную стратегию"}'; return 1; } fi rm -f "$DV_OFF_FLAG" local start end line start=$(grep -n -E '^[[:space:]]*--filter-tcp=2053,2083,2087,2096,8443' "$CONF" | head -n1 | cut -d: -f1) end=$(tail -n +"$start" "$CONF" | grep -n -m1 -E '^--new$|^#|^'"'"'$' | cut -d: -f1) end=$((start + end - 1)) sed -i "${start},$((end - 1))d" "$CONF" line=$start echo "$strat" | while IFS= read -r l; do sed -i "${line}i$l" "$CONF"; line=$((line + 1)); done if grep -q -E '^#[[:space:]]*Dv' "$CONF"; then sed -i "s/^#[[:space:]]*Dv[0-9]\+/#Dv$num/" "$CONF" else sed -i "${start}i#Dv$num" "$CONF" fi zapret_restart printf '{"ok":true,"dv":"Dv%s"}\n' "$num" } discord_set_fake() { local file="$1" case "$file" in stun.bin|stun2.bin|quic_initial_4pda_to.bin|quic_initial_tencent_com.bin|tls_clienthello_sochi_park.bin|quic_initial_www_google_com.bin|quic_initial_steamcommunity_com.bin|quic_initial_5ka_ru.bin|quic_initial_rutube_ru.bin) ;; *) echo '{"error":"неизвестный fake-файл"}'; return 1 ;; esac [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } grep -q -- "--filter-l7=discord,stun" "$CONF" || { echo '{"error":"стратегия Discord выключена — сначала выберите Dv"}'; return 1; } awk -v new="$file" '{ if ($0 == "--filter-l7=discord,stun") { print; getline; print; if ($0 == "--dpi-desync=fake") { getline a; getline b; if (a ~ /^--dpi-desync-fake-discord=/) { print "--dpi-desync-fake-discord=/opt/zapret/files/fake/" new print "--dpi-desync-fake-stun=/opt/zapret/files/fake/" new } else { print a; print b } } next } print }' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" zapret_restart printf '{"ok":true,"fake":"%s"}\n' "$(esc "$file")" } _hosts_block() { case "$1" in nalog) printf '%s\n' \ "#Nalog" \ "213.24.64.175 lkfl2.nalog.ru" \ "213.24.64.181 lknpd.nalog.ru" ;; ntc) printf '%s\n' \ "#ntc.party" \ "130.255.77.28 ntc.party" ;; instagram) printf '%s\n' \ "#Instagram&Facebook" \ "57.144.222.34 instagram.com www.instagram.com" \ "157.240.9.174 instagram.com www.instagram.com" \ "157.240.245.174 instagram.com www.instagram.com b.i.instagram.com z-p42-chat-e2ee-ig.facebook.com help.instagram.com" \ "157.240.205.174 instagram.com www.instagram.com" \ "57.144.244.192 static.cdninstagram.com graph.instagram.com i.instagram.com api.instagram.com edge-chat.instagram.com" \ "31.13.66.63 scontent.cdninstagram.com scontent-hel3-1.cdninstagram.com" \ "57.144.244.1 facebook.com www.facebook.com fb.com fbsbx.com" \ "57.144.244.128 static.xx.fbcdn.net scontent.xx.fbcdn.net" \ "31.13.67.20 scontent-hel3-1.xx.fbcdn.net" ;; librusec) printf '%s\n' \ "#lib.rus.ec" \ "185.39.18.98 lib.rus.ec www.lib.rus.ec" ;; ai) printf '%s\n' \ "#Gemini" \ "45.155.204.190 gemini.google.com" \ "#Grok" \ "45.155.204.190 grok.com accounts.x.ai assets.grok.com" \ "#OpenAI" \ "45.155.204.190 chatgpt.com ab.chatgpt.com auth.openai.com auth0.openai.com platform.openai.com cdn.oaistatic.com" \ "45.155.204.190 tcr9i.chat.openai.com webrtc.chatgpt.com android.chat.openai.com api.openai.com operator.chatgpt.com" \ "45.155.204.190 sora.chatgpt.com sora.com videos.openai.com ios.chat.openai.com cdn.auth0.com files.oaiusercontent.com" \ "#Microsoft" \ "45.155.204.190 copilot.microsoft.com sydney.bing.com edgeservices.bing.com rewards.bing.com" \ "45.155.204.190 xsts.auth.xboxlive.com xgpuwebf2p.gssv-play-prod.xboxlive.com xgpuweb.gssv-play-prod.xboxlive.com" \ "#ElevenLabs" \ "45.155.204.190 elevenlabs.io api.us.elevenlabs.io elevenreader.io api.elevenlabs.io help.elevenlabs.io" \ "#DeepL" \ "45.155.204.190 deepl.com www.deepl.com www2.deepl.com login-wall.deepl.com w.deepl.com dict.deepl.com ita-free.www.deepl.com" \ "45.155.204.190 write-free.www.deepl.com experimentation.deepl.com experimentation-grpc.deepl.com ita-free.app.deepl.com" \ "45.155.204.190 ott.deepl.com api-free.deepl.com backend.deepl.com clearance.deepl.com errortracking.deepl.com" \ "45.155.204.190 oneshot-free.www.deepl.com checkout.www.deepl.com gtm.deepl.com auth.deepl.com shield.deepl.com" \ "#Claude" \ "45.155.204.190 claude.ai console.anthropic.com api.anthropic.com" \ "#Trae.ai" \ "45.155.204.190 trae-api-sg.mchost.guru api.trae.ai api-sg-central.trae.ai api16-normal-alisg.mchost.guru" \ "#Windsurf" \ "45.155.204.190 windsurf.com codeium.com server.codeium.com web-backend.codeium.com marketplace.windsurf.com" \ "45.155.204.190 unleash.codeium.com inference.codeium.com windsurf-stable.codeium.com" \ "144.31.14.104 windsurf-telemetry.codeium.com" \ "#Manus" \ "45.155.204.190 manus.im api.manus.im" \ "#Notion" \ "45.155.204.190 www.notion.so calendar.notion.so" \ "#AIStudio" \ "45.155.204.190 aistudio.google.com generativelanguage.googleapis.com aitestkitchen.withgoogle.com aisandbox-pa.googleapis.com xsts.auth.xboxlive.com" \ "45.155.204.190 webchannel-alkalimakersuite-pa.clients6.google.com alkalimakersuite-pa.clients6.google.com assistant-s3-pa.googleapis.com" \ "45.155.204.190 proactivebackend-pa.googleapis.com robinfrontend-pa.googleapis.com o.pki.goog labs.google labs.google.com notebooklm.google" \ "45.155.204.190 notebooklm.google.com jules.google.com stitch.withgoogle.com gemini.google.com copilot.microsoft.com edgeservices.bing.com" \ "45.155.204.190 rewards.bing.com sydney.bing.com xboxdesignlab.xbox.com xgpuweb.gssv-play-prod.xboxlive.com xgpuwebf2p.gssv-play-prod.xboxlive.com" ;; twitch) printf '%s\n' \ "#Twitch" \ "45.155.204.190 usher.ttvnw.net gql.twitch.tv" ;; telegram) printf '%s\n' \ "#TelegramWeb" \ "149.154.167.220 core.telegram.org api.telegram.org flora.web.telegram.org kws1-1.web.telegram.org kws1.web.telegram.org kws2-1.web.telegram.org kws2.web.telegram.org kws4-1.web.telegram.org" \ "149.154.167.220 kws4.web.telegram.org kws5-1.web.telegram.org kws5.web.telegram.org pluto-1.web.telegram.org pluto.web.telegram.org td.telegram.org telegram.dog" \ "149.154.167.220 telegram.me telegram.org telegram.space telesco.pe venus.web.telegram.org web.telegram.org zws1-1.web.telegram.org zws1.web.telegram.org" \ "149.154.167.220 tg.dev t.me zws2-1.web.telegram.org zws2.web.telegram.org zws4-1.web.telegram.org zws5-1.web.telegram.org zws5.web.telegram.org" ;; spotify) printf '%s\n' \ "#Spotify" \ "45.155.204.190 api.spotify.com login5.spotify.com encore.scdn.co gew1-spclient.spotify.com spclient.wg.spotify.com" \ "45.155.204.190 api-partner.spotify.com aet.spotify.com www.spotify.com accounts.spotify.com open.spotify.com" \ "45.155.204.190 accounts.scdn.co gew1-dealer.spotify.com open-exp.spotifycdn.com www-growth.scdn.co" ;; 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" \ "103.27.157.38 game-assets.clashofclans.com game-assets.clashroyaleapp.com security.id.supercell.com store.supercell.com" \ "31.25.239.132 accounts.supercell.com cdn.id.supercell.com clashofclans.inbox.supercell.com game-assets.brawlstarsgame.com" \ "31.25.239.132 game-assets.clashofclans.com game-assets.clashroyaleapp.com game.boombeachgame.com game.mocogame.com security.id.supercell.com store.supercell.com" \ "185.246.223.127 game.brawlstarsgame.com" \ "62.133.62.97 game.clashroyaleapp.com" \ "193.23.209.189 gamea.clashofclans.com" \ "108.61.167.26 game.squadbustersgame.com" ;; githubraw) printf '%s\n' \ "#githubusercontent.com" \ "146.75.22.132 objects.githubusercontent.com release-assets.githubusercontent.com raw.githubusercontent.com private-user-images.githubusercontent.com gist.githubusercontent.com" \ "146.75.22.132 camo.githubusercontent.com avatars.githubusercontent.com avatars0.githubusercontent.com avatars1.githubusercontent.com avatars2.githubusercontent.com avatars3.githubusercontent.com avatars4.githubusercontent.com avatars5.githubusercontent.com" ;; github) printf '%s\n' \ "#github.com" \ "140.82.114.3 github.com" \ "185.199.110.154 github.githubassets.com" \ "185.199.110.133 camo.githubassets.com" ;; 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" ;; rutracker) printf '%s\n' \ "#Rutracker" \ "172.67.182.196 rutracker.org" ;; tapeop) printf '%s\n' \ "#tapeop.dev" \ "216.24.57.251 www.tapeop.dev tapeop.dev" \ "216.24.57.3 www.tapeop.dev tapeop.dev" ;; *) return 1 ;; esac } _hosts_block_status() { local block="$1" content content="$(_hosts_block "$block")" || return 1 [ -f "$HOSTS_FILE" ] || { echo false; return; } printf '%s\n' "$content" | awk -v hf="$HOSTS_FILE" 'function n(s) { gsub(/\r/, "", s); gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return s } BEGIN { while ((getline l < hf) > 0) have[n(l)] = 1; close(hf) } { k = n($0); if (k == "" || (k in seen)) next; seen[k] = 1; total++; if (k in have) found++ } END { print (total > 0 && found == total) ? "true" : "false" }' } _hosts_drop_lines() { local tmp="$HOSTS_FILE.zmtmp" list="$HOSTS_FILE.zmdrop" cat > "$list" awk -v list="$list" 'function n(s) { gsub(/\r/, "", s); gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return s } BEGIN { while ((getline l < list) > 0) { k = n(l); if (k != "") drop[k] = 1 } close(list) } !(n($0) in drop)' "$HOSTS_FILE" > "$tmp" && cat "$tmp" > "$HOSTS_FILE" rm -f "$tmp" "$list" } _hosts_has_line() { awk -v want="$1" 'function n(s) { gsub(/\r/, "", s); gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s); return s } BEGIN { want = n(want) } n($0) == want { f = 1; exit } END { exit !f }' "$HOSTS_FILE" 2>/dev/null } hosts_status() { local blocks="nalog ntc instagram librusec ai twitch telegram spotify spotifyext rutor rutracker scell githubraw github tapeop" b first=1 local geohide="" if grep -q '^### geohide.ru: hosts file' "$HOSTS_FILE" 2>/dev/null; then if grep -q '^# Регион серверов: US$' "$HOSTS_FILE" 2>/dev/null; then geohide="us" elif grep -q '^# Регион серверов: EU$' "$HOSTS_FILE" 2>/dev/null; then geohide="eu" elif grep -q '^# Регион серверов: RU$' "$HOSTS_FILE" 2>/dev/null; then geohide="ru" else geohide="unknown"; fi fi printf '{"geohide":"%s","items":[' "$(esc "$geohide")" for b in $blocks; do [ "$first" -eq 1 ] || printf ',' first=0 printf '{"id":"%s","enabled":%s}' "$b" "$(_hosts_block_status "$b")" done printf ']}\n' } hosts_toggle() { local block="$1" content line enabled content="$(_hosts_block "$block")" || { echo '{"error":"неизвестный блок"}'; return 1; } enabled="$(_hosts_block_status "$block")" if [ "$enabled" = "true" ]; then printf '%s\n' "$content" | _hosts_drop_lines else while IFS= read -r line; do [ -z "$line" ] && continue; _hosts_has_line "$line" || echo "$line" >> "$HOSTS_FILE"; done <<-EOF $content EOF fi /etc/init.d/dnsmasq restart >/dev/null 2>&1 printf '{"ok":true,"block":"%s","enabled":%s}\n' "$(esc "$block")" "$([ "$enabled" = "true" ] && echo false || echo true)" } hosts_replace_geohide() { local region="$1" url tmp case "$region" in ru) url="${GH_RAW}/Internet-Helper/GeoHideDNS/refs/heads/main/hosts/hosts" ;; eu) url="${GH_RAW}/Internet-Helper/GeoHideDNS/refs/heads/main/hosts/eu/hosts" ;; us) url="${GH_RAW}/Internet-Helper/GeoHideDNS/refs/heads/main/hosts/us/hosts" ;; *) echo '{"error":"неизвестный регион"}'; return 1 ;; esac tmp="$JOBS_DIR/geohide_hosts.tmp" wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$url" >/dev/null 2>&1 if [ ! -s "$tmp" ]; then rm -f "$tmp" echo '{"error":"не удалось скачать GeoHide hosts"}' return 1 fi mv "$tmp" "$HOSTS_FILE" /etc/init.d/dnsmasq restart >/dev/null 2>&1 printf '{"ok":true,"region":"%s"}\n' "$region" } hosts_reset() { printf '%s\n' "127.0.0.1 localhost" "" "::1 localhost ip6-localhost ip6-loopback" "ff02::1 ip6-allnodes" "ff02::2 ip6-allrouters" > "$HOSTS_FILE" /etc/init.d/dnsmasq restart >/dev/null 2>&1 printf '{"ok":true}\n' } HOSTS_EDIT_MAX=262144 hosts_file_get() { local sz n [ -f "$HOSTS_FILE" ] || { echo '{"error":"файл hosts не найден"}'; return 1; } sz="$(wc -c < "$HOSTS_FILE" | tr -d ' ')" if [ "${sz:-0}" -gt "$HOSTS_EDIT_MAX" ]; then n="$(wc -l < "$HOSTS_FILE" | tr -d ' ')" printf '{"too_big":true,"size":%s,"lines":%s}\n' "$sz" "${n:-0}" return 0 fi printf '{"content":"%s"}\n' "$(esc_ml "$(cat "$HOSTS_FILE")")" } hosts_file_set() { local content="$1" [ -n "$content" ] || { echo '{"error":"пустой файл — сохранение отменено"}'; return 1; } [ "$(printf '%s' "$content" | wc -c)" -gt "$HOSTS_EDIT_MAX" ] && { echo '{"error":"файл слишком большой для редактора"}'; return 1; } cp "$HOSTS_FILE" "$HOSTS_FILE.bak" 2>/dev/null printf '%s' "$content" > "$HOSTS_FILE" /etc/init.d/dnsmasq restart >/dev/null 2>&1 printf '{"ok":true}\n' } _ports_get() { sed -n "s/^[[:space:]]*option $1 '\([^']*\)'.*/\1/p" "$CONF" | head -n1; } _ports_put() { sed -i "s|^\([[:space:]]*option $1 '\)[^']*'|\1$2'|" "$CONF"; } _remove_ports_if_present() { local option="$1" ports="$2" cur out="" p grep -q "^[[:space:]]*option $option '" "$CONF" || return 0 cur="$(_ports_get "$option")" for p in $(echo "$cur" | tr ',' ' '); do case ",$ports," in *",$p,"*) continue ;; esac out="$out${out:+,}$p" done [ "$out" = "$cur" ] || _ports_put "$option" "$out" } _add_ports_if_missing() { local option="$1" ports="$2" cur p grep -q "^[[:space:]]*option $option '" "$CONF" || return 0 cur="$(_ports_get "$option")" for p in $(echo "$ports" | tr ',' ' '); do case ",$cur," in *",$p,"*) ;; *) cur="$cur${cur:+,}$p" ;; esac done _ports_put "$option" "$cur" } strategy_TCP_common() { printf '%s\n' "--new" "--filter-tcp=$PORTS_TCP" "--dpi-desync-any-protocol=1" "--dpi-desync-cutoff=n5" \ "--dpi-desync=multisplit" "--dpi-desync-split-seqovl=582" "--dpi-desync-split-pos=1" \ "--dpi-desync-split-seqovl-pattern=/opt/zapret/files/fake/stun.bin" } strategy_Gv1() { printf '%s\n' "#Gv1" "--new" "--filter-udp=$PORTS_UDP" "--dpi-desync=fake" "--dpi-desync-cutoff=d2" \ "--dpi-desync-any-protocol=1" "--dpi-desync-fake-unknown-udp=/opt/zapret/files/fake/stun.bin" } strategy_Gv() { local n="$1" printf '%s\n' "#Gv$n" "--new" "--filter-udp=$PORTS_UDP" "--dpi-desync=fake" "--dpi-desync-repeats=10" \ "--dpi-desync-any-protocol=1" "--dpi-desync-fake-unknown-udp=/opt/zapret/files/fake/stun.bin" "--dpi-desync-cutoff=n$n" } _game_rx() { printf '%s' "^#Gv[0-9]+(Xtreme)?\$|^--filter-(udp|tcp)=($PORTS_UDP|$PORTS_TCP|80,88,444-65535)\$" } _game_active() { [ -f "$CONF" ] || return 1 sed -n "/^[[:space:]]*option NFQWS_OPT '\$/,\$p" "$CONF" | grep -qE "$(_game_rx)" } _game_xtreme_undo_opts() { local xfile="/opt/zapret/tmp/GvXtreme" old_tcp_opt old_udp_opt grep -q "^#Gv[0-9]\+Xtreme\$" "$CONF" || { rm -f "$xfile"; return 0; } if [ -f "$xfile" ]; then old_tcp_opt=$(sed -n '4p' "$xfile") old_udp_opt=$(sed -n '5p' "$xfile") [ -n "$old_tcp_opt" ] && sed -i "s|^[[:space:]]*option NFQWS_PORTS_TCP .*|$old_tcp_opt|" "$CONF" [ -n "$old_udp_opt" ] && sed -i "s|^[[:space:]]*option NFQWS_PORTS_UDP .*|$old_udp_opt|" "$CONF" fi rm -f "$xfile" } game_status() { local current="" xtreme="false" fake="" active="false" if [ -f "$CONF" ]; then current=$(grep -oE '^#Gv[1-4](Xtreme)?$' "$CONF" | head -n1 | sed 's/^#//; s/Xtreme$//') grep -q "^#Gv[0-9]\+Xtreme\$" "$CONF" && xtreme="true" fake=$(grep -m1 -- '--dpi-desync-fake-unknown-udp=' "$CONF" | sed 's|.*fake/||') _game_active && active="true" fi printf '{"current":"%s","xtreme":%s,"fake":"%s","active":%s}\n' "$(esc "$current")" "$xtreme" "$(esc "$fake")" "$active" } game_set() { local choice="$1" current="" rc echo "$choice" | grep -qE '^([1-4]|off)$' || { echo '{"error":"некорректный номер Gv"}'; return 1; } [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } current=$(grep -oE '^#Gv[1-4](Xtreme)?$' "$CONF" | head -n1 | sed 's/^#//; s/Xtreme$//') if [ "$choice" = off ]; then _game_active || { printf '{"ok":true,"game":"none","removed":false}\n'; return 0; } cp "$CONF" "$CONF.gvbak" _game_xtreme_undo_opts _nfq_drop_profiles "$(_game_rx)" rc=$? case $rc in 0|3) rm -f "$CONF.gvbak" _remove_ports_if_present NFQWS_PORTS_UDP "$PORTS_UDP" _remove_ports_if_present NFQWS_PORTS_TCP "$PORTS_TCP" zapret_restart printf '{"ok":true,"game":"none","removed":true}\n' ;; *) mv -f "$CONF.gvbak" "$CONF" echo '{"error":"блок стратегий Zapret (NFQWS_OPT) записан не так, как его пишет панель, — игровая часть не тронута"}'; return 1 ;; esac return 0 fi [ "$current" = "Gv$choice" ] && { printf '{"ok":true,"game":"Gv%s"}\n' "$choice"; return 0; } cp "$CONF" "$CONF.gvbak" _game_xtreme_undo_opts _nfq_drop_profiles "$(_game_rx)" case $? in 0|3) rm -f "$CONF.gvbak" ;; *) mv -f "$CONF.gvbak" "$CONF" echo '{"error":"блок стратегий Zapret (NFQWS_OPT) записан не так, как его пишет панель, — игровая часть не тронута"}'; return 1 ;; esac local last_quote strat last_quote=$(grep -n "^[[:space:]]*'[[:space:]]*\$" "$CONF" | tail -n1 | cut -d: -f1) [ -n "$last_quote" ] && sed -i "${last_quote},\$d" "$CONF" if [ "$choice" = "1" ]; then strat="$(strategy_Gv1; strategy_TCP_common)"; else strat="$(strategy_Gv "$choice"; strategy_TCP_common)"; fi echo "$strat" | sed '/^$/d' >> "$CONF" echo "'" >> "$CONF" _add_ports_if_missing NFQWS_PORTS_UDP "$PORTS_UDP" _add_ports_if_missing NFQWS_PORTS_TCP "$PORTS_TCP" zapret_restart printf '{"ok":true,"game":"Gv%s"}\n' "$choice" } game_set_fake() { local file="$1" case "$file" in stun.bin|stun2.bin|quic_initial_4pda_to.bin|quic_initial_tencent_com.bin|tls_clienthello_sochi_park.bin|quic_initial_www_google_com.bin|quic_initial_steamcommunity_com.bin|quic_initial_5ka_ru.bin|quic_initial_rutube_ru.bin) ;; *) echo '{"error":"неизвестный fake-файл"}'; return 1 ;; esac [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } grep -q -- '--dpi-desync-fake-unknown-udp=' "$CONF" || { echo '{"error":"игровая стратегия не установлена"}'; return 1; } awk -v new="$file" 'BEGIN{done=0} !done && /--dpi-desync-fake-unknown-udp=/{sub(/\/opt\/zapret\/files\/fake\/[^ ]+/, "/opt/zapret/files/fake/" new); done=1} {print}' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" zapret_restart printf '{"ok":true,"fake":"%s"}\n' "$(esc "$file")" } game_toggle_xtreme() { [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } local xfile="/opt/zapret/tmp/GvXtreme" local xports="80,88,444-65535" local xnfqws="80,88,443-65535" mkdir -p "$(dirname "$xfile")" if grep -q "^#Gv[0-9]\+Xtreme\$" "$CONF"; then [ -f "$xfile" ] || { echo '{"error":"файл восстановления отсутствует"}'; return 1; } local old_gv old_udp old_tcp old_tcp_opt old_udp_opt old_gv=$(sed -n '1p' "$xfile") old_udp=$(sed -n '2p' "$xfile") old_tcp=$(sed -n '3p' "$xfile") old_tcp_opt=$(sed -n '4p' "$xfile") old_udp_opt=$(sed -n '5p' "$xfile") awk -v gv="$old_gv" -v udp="$old_udp" -v tcp="$old_tcp" ' /^#Gv[0-9]+Xtreme$/ { print gv; restore = 1; next } restore && /^--filter-udp=/ { print udp; next } restore && /^--filter-tcp=/ { print tcp; restore = 0; next } { print } ' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" [ -n "$old_tcp_opt" ] && sed -i "s|^[[:space:]]*option NFQWS_PORTS_TCP .*|$old_tcp_opt|" "$CONF" [ -n "$old_udp_opt" ] && sed -i "s|^[[:space:]]*option NFQWS_PORTS_UDP .*|$old_udp_opt|" "$CONF" rm -f "$xfile" zapret_restart printf '{"ok":true,"xtreme":false}\n' return fi grep -q "^#Gv[0-9]\+\$" "$CONF" || { echo '{"error":"игровая стратегия не установлена"}'; return 1; } awk ' /^#Gv[0-9]+$/ { gv = $0; found = 1; next } found && /^--filter-udp=/ { udp = $0 } found && /^--filter-tcp=/ { tcp = $0 } /^[ \t]*option NFQWS_PORTS_TCP / { tcp_option = $0 } /^[ \t]*option NFQWS_PORTS_UDP / { udp_option = $0 } END { print gv; print udp; print tcp; print tcp_option; print udp_option } ' "$CONF" > "$xfile" sed -i -e "s|^[[:space:]]*option NFQWS_PORTS_TCP .*| option NFQWS_PORTS_TCP '$xnfqws'|" \ -e "s|^[[:space:]]*option NFQWS_PORTS_UDP .*| option NFQWS_PORTS_UDP '$xnfqws'|" "$CONF" sed -i "s/^#\(Gv[0-9]\+\)\$/#\1Xtreme/" "$CONF" awk -v ports="$xports" ' /^#Gv[0-9]+Xtreme$/ { gv = 1; print; next } gv && /^--filter-udp=/ { print "--filter-udp=" ports; next } gv && /^--filter-tcp=/ { print "--filter-tcp=" ports; gv = 0; next } { print } ' "$CONF" > "$CONF.tmp" && mv "$CONF.tmp" "$CONF" zapret_restart printf '{"ok":true,"xtreme":true}\n' } _quic_blocked() { uci show firewall 2>/dev/null | grep -q "name='Block_UDP_80'" \ && uci show firewall 2>/dev/null | grep -q "name='Block_UDP_443'" \ && echo true || echo false } _ipv6_enabled_in_zapret() { [ -f "$CONF" ] && grep -q "option DISABLE_IPV6 '0'" "$CONF" && echo true || echo false } _flow_offloading_fix_applied() { grep -q 'ct original packets ge 30 flow offload @ft;' /usr/share/firewall4/templates/ruleset.uc 2>/dev/null \ && echo true || echo false } _expert_mode() { [ -f "$EXPERT_MODE_FILE" ] && echo true || echo false; } system_status() { printf '{"quic_blocked":%s,"ipv6_enabled":%s,"flow_offloading_fix":%s,"expert_mode":%s,"pkg":"%s"}\n' \ "$(_quic_blocked)" "$(_ipv6_enabled_in_zapret)" "$(_flow_offloading_fix_applied)" "$(_expert_mode)" "$PKG" } system_check_connectivity() { local t4 t6 t4=$(ping -4 -c1 -W2 google.com 2>/dev/null | grep 'time=' | sed -E 's/.*time=([0-9.]+).*/\1/') t6=$(ping -6 -c1 -W2 google.com 2>/dev/null | grep 'time=' | sed -E 's/.*time=([0-9.]+).*/\1/') printf '{"ipv4_ok":%s,"ipv4_ms":"%s","ipv6_ok":%s,"ipv6_ms":"%s"}\n' \ "$([ -n "$t4" ] && echo true || echo false)" "$(esc "$t4")" \ "$([ -n "$t6" ] && echo true || echo false)" "$(esc "$t6")" } system_toggle_quic() { if [ "$(_quic_blocked)" = "true" ]; then local rule idx for rule in Block_UDP_80 Block_UDP_443; do while true; do idx=$(uci show firewall | grep "name='$rule'" | cut -d. -f2 | cut -d= -f1 | head -n1) [ -z "$idx" ] && break uci delete firewall.$idx >/dev/null 2>&1 done done uci commit firewall >/dev/null 2>&1 /etc/init.d/firewall restart >/dev/null 2>&1 printf '{"ok":true,"quic_blocked":false}\n' return fi local lz wz lz="$(_zm_lan_zone)"; wz="$(_zm_wan_zone)" uci add firewall rule >/dev/null 2>&1 uci set firewall.@rule[-1].name='Block_UDP_80' >/dev/null 2>&1 uci add_list firewall.@rule[-1].proto='udp' >/dev/null 2>&1 uci set "firewall.@rule[-1].src=$lz" >/dev/null 2>&1 uci set "firewall.@rule[-1].dest=$wz" >/dev/null 2>&1 uci set firewall.@rule[-1].dest_port='80' >/dev/null 2>&1 uci set firewall.@rule[-1].target='REJECT' >/dev/null 2>&1 uci add firewall rule >/dev/null 2>&1 uci set firewall.@rule[-1].name='Block_UDP_443' >/dev/null 2>&1 uci add_list firewall.@rule[-1].proto='udp' >/dev/null 2>&1 uci set "firewall.@rule[-1].src=$lz" >/dev/null 2>&1 uci set "firewall.@rule[-1].dest=$wz" >/dev/null 2>&1 uci set firewall.@rule[-1].dest_port='443' >/dev/null 2>&1 uci set firewall.@rule[-1].target='REJECT' >/dev/null 2>&1 uci commit firewall >/dev/null 2>&1 /etc/init.d/firewall restart >/dev/null 2>&1 printf '{"ok":true,"quic_blocked":true}\n' } system_toggle_ipv6() { [ -f "$CONF" ] || { echo '{"error":"Zapret не установлен"}'; return 1; } if grep -q "option DISABLE_IPV6 '0'" "$CONF"; then sed -i "s/option DISABLE_IPV6 '0'/option DISABLE_IPV6 '1'/" "$CONF" zapret_restart printf '{"ok":true,"ipv6_enabled":false}\n' return fi local t6 t6=$(ping -6 -c1 -W2 google.com 2>/dev/null | grep 'time=') if [ -z "$t6" ]; then echo '{"error":"IPv6 недоступен на роутере — включение не рекомендуется"}' return 1 fi sed -i "s/option DISABLE_IPV6 '1'/option DISABLE_IPV6 '0'/" "$CONF" zapret_restart printf '{"ok":true,"ipv6_enabled":true}\n' } system_toggle_flow_offloading_fix() { local template="/usr/share/firewall4/templates/ruleset.uc" [ -f "$template" ] || { echo '{"error":"шаблон firewall4 не найден"}'; return 1; } if grep -q 'ct original packets ge 30 flow offload @ft;' "$template"; then sed -i 's/meta l4proto { tcp, udp } ct original packets ge 30 flow offload @ft;/meta l4proto { tcp, udp } flow offload @ft;/' "$template" fw4 restart >/dev/null 2>&1 printf '{"ok":true,"flow_offloading_fix":false}\n' return fi sed -i 's/meta l4proto { tcp, udp } flow offload @ft;/meta l4proto { tcp, udp } ct original packets ge 30 flow offload @ft;/' "$template" fw4 restart >/dev/null 2>&1 printf '{"ok":true,"flow_offloading_fix":true}\n' } system_uninstall_panel() { local script="/tmp/zm_uninstall_panel.sh" j if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "идёт операция «$j» — дождитесь её окончания и удалите панель")" return 1 fi cat > "$script" << 'ZM_UNINSTALL_EOF' sleep 1 /opt/zapret-manager-luci/backend.sh redbtn_panel_gone >/dev/null 2>&1 rm -rf /opt/zapret-manager-luci /usr/libexec/rpcd/zapret-manager \ /usr/share/luci/menu.d/luci-app-zapret-manager.json \ /usr/share/rpcd/acl.d/luci-app-zapret-manager.json \ /www/luci-static/resources/view/zapret-manager \ /www/luci-static/resources/zapret-manager \ /www/luci-static/resources/bytetube \ /www/luci-static/resources/view/bytetube \ /tmp/zapret-manager-luci /tmp/luci-indexcache* /tmp/luci-modulecache/* \ /www/zm /www/zm-webui.html /etc/zm-warp-own.conf 2>/dev/null [ -s /etc/zm-steer/owned ] || rm -rf /usr/share/zm-redbtn if grep -q '/opt/zapret-manager-luci/backend.sh' /etc/crontabs/root 2>/dev/null; then sed -i '\#/opt/zapret-manager-luci/backend.sh#d' /etc/crontabs/root /etc/init.d/cron restart >/dev/null 2>&1 fi uci -q delete uhttpd.zmweb && uci -q commit uhttpd /etc/init.d/rpcd restart >/dev/null 2>&1 /etc/init.d/uhttpd restart >/dev/null 2>&1 rm -f "$0" ZM_UNINSTALL_EOF chmod 0755 "$script" ( sh "$script" >/dev/null 2>&1 & ) printf '{"ok":true}\n' } _confz() { if [ "$PKG" = "apk" ]; then echo "/etc/apk/repositories.d/distfeeds.list" else echo "/etc/opkg/distfeeds.conf"; fi } _mirror_name() { local f url f="$(_confz)" [ -f "$f" ] || { echo "файл не найден"; return; } url=$(head -n1 "$f") case "$url" in *mirror-03.infra.openwrt.org*) echo "Infra OpenWrt" ;; *c3sl.ufpr.br*) echo "Brazil" ;; *ustc.edu.cn*) echo "China" ;; *tetaneutral.net*) echo "France" ;; *garr.it*) echo "Italy" ;; *marwan.ma*) echo "Morocco" ;; *pixeldeck.net*) echo "USA" ;; *rwth-aachen.de*) echo "Germany" ;; *downloads.openwrt.org*) echo "default / OpenWrt" ;; *) echo "неизвестное" ;; esac } mirror_status() { printf '{"current":"%s"}\n' "$(esc "$(_mirror_name)")" } do_mirror_set() { local host="$1" f echo "==> Проверяем доступность $host" if ! wget -q --spider --timeout=5 "https://$host/releases/" >/dev/null 2>&1; then echo "ОШИБКА: зеркало недоступно" return 1 fi f="$(_confz)" sed -i "s|https://.*/releases/|https://$host/releases/|g" "$f" echo "==> Зеркало доступно, обновляем список пакетов" if ! $UPDATE >&2; then echo "ОШИБКА обновления пакетов — зеркало сброшено на default" sed -i "s|https://.*/releases/|https://downloads.openwrt.org/releases/|g" "$f" return 1 fi echo "==> Готово, зеркало переключено и работает" } mirror_set() { local id="$1" host case "$id" in infra) host="mirror-03.infra.openwrt.org" ;; brazil) host="openwrt.c3sl.ufpr.br" ;; china) host="mirrors.ustc.edu.cn/openwrt" ;; france) host="openwrt.tetaneutral.net" ;; italy) host="openwrt.mirror.garr.it/mirrors/openwrt" ;; morocco) host="mirror.marwan.ma/openwrt" ;; usa) host="openwrt.pixeldeck.net" ;; germany_rwth) host="ftp.halifax.rwth-aachen.de/openwrt" ;; default) host="downloads.openwrt.org" ;; *) echo '{"error":"неизвестное зеркало"}'; return 1 ;; esac job_start mirror_set do_mirror_set "$host" } _excl_dir() { echo "/opt/zapret/init.d/openwrt/custom.d"; } _excl_file() { echo "$(_excl_dir)/20-script.sh"; } _excl_current() { local f; f="$(_excl_file)" [ -f "$f" ] || return 0 grep -oE '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' "$f" | sort -u } _excl_enable_custom() { [ -f "$CONF" ] || return 0 if grep -q "^[[:space:]]*option DISABLE_CUSTOM " "$CONF"; then sed -i "s/^\([[:space:]]*option DISABLE_CUSTOM \).*/\1'0'/" "$CONF" else sed -i "/^config main/a\\ option DISABLE_CUSTOM '0'" "$CONF" fi } _excl_write() { local ips="$1" f f="$(_excl_file)" mkdir -p "$(_excl_dir)" if [ -n "$ips" ]; then _excl_enable_custom local formatted formatted=$(printf '%s\n' "$ips" | tr '\n' ',' | sed 's/,$//' | sed 's/,/, /g') { echo "EXCEPT_SRC='{ $formatted }'" echo "nft insert rule inet zapret postrouting_hook index 0 \\" echo " ip saddr \$EXCEPT_SRC meta mark set meta mark \\| 0x40000000" } > "$f" else : > "$f" fi } exclusions_status() { [ -f /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } local current devjson="" first=1 ip name ts mac rest aip ahw aflags amac amask adev aname current=$(_excl_current) if [ -f /tmp/dhcp.leases ]; then while read -r ts mac ip name rest; do [ -z "$ip" ] && continue echo "$ip" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || continue [ -z "$name" ] && name="*" [ "$name" = "*" ] && name="Неизвестное устройство" [ "$first" -eq 1 ] || devjson="$devjson," first=0 devjson="$devjson{\"ip\":\"$ip\",\"name\":\"$(esc "$name")\",\"excluded\":$(printf '%s\n' "$current" | grep -qx "$ip" && echo true || echo false)}" done < /tmp/dhcp.leases fi local lan_dev arp_tmp lan_dev=" $(_zm_lan_devs) " arp_tmp="$JOBS_DIR/excl_arp_tmp" rm -f "$arp_tmp" if [ -f /proc/net/arp ]; then tail -n +2 /proc/net/arp | while read -r aip ahw aflags amac amask adev; do [ -z "$aip" ] && continue case "$lan_dev" in *" $adev "*) ;; *) continue ;; esac echo "$aip" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || continue [ "$aflags" = "0x0" ] && continue printf '%s' "$devjson" | grep -q "\"ip\":\"$aip\"" && continue aname="" [ -f /tmp/dhcp.leases ] && aname=$(awk -v mac="$amac" 'tolower($2)==tolower(mac){print $4; exit}' /tmp/dhcp.leases) if [ -z "$aname" ] || [ "$aname" = "*" ]; then aname=$(logread 2>/dev/null | grep -i "DHCPACK" | grep -i "$aip" | grep -i "$amac" | tail -n1 | awk '{print $NF}') fi [ "$aname" = "$amac" ] && aname="" [ -n "$aname" ] || aname="Неизвестное устройство" printf '%s|%s\n' "$aip" "$aname" >> "$arp_tmp" done fi if [ -f "$arp_tmp" ]; then while IFS='|' read -r aip aname; do [ -z "$aip" ] && continue [ "$first" -eq 1 ] || devjson="$devjson," first=0 devjson="$devjson{\"ip\":\"$aip\",\"name\":\"$(esc "$aname")\",\"excluded\":$(printf '%s\n' "$current" | grep -qx "$aip" && echo true || echo false)}" done < "$arp_tmp" rm -f "$arp_tmp" fi local ip2 for ip2 in $current; do [ -z "$ip2" ] && continue printf '%s' "$devjson" | grep -q "\"ip\":\"$ip2\"" && continue [ "$first" -eq 1 ] || devjson="$devjson," first=0 devjson="$devjson{\"ip\":\"$ip2\",\"name\":\"Устройство offline\",\"excluded\":true}" done printf '{"devices":[%s]}\n' "$devjson" } exclusions_toggle() { local ip="$1" current new_list excluded="false" echo "$ip" | grep -qE '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || { echo '{"error":"некорректный IP-адрес"}'; return 1; } [ -f /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } current=$(_excl_current) if printf '%s\n' "$current" | grep -qx "$ip"; then new_list=$(printf '%s\n' "$current" | grep -vx "$ip") else new_list=$(printf '%s\n%s\n' "$current" "$ip" | grep -v '^$' | sort -u) excluded="true" fi _excl_write "$new_list" zapret_restart printf '{"ok":true,"ip":"%s","excluded":%s}\n' "$(esc "$ip")" "$excluded" } exclusions_clear() { [ -f /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } _excl_write "" zapret_restart printf '{"ok":true}\n' } EXCLUDE_DOMAINS_FILE="/opt/zapret/ipset/zapret-hosts-user-exclude.txt" _zl_path() { case "$1" in exclude) echo /opt/zapret/ipset/zapret-hosts-user-exclude.txt ;; user) echo /opt/zapret/ipset/zapret-hosts-user.txt ;; google) echo /opt/zapret/ipset/zapret-hosts-google.txt ;; *) return 1 ;; esac } _zl_json_text() { [ -f "$1" ] || return 0 local tab; tab="$(printf '\t')" tr -d '\r\000-\010\013\014\016-\037' < "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e "s/$tab/\\\\t/g" | awk '{ printf "%s%s", (NR > 1 ? "\\n" : ""), $0 }' } _zl_count() { local n; [ -f "$1" ] && n=$(grep -cvE '^[[:space:]]*(#.*)?$' "$1"); echo "${n:-0}"; } _zl_info() { local f; f="$(_zl_path "$1")" printf '{"id":"%s","path":"%s","exists":%s,"count":%s,"size":%s,"mtime":%s,"auto":"%s","nochange":%s}' "$1" "$f" \ "$([ -f "$f" ] && echo true || echo false)" "$(_zl_count "$f")" \ "$([ -f "$f" ] && wc -c < "$f" | tr -d ' ' || echo 0)" "$([ -f "$f" ] && date -r "$f" +%s 2>/dev/null || echo 0)" \ "$([ "$1" = exclude ] && _zx_auto_get)" "$(_nochange_in "$f" && echo true || echo false)" } ZX_AUTO_TAG="# zm-excl-auto" _zx_auto_get() { local v v="$(grep -F "$ZX_AUTO_TAG=" "$CRON_FILE" 2>/dev/null | head -n1 | sed 's/.*=//')" case "$v" in h2|h4|h6|h12) echo "$v" ;; *) echo off ;; esac } zapret_excl_auto_set() { local v="$1" spec="" case "$v" in off) ;; h2|h4|h6|h12) spec="17 */${v#h} * * *" ;; *) echo '{"error":"неизвестный интервал"}'; return 1 ;; esac [ -z "$spec" ] || [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } mkdir -p "$(dirname "$CRON_FILE")" touch "$CRON_FILE" sed -i "\\|$ZX_AUTO_TAG=|d" "$CRON_FILE" [ -n "$spec" ] && echo "$spec /opt/zapret-manager-luci/backend.sh zapret_excl_tick >/dev/null 2>&1 $ZX_AUTO_TAG=$v" >> "$CRON_FILE" /etc/init.d/cron enable >/dev/null 2>&1 /etc/init.d/cron restart >/dev/null 2>&1 printf '{"ok":true,"info":%s}\n' "$(_zl_info exclude)" } zapret_excl_tick() { local f tmp [ -x /etc/init.d/zapret ] || return 0 f="$(_zl_path exclude)"; tmp="$f.zmauto" _nochange_in "$f" && return 0 _zm_busy_job >/dev/null 2>&1 && return 0 mkdir -p "$(dirname "$f")" rm -f "$tmp" wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$EXCLUDE_URL" 2>/dev/null if [ ! -s "$tmp" ] || grep -qi ' "$tmp" || { rm -f "$tmp"; echo '{"error":"не удалось записать файл"}'; return 1; } mv -f "$tmp" "$f" chmod 644 "$f" zapret_restart printf '{"ok":true,"info":%s}\n' "$(_zl_info "$1")" } _nochange_strip() { awk '{ l = tolower($0); if (l ~ /^[ \t]*#[ \t]*nochange[ \t]*$/) next; print }' } zapret_nochange_set() { local t="${1%%:*}" st="${1#*:}" f tmp case "$st" in on|off) ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } case "$t" in strategy) [ -f "$CONF" ] || { echo '{"error":"конфигурация Zapret не найдена"}'; return 1; } grep -q "^[[:space:]]*option NFQWS_OPT '\$" "$CONF" || { echo '{"error":"в конфигурации не найден блок NFQWS_OPT"}'; return 1; } tmp="$CONF.zmnc" if [ "$st" = on ]; then _nochange_strategy || { awk '{ print } !d && /^[ \t]*option NFQWS_OPT \047$/ { print "#nochange"; d = 1 }' "$CONF" > "$tmp" && [ -s "$tmp" ] && cat "$tmp" > "$CONF" rm -f "$tmp" } else awk '/^[ \t]*option NFQWS_OPT \047$/ { b = 1; print; next } b && /^[ \t]*\047$/ { b = 0 } b { l = tolower($0); if (l ~ /^[ \t]*#[ \t]*nochange[ \t]*$/) next } { print }' "$CONF" > "$tmp" && [ -s "$tmp" ] && cat "$tmp" > "$CONF" rm -f "$tmp" _nochange_strategy && { echo '{"error":"пометка #nochange стоит внутри другой строки — уберите её вручную в редакторе стратегии","nochange":true}'; return 1; } fi printf '{"ok":true,"nochange":%s}\n' "$(_nochange_strategy && echo true || echo false)" ;; exclude|user|google) f="$(_zl_path "$t")"; tmp="$f.zmnc" mkdir -p "$(dirname "$f")" if [ "$st" = on ]; then _nochange_in "$f" || { { echo "#nochange"; [ -f "$f" ] && cat "$f"; :; } > "$tmp" && mv -f "$tmp" "$f"; chmod 644 "$f"; } elif [ -f "$f" ]; then _nochange_strip < "$f" > "$tmp" && mv -f "$tmp" "$f"; chmod 644 "$f" _nochange_in "$f" && { printf '{"error":"пометка #nochange стоит внутри другой строки — уберите её вручную в редакторе списка","info":%s}\n' "$(_zl_info "$t")"; return 1; } fi printf '{"ok":true,"info":%s}\n' "$(_zl_info "$t")" ;; *) echo '{"error":"неизвестный список"}'; return 1 ;; esac } zapret_list_restore() { case "$1" in auto=*) zapret_excl_auto_set "${1#auto=}"; return ;; nochange=*) zapret_nochange_set "${1#nochange=}"; return ;; esac [ "$1" = exclude ] || { echo '{"error":"восстановить можно только список исключений"}'; return 1; } [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } local f tmp; f="$(_zl_path exclude)"; tmp="$f.zmdl" _nochange_in "$f" && { echo '{"error":"включено «Не изменять список» (#nochange) — панель его не меняет. Выключите этот переключатель, чтобы восстановить исходный"}'; return 1; } mkdir -p "$(dirname "$f")" rm -f "$tmp" wget -q --timeout=20 -U "Mozilla/5.0" -O "$tmp" "$EXCLUDE_URL" 2>/dev/null if [ ! -s "$tmp" ]; then rm -f "$tmp"; echo '{"error":"не удалось скачать список исключений — текущий список не тронут"}'; return 1; fi mv -f "$tmp" "$f"; chmod 644 "$f" zapret_restart zapret_list_get exclude } nfqws_opt_get() { [ -f "$CONF" ] || { printf '{"content":""}\n'; return 0; } local content content=$(sed -n "/^[[:space:]]*option NFQWS_OPT '\$/,/^[[:space:]]*'\$/p" "$CONF" | sed '1d;$d') printf '{"content":"%s"}\n' "$(esc_ml "$content")" } nfqws_opt_set() { local content="$1" [ -x /etc/init.d/zapret ] || { echo '{"error":"Zapret не установлен"}'; return 1; } [ -f "$CONF" ] || { echo '{"error":"конфигурация Zapret не найдена"}'; return 1; } grep -q "^[[:space:]]*option NFQWS_OPT '\$" "$CONF" || { echo '{"error":"в конфигурации не найден блок NFQWS_OPT"}'; return 1; } sed -i "/^[[:space:]]*option NFQWS_OPT '/,\$d" "$CONF" { echo " option NFQWS_OPT '"; printf '%s\n' "$content"; echo "'"; } >> "$CONF" [ -f /opt/zapret/sync_config.sh ] && chmod +x /opt/zapret/sync_config.sh zapret_restart sleep 1 printf '{"ok":true}\n' } TG_MTPROTO_VER="0.10" TGWS_VERSION="0.3.1" TGWS_BASE_URL="https://gitlab.com/xyzmean/brb/-/raw/main/dist" TGWS_VERSION_URL="https://gitlab.com/xyzmean/brb/-/raw/main/VERSION" TG_GO_VER="1.4.1" TG_RS_VER="2.3.3" TG_BIN_GO="/usr/bin/tg-ws-proxy-go" TG_INIT_GO="/etc/init.d/tg-ws-proxy-go" TG_BIN_RS="/usr/bin/tg-ws-proxy-rs" TG_INIT_RS="/etc/init.d/tg-ws-proxy-rs" TG_SECRET_RS_FILE="/etc/tg-ws-proxy-rs.secret" TG_SECRET_MT_FILE="/etc/tg-ws-proxy/secret.conf" TG_VER_GO_FILE="/etc/tg-ws-proxy-go.ver" TG_VER_RS_FILE="/etc/tg-ws-proxy-rs.ver" TG_REPO_MT="spatiumstas/tg-ws-proxy-go" TG_REPO_GO="d0mhate/-tg-ws-proxy-Manager-go" TG_REPO_RS="valnesfjord/tg-ws-proxy-rs" _gh_latest_tag() { curl -Ls --connect-timeout 5 --max-time 12 -o /dev/null -w '%{url_effective}' "https://github.com/$1/releases/latest" 2>/dev/null | sed -n 's#.*/releases/tag/##p' | head -n1 | grep -E '^[vV]?[0-9]+(\.[0-9]+)+$' } _tg_url_ok() { curl -sfIL --connect-timeout 6 --max-time 15 -o /dev/null "$1" 2>/dev/null; } _tg_mt_url() { local sfx="$3" raz="ipk" arch [ "$PKG" = apk ] && { sfx="r$3"; raz="apk"; } arch="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" echo "${GH_MAIN}/$TG_REPO_MT/releases/download/$1/tg-ws-proxy_$2-${sfx}_openwrt_${arch}.${raz}" } _tg_tag_mt() { local t v r t="$(_gh_latest_tag "$TG_REPO_MT")"; [ -n "$t" ] || return 1 v="$(_ver_norm "$t")" for r in 1 2 3; do _tg_url_ok "$(_tg_mt_url "$t" "$v" "$r")" && { echo "$t $r"; return 0; }; done return 1 } _tg_tag_go() { local t f f="$(_tg_arch_go)" || return 1 t="$(_gh_latest_tag "$TG_REPO_GO")"; [ -n "$t" ] || return 1 _tg_url_ok "${GH_MAIN}/$TG_REPO_GO/releases/download/$t/$f" && echo "$t" } _tg_tag_rs() { local t f f="$(_tg_arch_rs)" || return 1 t="$(_gh_latest_tag "$TG_REPO_RS")"; [ -n "$t" ] || return 1 _tg_url_ok "${GH_MAIN}/$TG_REPO_RS/releases/download/$t/$f.tar.gz" && echo "$t" } _tg_latest() { local t t="$(_zm_cached "tg_$1" "_tg_tag_$1")" [ -n "$t" ] || case "$1" in mt) t="$TG_MTPROTO_VER 1" ;; go) t="v$TG_GO_VER" ;; rs) t="v$TG_RS_VER" ;; esac echo "$t" } _tg_fresh_tag() { local t t="$(ZM_VER_FORCE=1 _zm_cached "tg_$1" "_tg_tag_$1")" [ -n "$t" ] || t="$(_tg_latest "$1")" echo "$t" } _tg_ver_migrate() { [ -s /usr/bin/tg-ws-proxy-go.ver ] && { [ -s "$TG_VER_GO_FILE" ] || mv /usr/bin/tg-ws-proxy-go.ver "$TG_VER_GO_FILE"; rm -f /usr/bin/tg-ws-proxy-go.ver; } [ -s /usr/bin/tg-ws-proxy-rs.ver ] && { [ -s "$TG_VER_RS_FILE" ] || mv /usr/bin/tg-ws-proxy-rs.ver "$TG_VER_RS_FILE"; rm -f /usr/bin/tg-ws-proxy-rs.ver; } return 0 } _tg_newer() { [ -n "$1" ] && [ -n "$2" ] && _st_ver_lt "$(_ver_norm "$1")" "$(_ver_norm "$2")"; } _zm_pkg_files() { if [ "$PKG" = apk ]; then apk info -L "$1" 2>/dev/null | grep -v 'contains:$' | grep -v '^$' | sed 's#^/*#/#' else opkg files "$1" 2>/dev/null | grep '^/'; fi } _zm_pkg_purge() { local p="$1" out files f _pkg_is_installed "$p" || return 0 files="$(_zm_pkg_files "$p")" echo "==> Удаляем пакет $p" $DELETE "$p" if _pkg_is_installed "$p"; then echo "==> Менеджер пакетов отказался — удаляем $p принудительно" if [ "$PKG" = apk ]; then _zm_run 180 apk del --force-broken-world "$p" >/dev/null 2>&1 || _zm_run 180 apk del --force "$p" >/dev/null 2>&1 else _zm_run 180 opkg remove --force-depends --force-remove "$p" >/dev/null 2>&1 fi fi for f in $files; do [ -f "$f" ] || [ -L "$f" ] && rm -f "$f"; done if _pkg_is_installed "$p"; then echo "!! Пакет $p всё ещё числится в системе, но его файлы удалены" return 1 fi return 0 } _zm_left() { local f left="" for f in "$@"; do [ -e "$f" ] || [ -L "$f" ] && left="$left $f"; done [ -z "$left" ] && return 0 echo "!! Не удалось удалить:$left" return 1 } _zm_stop_svc() { local s="$1" b="$2" [ -x "/etc/init.d/$s" ] && { "/etc/init.d/$s" stop >/dev/null 2>&1; "/etc/init.d/$s" disable >/dev/null 2>&1; } [ -n "$b" ] && killall "$b" >/dev/null 2>&1 rm -f /etc/rc.d/[SK][0-9][0-9]"$s" } _zm_reinstall() { "$1" || { echo "ОШИБКА: не удалось удалить — переустановка остановлена"; return 1; } "$2" } _tg_arch_rs() { case "$TG_ARCH" in aarch64*) echo "tg-ws-proxy-aarch64-unknown-linux-musl" ;; x86_64) echo "tg-ws-proxy-x86_64-unknown-linux-musl" ;; arm*) echo "tg-ws-proxy-armv7-unknown-linux-musleabihf" ;; mips64*) return 1 ;; mipsel*) echo "tg-ws-proxy-mipsel-unknown-linux-musl" ;; mips*) echo "tg-ws-proxy-mips-unknown-linux-musl" ;; *) return 1 ;; esac } _tg_arch_go() { case "$TG_ARCH" in aarch64*) echo "tg-ws-proxy-openwrt-aarch64" ;; arm*) echo "tg-ws-proxy-openwrt-armv7" ;; mips64*) return 1 ;; mipsel*) echo "tg-ws-proxy-openwrt-mipsel_24kc" ;; mips*) echo "tg-ws-proxy-openwrt-mips_24kc" ;; x86_64) echo "tg-ws-proxy-openwrt-x86_64" ;; *) return 1 ;; esac } tg_status() { local mt="not_installed" mt_running="false" mt_ver="" local go="not_installed" go_running="false" go_ver="" local rs="not_installed" rs_running="false" rs_ver="" local secret_mt="" secret_rs="" lan_ip if [ -f /etc/init.d/tg-ws-proxy ]; then mt="installed"; pidof tg-ws-proxy >/dev/null 2>&1 && mt_running="true" if [ "$PKG" = "apk" ]; then mt_ver=$(apk info -v 2>/dev/null | grep '^tg-ws-proxy-' | grep -v '^tg-ws-proxy-go' | head -n1 | sed -E 's/^tg-ws-proxy-([0-9.]+).*/\1/') else mt_ver=$(opkg list-installed 2>/dev/null | awk '$1=="tg-ws-proxy"{print $3}' | sed 's/-[0-9]*$//; s/-r[0-9]*$//') fi fi _tg_ver_migrate if [ -f "$TG_INIT_GO" ]; then go="installed"; pidof tg-ws-proxy-go >/dev/null 2>&1 && go_running="true" [ -f "$TG_VER_GO_FILE" ] && go_ver=$(cat "$TG_VER_GO_FILE") fi if [ -f "$TG_INIT_RS" ]; then rs="installed"; pidof tg-ws-proxy-rs >/dev/null 2>&1 && rs_running="true" [ -f "$TG_VER_RS_FILE" ] && rs_ver=$(cat "$TG_VER_RS_FILE") fi [ -f "$TG_SECRET_MT_FILE" ] && secret_mt=$(grep '^SECRET=' "$TG_SECRET_MT_FILE" | cut -d= -f2) if [ -f "$TG_INIT_RS" ]; then secret_rs=$(sed -n 's/.*--secret[[:space:]]*\([0-9a-fA-F]\{32\}\).*/\1/p' "$TG_INIT_RS" | head -n1) fi [ -z "$secret_rs" ] && [ -f "$TG_SECRET_RS_FILE" ] && secret_rs=$(cat "$TG_SECRET_RS_FILE") lan_ip="$(_zm_lan_ip)" local mt_l go_l rs_l mt_l="$(_tg_latest mt)"; mt_l="$(_ver_norm "${mt_l%% *}")"; go_l="$(_ver_norm "$(_tg_latest go)")"; rs_l="$(_ver_norm "$(_tg_latest rs)")" printf '{"mtproto":"%s","mtproto_running":%s,"mtproto_version":"%s","mtproto_latest":"%s","mtproto_newer":%s,"socks5":"%s","socks5_running":%s,"socks5_version":"%s","socks5_latest":"%s","socks5_newer":%s,"rust":"%s","rust_running":%s,"rust_version":"%s","rust_latest":"%s","rust_newer":%s,"lan_ip":"%s","secret_mtproto":"%s","secret_rust":"%s","auto":%s}\n' \ "$mt" "$mt_running" "$(esc "$mt_ver")" "$(esc "$mt_l")" "$(_tg_newer "$mt_ver" "$mt_l" && echo true || echo false)" \ "$go" "$go_running" "$(esc "$go_ver")" "$(esc "$go_l")" "$(_tg_newer "$go_ver" "$go_l" && echo true || echo false)" \ "$rs" "$rs_running" "$(esc "$rs_ver")" "$(esc "$rs_l")" "$(_tg_newer "$rs_ver" "$rs_l" && echo true || echo false)" \ "$(esc "$lan_ip")" "$(esc "$secret_mt")" "$(esc "$secret_rs")" "$(_tg_auto_json)" } TG_AUTO_TAG="# zm-tg-auto" TG_WATCH_FLAG="$ZM_STATE_DIR/tg.watch" _tg_auto_init() { case "$1" in mtproto) echo /etc/init.d/tg-ws-proxy ;; socks5) echo "$TG_INIT_GO" ;; rust) echo "$TG_INIT_RS" ;; tgws) echo /etc/init.d/tgws ;; *) return 1 ;; esac } _tg_auto_alive() { case "$1" in mtproto) pidof tg-ws-proxy >/dev/null 2>&1 ;; socks5) pidof tg-ws-proxy-go >/dev/null 2>&1 ;; rust) pidof tg-ws-proxy-rs >/dev/null 2>&1 ;; tgws) [ -n "$(tgws status 2>/dev/null)" ] ;; esac } _tg_auto_busy() { case "$1" in tgws) _job_running tgws_install || _job_running tgws_remove || _job_running tgws_restart || _job_running tgws_reconfigure ;; *) _job_running "tg_install_$1" || _job_running "tg_remove_$1" ;; esac } _tg_auto_get() { local v v="$(grep -F "$TG_AUTO_TAG-$1=" "$CRON_FILE" 2>/dev/null | head -n1 | sed 's/.*=//')" case "$v" in m5|m10|m15|m30|h1|h2|h3|h6|h12|d[0-9]|d1[0-9]|d2[0-3]) echo "$v" ;; *) echo off ;; esac } _tg_auto_json() { printf '{"mtproto":"%s","socks5":"%s","rust":"%s","tgws":"%s","watch":%s}' \ "$(_tg_auto_get mtproto)" "$(_tg_auto_get socks5)" "$(_tg_auto_get rust)" "$(_tg_auto_get tgws)" \ "$([ -f "$TG_WATCH_FLAG" ] && echo true || echo false)" } _tg_auto_drop() { grep -qF "$TG_AUTO_TAG-$1=" "$CRON_FILE" 2>/dev/null || return 0 sed -i "\\|$TG_AUTO_TAG-$1=|d" "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 } tg_auto_set() { local id="$1" v="$2" spec init off init="$(_tg_auto_init "$id")" || { echo '{"error":"неизвестный прокси"}'; return 1; } off=$(( $(printf '%s' "$id" | wc -c) % 5 )) case "$v" in off) spec="" ;; h1) spec="$((7 + off)) * * * *" ;; h2|h3|h6|h12) spec="$((7 + off)) */${v#h} * * *" ;; *) echo '{"error":"неизвестный интервал"}'; return 1 ;; esac [ -z "$spec" ] || [ -f "$init" ] || { echo '{"error":"этот прокси не установлен"}'; return 1; } mkdir -p "$(dirname "$CRON_FILE")" touch "$CRON_FILE" sed -i "\\|$TG_AUTO_TAG-$id=|d" "$CRON_FILE" [ -n "$spec" ] && echo "$spec /opt/zapret-manager-luci/backend.sh tg_auto_tick $id >/dev/null 2>&1 $TG_AUTO_TAG-$id=$v" >> "$CRON_FILE" /etc/init.d/cron enable >/dev/null 2>&1 /etc/init.d/cron restart >/dev/null 2>&1 printf '{"ok":true,"auto":%s}\n' "$(_tg_auto_json)" } tg_watch_set() { mkdir -p "$ZM_STATE_DIR" case "$1" in on) : > "$TG_WATCH_FLAG" ;; off) rm -f "$TG_WATCH_FLAG" ;; *) echo '{"error":"неизвестное значение"}'; return 1 ;; esac printf '{"ok":true,"auto":%s}\n' "$(_tg_auto_json)" } tg_auto_tick() { local init init="$(_tg_auto_init "$1")" || return 0 [ -x "$init" ] || return 0 "$init" enabled >/dev/null 2>&1 || return 0 _tg_auto_busy "$1" && return 0 "$init" restart >/dev/null 2>&1 return 0 } _tg_watch() { local id init [ -f "$TG_WATCH_FLAG" ] || return 0 for id in mtproto socks5 rust tgws; do init="$(_tg_auto_init "$id")" [ -x "$init" ] || continue "$init" enabled >/dev/null 2>&1 || continue _tg_auto_busy "$id" && continue _tg_auto_alive "$id" && continue sleep 3 _tg_auto_alive "$id" && continue logger -t zapret-manager "TG-прокси ($id) не работает — запускаем заново" "$init" restart >/dev/null 2>&1 done return 0 } do_tg_install_mtproto() { _ensure_deps echo "==> Устанавливаем TG WS Proxy MTProto" local raz url tmp tag ver cur rev raz="ipk"; [ "$PKG" = "apk" ] && raz="apk" echo "==> Узнаём последнюю версию на GitHub ($TG_REPO_MT)" tag="$(_tg_fresh_tag mt)"; rev="${tag#* }"; tag="${tag%% *}"; [ "$rev" = "$tag" ] && rev=1 ver="$(_ver_norm "$tag")" cur="$(tg_status | sed -n 's/.*"mtproto_version":"\([^"]*\)".*/\1/p')" echo " ✓ Последняя версия: $ver${cur:+, сейчас стоит $cur}" tmp="/tmp/tg-ws-proxy.$raz" rm -f /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg $UPDATE >&2 url="$(_tg_mt_url "$tag" "$ver" "$rev")" echo "==> Скачиваем $(basename "$url")" if ! curl -fsL --connect-timeout 10 --max-time 120 -o "$tmp" "$url" || [ ! -s "$tmp" ]; then if [ "$ver" != "$TG_MTPROTO_VER" ]; then echo "!! Для версии $ver нет пакета под эту архитектуру — ставим проверенную $TG_MTPROTO_VER" ver="$TG_MTPROTO_VER" url="$(_tg_mt_url "$ver" "$ver" 1)" curl -fsL --connect-timeout 10 --max-time 120 -o "$tmp" "$url" && [ -s "$tmp" ] || { rm -f "$tmp"; echo "ОШИБКА скачивания $url"; return 1; } else rm -f "$tmp"; echo "ОШИБКА скачивания $url"; return 1 fi fi if _pkg_is_installed tg-ws-proxy && [ ! -f /etc/init.d/tg-ws-proxy ]; then echo "==> Пакет числится установленным, но файлов нет — ставим поверх" if [ "$PKG" = apk ]; then _zm_run 180 apk del --force-broken-world tg-ws-proxy >/dev/null 2>&1; $INSTALL "$tmp" >&2 else $INSTALL --force-reinstall "$tmp" >&2; fi elif [ "$PKG" = opkg ] && _pkg_is_installed tg-ws-proxy; then $INSTALL --force-reinstall "$tmp" >&2 else $INSTALL "$tmp" >&2 fi [ -f /etc/init.d/tg-ws-proxy ] || { echo "ОШИБКА установки: менеджер пакетов не поставил tg-ws-proxy"; rm -f "$tmp"; return 1; } rm -f "$tmp" mkdir -p "$(dirname "$TG_SECRET_MT_FILE")" if ! grep -q '^SECRET=.' "$TG_SECRET_MT_FILE" 2>/dev/null; then echo "SECRET=$(head -c16 /dev/urandom | hexdump -e '16/1 "%02x"')" > "$TG_SECRET_MT_FILE" fi rm -f /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg /etc/init.d/tg-ws-proxy enable >/dev/null 2>&1 /etc/init.d/tg-ws-proxy restart >/dev/null 2>&1 cur="$(tg_status | sed -n 's/.*"mtproto_version":"\([^"]*\)".*/\1/p')" echo "==> Готово: TG WS Proxy MTProto ${cur:-$ver}" } do_tg_remove_mtproto() { local rc=0 echo "==> Останавливаем TG WS Proxy MTProto" _zm_stop_svc tg-ws-proxy tg-ws-proxy _zm_pkg_purge tg-ws-proxy || rc=1 echo "==> Чистим файлы и настройки" rm -rf /etc/init.d/tg-ws-proxy /usr/bin/tg-ws-proxy /etc/tg-ws-proxy /etc/tg-ws-proxy.conf /etc/tg-ws-proxy.conf-opkg /etc/config/tg-ws-proxy /etc/rc.d/*tg-ws-proxy _zm_cron_drop 'tg-ws-proxy([^-]|$)' "TG WS Proxy" _zm_left /etc/init.d/tg-ws-proxy /usr/bin/tg-ws-proxy || return 1 [ "$rc" = 0 ] && echo "==> Готово: TG WS Proxy MTProto удалён" || echo "==> Готово: файлы удалены, можно ставить заново" } do_tg_install_socks5() { _ensure_deps echo "==> Устанавливаем TG WS Proxy SOCKS5" local file url tag tmp="$TG_BIN_GO.new" file="$(_tg_arch_go)" || { echo "ОШИБКА: архитектура не поддерживается ($TG_ARCH)"; return 1; } echo "==> Узнаём последнюю версию на GitHub ($TG_REPO_GO)" tag="$(_tg_fresh_tag go)" echo " ✓ Последняя версия: $(_ver_norm "$tag")$([ -s "$TG_VER_GO_FILE" ] && echo ", сейчас стоит $(cat "$TG_VER_GO_FILE")")" url="${GH_MAIN}/$TG_REPO_GO/releases/download/${tag}/${file}" echo "==> Скачиваем $file" if ! curl -fsL --connect-timeout 10 --max-time 120 -o "$tmp" "$url" || [ ! -s "$tmp" ]; then rm -f "$tmp" [ "$(_ver_norm "$tag")" = "$TG_GO_VER" ] && { echo "ОШИБКА скачивания"; return 1; } echo "!! Для версии $(_ver_norm "$tag") нет файла под эту архитектуру — ставим проверенную $TG_GO_VER" tag="v$TG_GO_VER" curl -fsL --connect-timeout 10 --max-time 120 -o "$tmp" "${GH_MAIN}/$TG_REPO_GO/releases/download/${tag}/${file}" && [ -s "$tmp" ] || { rm -f "$tmp"; echo "ОШИБКА скачивания"; return 1; } fi chmod +x "$tmp" [ -x "$TG_INIT_GO" ] && "$TG_INIT_GO" stop >/dev/null 2>&1 mv -f "$tmp" "$TG_BIN_GO" || { rm -f "$tmp"; echo "ОШИБКА: не удалось записать программу"; return 1; } if [ ! -f "$TG_INIT_GO" ]; then printf '#!/bin/sh /etc/rc.common\nSTART=99\nUSE_PROCD=1\n\nstart_service() {\n\tprocd_open_instance\n\tprocd_set_param command /usr/bin/tg-ws-proxy-go --host 0.0.0.0 --port 2080 --cf-proxy --cf-proxy-first --cf-balance\n\tprocd_set_param respawn\n\tprocd_close_instance\n}\n' > "$TG_INIT_GO" chmod +x "$TG_INIT_GO" "$TG_INIT_GO" enable >/dev/null 2>&1 fi "$TG_INIT_GO" restart >/dev/null 2>&1 _ver_norm "$tag" > "$TG_VER_GO_FILE" rm -f /usr/bin/tg-ws-proxy-go.ver echo "==> Готово: TG WS Proxy SOCKS5 $(cat "$TG_VER_GO_FILE")" } do_tg_remove_socks5() { echo "==> Удаляем TG WS Proxy SOCKS5" _zm_stop_svc tg-ws-proxy-go tg-ws-proxy-go echo "==> Удаляем программу, службу и настройки" rm -f "$TG_BIN_GO" "$TG_BIN_GO.new" "$TG_INIT_GO" "$TG_VER_GO_FILE" /usr/bin/tg-ws-proxy-go.ver /etc/rc.d/*tg-ws-proxy-go _zm_cron_drop 'tg-ws-proxy-go' "TG WS Proxy SOCKS5" _zm_left "$TG_BIN_GO" "$TG_INIT_GO" || return 1 echo "==> Готово: TG WS Proxy SOCKS5 удалён" } do_tg_install_rust() { _ensure_deps echo "==> Устанавливаем TG WS Proxy Rust" local file url tmp_archive tmp_dir secret tag file="$(_tg_arch_rs)" || { echo "ОШИБКА: архитектура не поддерживается ($TG_ARCH)"; return 1; } echo "==> Узнаём последнюю версию на GitHub ($TG_REPO_RS)" tag="$(_tg_fresh_tag rs)" echo " ✓ Последняя версия: $(_ver_norm "$tag")$([ -s "$TG_VER_RS_FILE" ] && echo ", сейчас стоит $(cat "$TG_VER_RS_FILE")")" tmp_archive="$JOBS_DIR/tg-ws-proxy-rs.tar.gz"; tmp_dir="$JOBS_DIR/tg-ws-proxy-rs" url="${GH_MAIN}/$TG_REPO_RS/releases/download/${tag}/${file}.tar.gz" echo "==> Скачиваем $file" if ! curl -fsL --connect-timeout 10 --max-time 120 -o "$tmp_archive" "$url" || [ ! -s "$tmp_archive" ]; then rm -f "$tmp_archive" [ "$(_ver_norm "$tag")" = "$TG_RS_VER" ] && { echo "ОШИБКА скачивания"; return 1; } echo "!! Для версии $(_ver_norm "$tag") нет архива под эту архитектуру — ставим проверенную $TG_RS_VER" tag="v$TG_RS_VER" curl -fsL --connect-timeout 10 --max-time 120 -o "$tmp_archive" "${GH_MAIN}/$TG_REPO_RS/releases/download/${tag}/${file}.tar.gz" && [ -s "$tmp_archive" ] || { rm -f "$tmp_archive"; echo "ОШИБКА скачивания"; return 1; } fi rm -rf "$tmp_dir"; mkdir -p "$tmp_dir" tar -xzf "$tmp_archive" -C "$tmp_dir" || { echo "ОШИБКА распаковки"; rm -rf "$tmp_dir" "$tmp_archive"; return 1; } set -- $(find "$tmp_dir" -type f -name 'tg-ws-proxy*' 2>/dev/null) [ -n "$1" ] || { echo "ОШИБКА: в архиве нет программы"; rm -rf "$tmp_dir" "$tmp_archive"; return 1; } [ -x "$TG_INIT_RS" ] && "$TG_INIT_RS" stop >/dev/null 2>&1 rm -f "$TG_BIN_RS" mv "$1" "$TG_BIN_RS" || { echo "ОШИБКА установки бинарника"; rm -rf "$tmp_dir" "$tmp_archive"; return 1; } chmod +x "$TG_BIN_RS" rm -rf "$tmp_dir" "$tmp_archive" if [ ! -f "$TG_SECRET_RS_FILE" ]; then head -c16 /dev/urandom | hexdump -e '16/1 "%02x"' > "$TG_SECRET_RS_FILE" fi secret=$(cat "$TG_SECRET_RS_FILE") if [ ! -f "$TG_INIT_RS" ]; then printf '#!/bin/sh /etc/rc.common\nSTART=99\nUSE_PROCD=1\n\nstart_service() {\n\tprocd_open_instance\n\tprocd_set_param command /usr/bin/tg-ws-proxy-rs --host 0.0.0.0 --port 2443 --secret %s --default-domains --cf-balance --cf-priority\n\tprocd_set_param respawn\n\tprocd_close_instance\n}\n' "$secret" > "$TG_INIT_RS" chmod +x "$TG_INIT_RS" "$TG_INIT_RS" enable >/dev/null 2>&1 fi "$TG_INIT_RS" restart >/dev/null 2>&1 _ver_norm "$tag" > "$TG_VER_RS_FILE" rm -f /usr/bin/tg-ws-proxy-rs.ver echo "==> Готово: TG WS Proxy Rust $(cat "$TG_VER_RS_FILE")" } do_tg_remove_rust() { echo "==> Удаляем TG WS Proxy Rust" _zm_stop_svc tg-ws-proxy-rs tg-ws-proxy-rs echo "==> Удаляем программу, службу и настройки" rm -f "$TG_BIN_RS" "$TG_INIT_RS" "$TG_SECRET_RS_FILE" "$TG_VER_RS_FILE" /usr/bin/tg-ws-proxy-rs.ver /etc/rc.d/*tg-ws-proxy-rs _zm_cron_drop 'tg-ws-proxy-rs' "TG WS Proxy Rust" _zm_left "$TG_BIN_RS" "$TG_INIT_RS" || return 1 echo "==> Готово: TG WS Proxy Rust удалён" } tg_action() { local variant="$1" action="$2" case "$variant:$action" in watch:on|watch:off) tg_watch_set "$action"; return ;; *:auto=*) tg_auto_set "$variant" "${action#auto=}"; return ;; mtproto:remove|socks5:remove|rust:remove) _tg_auto_drop "$variant" ;; esac case "$variant:$action" in mtproto:install|mtproto:update) job_start tg_install_mtproto do_tg_install_mtproto ;; mtproto:reinstall) job_start tg_install_mtproto _zm_reinstall do_tg_remove_mtproto do_tg_install_mtproto ;; socks5:reinstall) job_start tg_install_socks5 _zm_reinstall do_tg_remove_socks5 do_tg_install_socks5 ;; rust:reinstall) job_start tg_install_rust _zm_reinstall do_tg_remove_rust do_tg_install_rust ;; mtproto:remove) job_start tg_remove_mtproto do_tg_remove_mtproto ;; socks5:install|socks5:update) job_start tg_install_socks5 do_tg_install_socks5 ;; socks5:remove) job_start tg_remove_socks5 do_tg_remove_socks5 ;; rust:install|rust:update) job_start tg_install_rust do_tg_install_rust ;; rust:remove) job_start tg_remove_rust do_tg_remove_rust ;; *) echo '{"error":"неизвестный вариант/действие"}' ;; esac } tg_restart_all() { [ -x /etc/init.d/tg-ws-proxy ] && /etc/init.d/tg-ws-proxy restart >/dev/null 2>&1 [ -x "$TG_INIT_GO" ] && "$TG_INIT_GO" restart >/dev/null 2>&1 [ -x "$TG_INIT_RS" ] && "$TG_INIT_RS" restart >/dev/null 2>&1 [ -x /etc/init.d/tgws ] && /etc/init.d/tgws restart >/dev/null 2>&1 printf '{"ok":true}\n' } _tgws_domain() { tgws status 2>/dev/null | sed -n 's/^[[:space:]]*домен:[[:space:]]*//p' | head -n1 } tgws_status() { local installed="not_installed" ver="" domain="" running="false" latest="" if [ "$PKG" = "apk" ]; then ver=$(apk info -v 2>/dev/null | grep '^tgws-' | sed -E 's/^tgws-([0-9.]+).*/\1/') else ver=$(opkg list-installed 2>/dev/null | awk '$1=="tgws"{print $3}' | sed 's/-r[0-9]\+$//') fi if [ -x /etc/init.d/tgws ]; then installed="installed" [ -n "$(tgws status 2>/dev/null)" ] && running="true" domain=$(_tgws_domain) fi latest="$(_zm_cached tgws _tgws_latest)" [ -n "$latest" ] || latest="$TGWS_VERSION" printf '{"installed":"%s","running":%s,"version":"%s","latest":"%s","newer":%s,"domain":"%s"}\n' "$installed" "$running" "$(esc "$ver")" "$(esc "$latest")" "$(_tg_newer "$ver" "$latest" && echo true || echo false)" "$(esc "$domain")" } do_tgws_install() { _ensure_deps echo "==> Устанавливаем sTGWS" local ver arch file tmp echo "==> Узнаём последнюю версию sTGWS" ver="$(ZM_VER_FORCE=1 _zm_cached tgws _tgws_latest)" [ -n "$ver" ] || ver="$TGWS_VERSION" echo " ✓ Последняя версия: $ver" arch="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release)" [ -n "$arch" ] || arch="$(opkg print-architecture 2>/dev/null | awk '$2!="all"&&$2!="noarch"{print $2}' | tail -1)" if [ -z "$arch" ]; then echo "ОШИБКА: не удалось определить архитектуру роутера" return 1 fi file="tgws-${ver}-1_${arch}.${RAZ}" tmp="$JOBS_DIR/tgws_install.$RAZ" echo "==> Роутер: $arch, формат пакетов: $PKG" echo "==> Скачиваем $file" local attempt=1 max_attempts=5 while [ "$attempt" -le "$max_attempts" ]; do rm -f "$tmp" wget -q --timeout=20 -O "$tmp" "$TGWS_BASE_URL/$file" >&2 if [ -s "$tmp" ]; then if [ "$RAZ" = "ipk" ]; then tar -tzf "$tmp" 2>/dev/null | grep -q 'debian-binary' && break else head -c 512 "$tmp" | grep -qiE ' Обновляем список пакетов" $UPDATE >&2 if [ "$PKG" = "apk" ]; then if apk info -e kmod-nft-queue >/dev/null 2>&1; then $INSTALL kmod-nft-queue >&2 fi elif opkg list-installed kmod-nft-queue 2>/dev/null | grep -q .; then opkg flag user kmod-nft-queue >/dev/null 2>&1 fi echo "==> Устанавливаем пакет" $INSTALL "$tmp" || { echo "ОШИБКА: менеджер пакетов отказался ставить sTGWS"; rm -f "$tmp"; return 1; } rm -f "$tmp" if [ ! -x /etc/init.d/tgws ]; then echo "ОШИБКА: установка sTGWS не удалась" return 1 fi echo "==> Подбираем домен" /etc/init.d/tgws enable >/dev/null 2>&1 /etc/init.d/tgws restart >/dev/null 2>&1 tgws pick >/dev/null 2>&1 local i started=0 for i in $(seq 1 20); do if [ -n "$(tgws status 2>/dev/null)" ]; then started=1 break fi sleep 5 done if [ "$started" != "1" ]; then echo "ОШИБКА: не удалось подобрать домен" return 1 fi local domain domain=$(_tgws_domain) echo "==> Используем домен: ${domain:-не определён}" echo "==> Готово, sTGWS установлен и запущен" } do_tgws_remove() { local rc=0 echo "==> Останавливаем sTGWS" _zm_stop_svc tgws tgws killall stgws >/dev/null 2>&1 /usr/sbin/stgws apply --spec /dev/null --state-dir /var/lib/stgws >/dev/null 2>&1 /usr/sbin/tgws apply --spec /dev/null --state-dir /var/lib/tgws >/dev/null 2>&1 nft delete table inet stgws >/dev/null 2>&1 nft delete table inet tgws >/dev/null 2>&1 _zm_pkg_purge tgws || rc=1 echo "==> Чистим файлы" rm -rf /etc/*tgws* /var/lib/*tgws* /var/lock/*tgws* /etc/rc.d/*tgws* /etc/init.d/*tgws* /usr/sbin/*tgws* /usr/bin/*tgws* /etc/config/*tgws* _zm_cron_drop 'tgws' "sTGWS" echo " ✓ Файлы, правила файрвола и настройки удалены" _zm_left /etc/init.d/tgws /usr/sbin/tgws || return 1 [ "$rc" = 0 ] && echo "==> Готово: sTGWS удалён" || echo "==> Готово: файлы удалены, можно ставить заново" } do_tgws_restart() { [ -x /etc/init.d/tgws ] || { echo "ОШИБКА: sTGWS не установлен"; return 1; } echo "==> Перезапускаем sTGWS" /etc/init.d/tgws restart >/dev/null 2>&1 sleep 5 if [ -z "$(tgws status 2>/dev/null)" ]; then echo "ОШИБКА: sTGWS не запущен после перезапуска" return 1 fi echo "==> Готово, sTGWS перезапущен" } do_tgws_reconfigure() { [ -x /etc/init.d/tgws ] || { echo "ОШИБКА: sTGWS не установлен"; return 1; } echo "==> Подбираем новый домен" tgws pick >/dev/null 2>&1 sleep 6 /etc/init.d/tgws restart >/dev/null 2>&1 sleep 3 local domain domain=$(_tgws_domain) if [ -z "$(tgws status 2>/dev/null)" ] || [ -z "$domain" ]; then echo "ОШИБКА: не удалось подобрать домен" return 1 fi echo "==> Новый домен: $domain" echo "==> Готово, домен перенастроен" } tgws_action() { local action="$1" case "$action" in install|update) job_start tgws_install do_tgws_install ;; reinstall) job_start tgws_install _zm_reinstall do_tgws_remove do_tgws_install ;; remove) _tg_auto_drop tgws; job_start tgws_remove do_tgws_remove ;; restart) job_start tgws_restart do_tgws_restart ;; reconfigure) job_start tgws_reconfigure do_tgws_reconfigure ;; *) echo '{"error":"неизвестное действие"}' ;; esac } TEST_DIR="$JOBS_DIR/strategy_test" _test_results_file() { echo "$TEST_DIR/results_$1.txt"; } _test_running() { [ -f "$JOBS_DIR/strategy_test.pid" ] && kill -0 "$(cat "$JOBS_DIR/strategy_test.pid" 2>/dev/null)" 2>/dev/null; } _test_recover() { [ -s "$ZM_STATE_DIR/strategy_test.backup" ] || return 0 _test_running && return 0 [ -d /opt/zapret ] && cp -f "$ZM_STATE_DIR/strategy_test.backup" "$CONF" && zapret_restart rm -f "$ZM_STATE_DIR/strategy_test.backup" } TEST_BACKUP="$ZM_STATE_DIR/strategy_test.backup" TEST_STOP_FLAG="$TEST_DIR/stop" TEST_MODE_FILE="$TEST_DIR/mode" TEST_DOMAINS_JSON="${GH_RAW}/hyperion-cs/dpi-checkers/refs/heads/main/ru/tcp-16-20/suite.v2.json" TEST_YT_DOMAINS="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 rr4---sn-4g5e6nze.googlevideo.com rr4---sn-5go7yner.googlevideo.com rr4---sn-q4flrnsl.googlevideo.com rr5---sn-n8v7knez.googlevideo.com rr2---sn-q4fl6ndl.googlevideo.com rr1---sn-q4fl6n6y.googlevideo.com rr1---sn-aj5go5-53.googlevideo.com rr1---sn-4axm-n8vs.googlevideo.com rr14---sn-n8v7kn7r.googlevideo.com rr16---sn-axq7sn76.googlevideo.com rr4---sn-jvhnu5g-c35d.googlevideo.com rr1---sn-8ph2xajvh-5xge.googlevideo.com rr1---sn-xguxaxjvh-gufl.googlevideo.com rr1---sn-gvnuxaxjvh-jx3z.googlevideo.com rr1---sn-gvnuxaxjvh-jx3l.googlevideo.com rr1---sn-gvnuxaxjvh-o8ge.googlevideo.com rr5---sn-gvnuxaxjvh-n8vk.googlevideo.com rr10---sn-gvnuxaxjvh-304z.googlevideo.com rr12---sn-gvnuxaxjvh-bvwz.googlevideo.com rr3---sn-ug5onuxaxjvh-n8v6.googlevideo.com rr1---sn-ug5onuxaxjvh-p5ge.googlevideo.com rr1---sn-ug5onuxaxjvh-p3ul.googlevideo.com rr1---sn-ug5onuxaxjvh-n8v6.googlevideo.com rr1---sn-u5uuxaxjvhg0-ocje.googlevideo.com" TEST_PARALLEL=8 _test_sort_results() { local f="$1" tmp tmp="$TEST_DIR/sort.$$" awk -F'[/ ]' '{ for (i = 1; i <= NF; i++) { if ($i ~ /^[0-9]+$/) { print $i, $0; break } } }' "$f" | sort -k1,1 -nr | cut -d' ' -f2- > "$tmp" mv "$tmp" "$f" } _test_prepare_urls() { local out="$1" : > "$out" printf '%s\n' \ "gosuslugi.ru|https://www.gosuslugi.ru" \ "esia.gosuslugi.ru|https://esia.gosuslugi.ru" \ "nalog.ru|https://nalog.ru" \ "lkfl2.nalog.ru|https://lkfl2.nalog.ru" \ "rutube.ru|https://rutube.ru" \ "ntc.party|https://ntc.party/" \ "instagram.com|https://instagram.com" \ "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" \ "forum.ru-board.com|https://forum.ru-board.com" \ "play.google.com|https://play.google.com" \ "downloads.openwrt.org|https://downloads.openwrt.org" \ "githubusercontent.com|https://raw.githubusercontent.com/StressOzz/Zapret-Manager/refs/heads/main/Zapret-Manager.sh" \ >> "$out" curl -fsSL --connect-timeout 8 --max-time 15 "$TEST_DOMAINS_JSON" 2>/dev/null \ | sed -n 's/.*"id":[[:space:]]*"\([^"]*\)".*"host":[[:space:]]*"\([^"]*\)".*/\1|\2/p' >> "$out" } _test_yt_urls() { local d for d in $TEST_YT_DOMAINS; do printf '%s|https://%s/\n' "$d" "$d" done } _test_kill_pid_tree() { local pid="$1" ch c [ -n "$pid" ] || return 0 ch=$(cat "/proc/$pid/task/$pid/children" 2>/dev/null) for c in $ch; do _test_kill_pid_tree "$c"; done kill -9 "$pid" 2>/dev/null } _test_kill_bg_jobs() { local pf="$1" pid [ -s "$pf" ] || return 0 while IFS= read -r pid; do [ -n "$pid" ] && _test_kill_pid_tree "$pid" done < "$pf" wait 2>/dev/null } _test_check_url() { local entry="$1" okfile="$2" logfile="$3" text link text=$(echo "$entry" | cut -d'|' -f1) link=$(echo "$entry" | cut -d'|' -f2) if curl -sL --connect-timeout 4 --max-time 6 --speed-time 3 --speed-limit 1 --range 0-65535 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) curl/8.0" -o /dev/null "$link" >/dev/null 2>&1; then echo 1 >> "$okfile" echo "[ OK ] $text" >> "$logfile" else echo "[FAIL] $text" >> "$logfile" fi } _test_check_all_urls() { local urls="$1" logfile="$2" okfile pidfile total run=0 ok entry okfile="$TEST_DIR/ok.$$" pidfile="$TEST_DIR/pids.$$" : > "$okfile" : > "$pidfile" total=$(printf '%s\n' "$urls" | grep -c '|') while IFS= read -r entry; do [ -z "$entry" ] && continue [ -f "$TEST_STOP_FLAG" ] && break _test_check_url "$entry" "$okfile" "$logfile" & echo $! >> "$pidfile" run=$((run + 1)) if [ "$run" -ge "$TEST_PARALLEL" ]; then wait run=0 [ -f "$TEST_STOP_FLAG" ] && break fi done <<-TEST_URLS_EOF $urls TEST_URLS_EOF if [ -f "$TEST_STOP_FLAG" ]; then _test_kill_bg_jobs "$pidfile" else wait fi ok=$(wc -l < "$okfile" | tr -d ' ') rm -f "$okfile" "$pidfile" printf '%s %s\n' "$ok" "$total" } _test_build_candidates() { local mode="$1" out="$2" n f : > "$out" case "$mode" in v) for n in 1 2 3 4 5 6 7 8 9 10; do strategy_v"$n" >> "$out"; done ;; flowseal) f="$(_flowseal_file)" [ -s "$f" ] || do_flowseal_download >/dev/null 2>&1 [ -s "$f" ] && cat "$f" >> "$out" ;; v_flowseal) f="$(_flowseal_file)" [ -s "$f" ] || do_flowseal_download >/dev/null 2>&1 [ -s "$f" ] && cat "$f" >> "$out" for n in 1 2 3 4 5 6 7 8 9 10; do strategy_v"$n" >> "$out"; done ;; youtube) f="$(_yv_file)" [ -s "$f" ] || do_yv_download >/dev/null 2>&1 [ -s "$f" ] && cat "$f" >> "$out" ;; esac [ "$mode" = "youtube" ] || sed -i '/^#Y/d' "$out" } _test_apply_block() { local block="$1" sed -i "/^[[:space:]]*option NFQWS_OPT '/,\$d" "$CONF" { echo " option NFQWS_OPT '"; echo "$block"; echo "'"; } >> "$CONF" } _test_detail_file() { echo "$TEST_DIR/detail_$1.txt"; } _test_detail_domains() { printf 'D|%s\n' "$(printf '%s\n' "$2" | grep '|' | cut -d'|' -f1 | sed 's/[[:space:]]/_/g' | tr '\n' ' ' | sed 's/ $//')" >> "$1" } _test_detail_add() { local f="$1" name="$2" ok="$3" tot="$4" log="$5" fails fails="$(sed -n 's/^\[FAIL\] //p' "$log" 2>/dev/null | sed 's/[[:space:]]/_/g; s/|/\//g' | tr '\n' ' ' | sed 's/ $//')" printf 'S|%s|%s|%s|%s\n' "$(printf '%s' "$name" | tr '|' '/')" "${ok:-0}" "${tot:-0}" "$fails" >> "$f" } _test_run_set() { local urls="$1" name="$2" results="$3" detail="$4" log="$TEST_DIR/log_run.txt" res ok tot : > "$log" res=$(_test_check_all_urls "$urls" "$log") ok=$(echo "$res" | cut -d' ' -f1) tot=$(echo "$res" | cut -d' ' -f2) echo "${name} → ${ok}/${tot}" >> "$results" _test_detail_add "$detail" "$name" "$ok" "$tot" "$log" echo " результат: ${ok} из ${tot}" } _test_domain_urls() { local d h for d in $(cat "$TEST_DIR/domain_list" 2>/dev/null); do h="$(printf '%s' "$d" | sed -E 's#^[a-zA-Z]+://##; s#[/?#].*##')" [ -n "$h" ] && printf '%s|https://%s/\n' "$h" "$h" done } do_test_run() { local mode="$1" results detail mkdir -p "$TEST_DIR" rm -f "$TEST_STOP_FLAG" results="$(_test_results_file "$mode")" detail="$(_test_detail_file "$mode")" : > "$results" printf 'T|%s\n' "$(date '+%d.%m.%Y %H:%M')" > "$detail" echo "$mode" > "$TEST_MODE_FILE" [ -f "$CONF" ] || { echo "ОШИБКА: Zapret не установлен"; return 1; } if [ "$mode" = "current" ]; then _add_gp_domains _refresh_exclude_file echo "==> Проверяем текущую стратегию, ничего не меняя" zapret_restart local urls_file="$TEST_DIR/urls.txt" dpi_urls yt_urls echo "==> Собираем список сайтов" _test_prepare_urls "$urls_file" dpi_urls="$(cat "$urls_file")" yt_urls="$(_test_yt_urls)" echo "==> Заблокированные сайты: $(printf '%s\n' "$dpi_urls" | grep -c '|')" _test_detail_domains "$detail" "$dpi_urls" _test_run_set "$dpi_urls" "Заблокированные сайты" "$results" "$detail" if [ -f "$TEST_STOP_FLAG" ]; then rm -f "$TEST_STOP_FLAG" echo "==> Тест остановлен" return 0 fi echo "==> YouTube: $(printf '%s\n' "$yt_urls" | grep -c '|') адресов" _test_detail_domains "$detail" "$yt_urls" _test_run_set "$yt_urls" "YouTube" "$results" "$detail" rm -f "$TEST_STOP_FLAG" echo "==> Готово" return 0 fi local urls total_domains case "$mode" in youtube) urls="$(_test_yt_urls)" ;; domain) urls="$(_test_domain_urls)" [ -n "$urls" ] || { echo "ОШИБКА: не указаны домены"; return 1; } ;; *) local urls_file="$TEST_DIR/urls.txt" echo "==> Собираем список сайтов" _test_prepare_urls "$urls_file" urls="$(cat "$urls_file")" ;; esac total_domains=$(printf '%s\n' "$urls" | grep -c '|') mkdir -p "$ZM_STATE_DIR" cp "$CONF" "$TEST_BACKUP" _add_gp_domains _refresh_exclude_file local cand="$TEST_DIR/candidates.txt" cmode="$mode" [ "$mode" = domain ] && cmode=v_flowseal echo "==> Собираем стратегии" _test_build_candidates "$cmode" "$cand" if [ ! -s "$cand" ]; then echo "ОШИБКА: не удалось собрать ни одной стратегии" rm -f "$TEST_BACKUP" return 1 fi local total_str total_str=$(grep -c '^#' "$cand") echo "==> Стратегий: $total_str, адресов: $total_domains" _test_detail_domains "$detail" "$urls" echo "==> Контрольный тест: Zapret выключен" /etc/init.d/zapret stop >/dev/null 2>&1 _test_run_set "$urls" "Контрольный тест (Zapret выключен)" "$results" "$detail" /etc/init.d/zapret start >/dev/null 2>&1 local lines lines=$(grep -n '^#' "$cand" | cut -d: -f1) echo "$lines" | awk '{ print NR, $0 }' | while read -r cur start; do [ -f "$TEST_STOP_FLAG" ] && break local next name next=$(echo "$lines" | awk -v s="$start" '$1>s{print;exit}') if [ -z "$next" ]; then sed -n "${start},\$p" "$cand" > "$TEST_DIR/block.txt" else sed -n "${start},$((next-1))p" "$cand" > "$TEST_DIR/block.txt"; fi name=$(head -n1 "$TEST_DIR/block.txt") name="${name#\#}" echo "==> [$cur/$total_str] $name" _test_apply_block "$(cat "$TEST_DIR/block.txt")" zapret_restart _test_run_set "$urls" "$name" "$results" "$detail" done if [ -f "$TEST_STOP_FLAG" ]; then echo "==> Тест остановлен, возвращаем настройки" rm -f "$TEST_STOP_FLAG" else echo "==> Тест завершён, возвращаем настройки" fi _test_sort_results "$results" local best best=$(grep -v '^Контрольный тест' "$results" | head -n1) [ -n "$best" ] && echo "==> Лучшая: $best" cp "$TEST_BACKUP" "$CONF" zapret_restart rm -f "$TEST_BACKUP" echo "==> Готово: настройки возвращены" } test_action() { local action="$1" mode="$2" doms="" d h case "$action" in start) case "$mode" in domain:*) for d in $(printf '%s' "${mode#domain:}" | tr ',;' ' '); do h="$(printf '%s' "$d" | sed -E 's#^[a-zA-Z]+://##; s#[/?#].*##' | tr 'A-Z' 'a-z')" printf '%s' "$h" | grep -qE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' || { printf '{"error":"%s"}\n' "$(esc "не похоже на домен: $d")"; return 1; } doms="$doms $h" done doms="$(printf '%s\n' $doms | awk '!s[$0]++' | head -n 30 | tr '\n' ' ')" [ -n "${doms# }" ] || { echo '{"error":"введите хотя бы один домен"}'; return 1; } mkdir -p "$TEST_DIR" printf '%s\n' $doms > "$TEST_DIR/domain_list" mode=domain ;; v|flowseal|v_flowseal|youtube|current) ;; *) echo '{"error":"неизвестный режим теста"}'; return 1 ;; esac _test_running && { echo '{"error":"тест уже идёт"}'; return 1; } job_start strategy_test do_test_run "$mode" ;; stop) _test_running || { echo '{"error":"тест не запущен"}'; return 1; } mkdir -p "$TEST_DIR" touch "$TEST_STOP_FLAG" printf '{"ok":true}\n' ;; clear) case "$mode" in v|flowseal|v_flowseal|youtube|current|domain) rm -f "$(_test_results_file "$mode")" "$(_test_detail_file "$mode")" ;; *) rm -f "$TEST_DIR"/results_*.txt "$TEST_DIR"/detail_*.txt ;; esac printf '{"ok":true}\n' ;; *) echo '{"error":"неизвестное действие"}' ;; esac } test_status() { local running="false" mode="" m has="" _test_recover _test_running && running="true" [ -f "$TEST_MODE_FILE" ] && mode=$(cat "$TEST_MODE_FILE") for m in v flowseal v_flowseal youtube current domain; do has="$has,\"has_results_$m\":$([ -s "$(_test_results_file "$m")" ] && echo true || echo false)" done printf '{"running":%s,"mode":"%s","domains":"%s"%s}\n' "$running" "$(esc "$mode")" "$(esc "$(tr '\n' ' ' < "$TEST_DIR/domain_list" 2>/dev/null | sed 's/ $//')")" "$has" } test_results() { local mode="$1" f d f="$(_test_results_file "$mode")" d="$(_test_detail_file "$mode")" [ -s "$f" ] || { echo '{"lines":"","detail":""}'; return; } printf '{"lines":"%s","detail":"%s"}\n' "$(esc_ml "$(if [ "$(wc -c < "$f")" -gt 60000 ]; then tail -c 60000 "$f" | sed 1d; else cat "$f"; fi)")" "$(esc_ml "$(if [ "$(wc -c < "$d" 2>/dev/null || echo 0)" -gt 150000 ]; then head -n1 "$d"; tail -n +2 "$d" | tail -c 150000 | sed 1d; else cat "$d" 2>/dev/null; fi)")" } _zm_gh_get() { local i curl -fsSL --connect-timeout 6 --max-time 120 ${3:+-r "$3"} -o "$2" "$1" 2>/dev/null && [ -s "$2" ] && return 0 i="$(_st_warp_first 2>/dev/null)" [ -n "$i" ] && [ -d "/sys/class/net/$i" ] || return 1 curl -fsSL --interface "$i" --connect-timeout 6 --max-time 120 ${3:+-r "$3"} -o "$2" "$1" 2>/dev/null && [ -s "$2" ] } _zm_panel_latest() { local f="$JOBS_DIR/zm_head.$$" v mkdir -p "$JOBS_DIR" _zm_gh_get "$ZM_SCRIPT_URL" "$f" 0-400 || { rm -f "$f"; return 1; } v="$(grep -m1 '^# Version:' "$f" | sed 's/^# Version:[[:space:]]*//' | tr -d '\r ')" rm -f "$f" echo "$v" | grep -qE '^[0-9]+(\.[0-9]+)*$' && echo "$v" } _zm_newer() { [ -n "$1" ] && _st_ver_lt "$ZM_VERSION" "$1"; } zm_update_status() { local latest if [ -s "$ZM_STATE_DIR/latest.panel" ]; then latest="$(_zm_cached panel _zm_panel_latest)" else latest="$(ZM_VER_FORCE=1 _zm_cached panel _zm_panel_latest)"; fi printf '{"current":"%s","latest":"%s","newer":%s}\n' "$(esc "$ZM_VERSION")" "$(esc "$latest")" "$(_zm_newer "$latest" && echo true || echo false)" } _zm_update_fetch() { rm -f "$1" _zm_gh_get "$ZM_SCRIPT_URL" "$1" || { echo "не удалось скачать установщик с GitHub — ни напрямую, ни через WARP"; return 1; } head -n1 "$1" | grep -qx '#!/bin/sh' || { echo "скачанный файл не похож на установщик (возможно, вместо него пришла страница-заглушка)"; return 1; } grep -q '^# Version:' "$1" || { echo "в установщике нет номера версии"; return 1; } tail -n 5 "$1" | grep -q 'Web UI:' || { echo "установщик скачался не полностью — попробуйте ещё раз"; return 1; } sh -n "$1" 2>/dev/null || { echo "установщик повреждён — установка отменена"; return 1; } return 0 } _zm_job_label() { case "$1" in steer) echo "Steer" ;; awg) echo "AmneziaWG" ;; forkop) echo "Forkozz" ;; mixomo*) echo "Mixomo" ;; bytetube*) echo "ByeTube" ;; install_zapret2|remove_zapret2) echo "Zapret2" ;; install_zapret|remove_zapret) echo "Zapret" ;; strategy_test) echo "тест стратегий" ;; tg*) echo "TG WS Proxy" ;; doh*) echo "DNS over HTTPS" ;; mirror_set) echo "смена зеркала" ;; *) echo "$1" ;; esac } _zm_busy_job() { local f j for f in "$JOBS_DIR"/*.pid; do [ -f "$f" ] || continue j="$(basename "$f" .pid)" case "$j" in versions|sysinfo|*_download|redbtn_deep) continue ;; esac _job_running "$j" && { _zm_job_label "$j"; return 0; } done return 1 } zm_update_action() { local tmp="/tmp/zm_update_install.sh" why v j if j="$(_zm_busy_job)"; then printf '{"error":"%s"}\n' "$(esc "идёт операция «$j» — дождитесь её окончания и обновите панель")" return 1 fi if ! why="$(_zm_update_fetch "$tmp")"; then rm -f "$tmp" printf '{"error":"%s"}\n' "$(esc "$why")" return 1 fi v="$(grep -m1 '^# Version:' "$tmp" | sed 's/^# Version:[[:space:]]*//' | tr -d '\r ')" chmod +x "$tmp" rm -f "$ZM_STATE_DIR/latest.panel" ( sh "$tmp" >/tmp/zm_update_install.log 2>&1; rm -f "$tmp" ) >/dev/null 2>&1 "$f" elif [ ! -s "$f" ] || [ -n "$(find "$f" -mmin +$age 2>/dev/null)" ]; then if mkdir "$f.lock" 2>/dev/null; then ( v="$("$@")"; [ -n "$v" ] && echo "$v" > "$f"; rmdir "$f.lock" ) >/dev/null 2>&1 & elif [ -n "$(find "$f.lock" -mmin +2 2>/dev/null)" ]; then rmdir "$f.lock" 2>/dev/null fi fi cat "$f" 2>/dev/null } _mihomo_latest() { curl -Ls --connect-timeout 4 --max-time 6 -o /dev/null -w '%{url_effective}' "https://github.com/MetaCubeX/mihomo/releases/latest" 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1; } _mt_arch() { local a a=$(grep '^OPENWRT_ARCH=' /etc/os-release 2>/dev/null | cut -d'"' -f2) [ -n "$a" ] || a="$(awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release 2>/dev/null)" echo "$a" } _mt_tag_now() { local t n=0 while [ "$n" -lt 3 ]; do t="$(curl -Ls --connect-timeout 6 --max-time 15 -o /dev/null -w '%{url_effective}' "https://github.com/MagiTrickle/MagiTrickle/releases/latest" 2>/dev/null | sed -n 's#.*/tag/##p' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-rev[0-9]+)?$')" [ -n "$t" ] && { echo "$t"; return 0; } n=$((n + 1)); sleep 2 done t="$(cat "$ZM_STATE_DIR/latest.magitrickle2" 2>/dev/null | head -n1)" [ -n "$t" ] && { echo "$t"; return 0; } return 1 } _mt_files() { local tag="$1" arch="$2" ver rev ver="${tag%-rev*}" case "$tag" in *-rev*) rev="${tag##*-rev}" ;; *) rev=1 ;; esac if [ "$RAZ" = apk ]; then echo "magitrickle_${ver}-r${rev}_openwrt_${arch}.apk" else echo "magitrickle_${ver}-${rev}_openwrt_${arch}.ipk"; fi } _mt_install() { local arch tag file url url2 tmp page n=0 ok=0 arch="$(_mt_arch)" [ -n "$arch" ] || { echo "!! Не удалось определить архитектуру роутера"; return 1; } tag="$(_mt_tag_now)" || { echo "!! Не удалось узнать последнюю версию MagiTrickle — GitHub не ответил"; return 1; } file="$(_mt_files "$tag" "$arch")" url="${GH_MAIN}/MagiTrickle/MagiTrickle/releases/download/${tag}/${file}" tmp="$JOBS_DIR/$file" echo " → Версия $tag, архитектура $arch" while [ "$n" -lt 3 ] && [ "$ok" = 0 ]; do n=$((n + 1)) rm -f "$tmp" echo " → Скачиваем $file (попытка $n из 3)" if curl -sSLf --connect-timeout 8 --max-time 120 -o "$tmp" "$url" 2>/dev/null && [ -s "$tmp" ]; then ok=1; break; fi page="$(curl -fsSL --connect-timeout 8 --max-time 30 "${GH_MAIN}/MagiTrickle/MagiTrickle/releases/expanded_assets/${tag}" 2>/dev/null)" url2="$(printf '%s' "$page" | grep -oE "href=\"/MagiTrickle/MagiTrickle/releases/download/[^\"]*_openwrt_${arch}\.${RAZ}\"" | head -n1 | sed 's/^href="//; s/"$//')" if [ -n "$url2" ] && [ "${GH_MAIN}$url2" != "$url" ]; then url="${GH_MAIN}$url2"; file="$(basename "$url")"; tmp="$JOBS_DIR/$file" echo " → На странице релиза другое имя файла: $file" continue fi sleep 3 done if [ "$ok" != 1 ]; then rm -f "$tmp" echo "!! Не удалось скачать $file — проверьте, есть ли сборка для $arch на странице релиза $tag" return 1 fi echo " ✓ Скачано ($(wc -c < "$tmp") байт)" if ! $INSTALL "$tmp" >&2; then rm -f "$tmp"; echo "!! Пакет MagiTrickle не установился"; return 1; fi rm -f "$tmp" "$ZM_STATE_DIR/latest.magitrickle2" [ ! -f "$MAGITRICKLE_CONF" ] && [ -f "$MAGITRICKLE_CONF-opkg" ] && cp -f "$MAGITRICKLE_CONF-opkg" "$MAGITRICKLE_CONF" echo " ✓ MagiTrickle $tag установлен" return 0 } _mt_latest() { curl -fsSL --connect-timeout 4 --max-time 6 -o /dev/null -w '%{url_effective}' "https://github.com/MagiTrickle/MagiTrickle/releases/latest" 2>/dev/null | sed -n 's#.*/tag/##p' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-rev[0-9]+)?$' | head -1; } _mt_installed_ver() { local v if [ "$PKG" = "apk" ]; then v=$(apk info -v 2>/dev/null | sed -n 's/^magitrickle-\([0-9].*\)$/\1/p' | head -n1) else v=$(opkg status magitrickle 2>/dev/null | awk '/^Version:/ {print $2; exit}') fi printf '%s' "$v" | sed -E 's/-r?1$//; s/-r?([0-9]+)$/-rev\1/' } _tgws_latest() { curl -fsSL --connect-timeout 4 --max-time 6 "$TGWS_VERSION_URL" 2>/dev/null | tr -d '[:space:]' | grep -E '^[vV]?[0-9]+(\.[0-9]+)+$'; } _mixomo_arch() { local arch endian_byte arch=$(uname -m) endian_byte=$(hexdump -s 5 -n 1 -e '1/1 "%d"' /bin/busybox 2>/dev/null || echo "0") case "$arch" in x86_64) if grep -q avx2 /proc/cpuinfo 2>/dev/null; then echo "amd64"; else echo "amd64-compatible"; fi ;; i?86) echo "386" ;; aarch64|arm64) echo "arm64" ;; armv7*) echo "armv7" ;; armv5*|armv4*) echo "armv5" ;; mips64*) if [ "$endian_byte" = "1" ]; then echo "mips64le"; else echo "mips64"; fi ;; mips*) local fpu floattype fpu=$(grep -c FPU /proc/cpuinfo 2>/dev/null || echo 0) floattype="softfloat" [ "$fpu" -gt 0 ] && floattype="hardfloat" if [ "$endian_byte" = "1" ]; then echo "mipsle-${floattype}"; else echo "mips-${floattype}"; fi ;; riscv64) echo "riscv64" ;; *) return 1 ;; esac } mixomo_status() { local mihomo="not_installed" mihomo_running="false" mihomo_ver="" mihomo_latest="" local magitrickle="not_installed" magitrickle_running="false" mt_ver="" mt_latest="" local hev="not_installed" hev_running="false" hev_ver="" local lan_ip subscription="false" mt_list="" autorestart="" ui_panel="" if [ -x "$MIHOMO_BIN" ]; then mihomo="installed" pidof mihomo >/dev/null 2>&1 && mihomo_running="true" mihomo_ver=$("$MIHOMO_BIN" -v 2>/dev/null | head -n1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1) mihomo_latest="$(_zm_cached mihomo _mihomo_latest)" [ -f "$MIHOMO_DIR/.ui_panel" ] && ui_panel=$(cat "$MIHOMO_DIR/.ui_panel") fi if [ -x /etc/init.d/magitrickle ]; then magitrickle="installed" /etc/init.d/magitrickle status >/dev/null 2>&1 && magitrickle_running="true" mt_ver="$(_mt_installed_ver)" mt_latest="$(_zm_cached magitrickle2 _mt_latest)" fi if [ -x /etc/init.d/hev-socks5-tunnel ]; then hev="installed" /etc/init.d/hev-socks5-tunnel status >/dev/null 2>&1 && hev_running="true" if [ "$PKG" = "apk" ]; then hev_ver=$(apk info -v 2>/dev/null | grep '^hev-socks5-tunnel-' | cut -d- -f4-) else hev_ver=$(opkg status hev-socks5-tunnel 2>/dev/null | awk '/^Version:/ {print $2}') fi fi lan_ip=$(_mixomo_lan_ip) [ -f "$MIHOMO_CONF" ] && grep -q '^[[:space:]]*[^#].*url: "' "$MIHOMO_CONF" && subscription="true" if [ -f "$MAGITRICKLE_CONF" ]; then if grep -Fq 'name: Google_ai' "$MAGITRICKLE_CONF"; then mt_list="itdog" elif grep -Fq 'name: Meta (WA+FB+Instagram)' "$MAGITRICKLE_CONF"; then mt_list="ih2" elif grep -Fq 'url: https://sw.ext.io/ipset/ipset_cf.list' "$MAGITRICKLE_CONF"; then mt_list="ih1" fi fi local cronline hourm cronline=$(grep -F "$MIXOMO_CRON_CMD" "$CRON_FILE" 2>/dev/null | head -n1) if [ -n "$cronline" ]; then hourm=$(echo "$cronline" | awk '{print $2}') case "$hourm" in */*) autorestart="every:$(echo "$hourm" | cut -d'/' -f2)" ;; *) autorestart="daily:$hourm" ;; esac fi printf '{"mihomo":"%s","mihomo_running":%s,"mihomo_version":"%s","mihomo_latest":"%s","magitrickle":"%s","magitrickle_running":%s,"magitrickle_version":"%s","magitrickle_latest":"%s","hev":"%s","hev_running":%s,"hev_version":"%s","lan_ip":"%s","subscription":%s,"magitrickle_list":"%s","autorestart":"%s","ui_panel":"%s","forkozz":%s,"bytetube":%s,"exclude":"%s"}\n' \ "$mihomo" "$mihomo_running" "$(esc "$mihomo_ver")" "$(esc "$mihomo_latest")" \ "$magitrickle" "$magitrickle_running" "$(esc "$mt_ver")" "$(esc "$mt_latest")" \ "$hev" "$hev_running" "$(esc "$hev_ver")" \ "$(esc "$lan_ip")" "$subscription" "$mt_list" "$(esc "$autorestart")" "$(esc "$ui_panel")" \ "$(_fk_present && echo true || echo false)" "$(_bt_installed && echo true || echo false)" "$(esc "$(cat "$MIXOMO_EXCL" 2>/dev/null)")" } _bt_installed() { [ -x /usr/bin/bytetube ] || [ -x /etc/init.d/bytetube ]; } _mx_installed() { [ -x "$MIHOMO_BIN" ] || [ -x /etc/init.d/mihomo ]; } _mt_apply_list() { local new="$MAGITRICKLE_CONF.new" out="$MAGITRICKLE_CONF.zmtmp" mkdir -p "$(dirname "$MAGITRICKLE_CONF")" rm -f "$new" "$out" wget -q --timeout=20 -O "$new" "$1" 2>/dev/null || curl -fsSL --connect-timeout 8 --max-time 30 -o "$new" "$1" 2>/dev/null if [ ! -s "$new" ] || ! grep -q '^groups:' "$new"; then rm -f "$new" return 1 fi if [ -s "$MAGITRICKLE_CONF" ] && grep -q '^app:' "$MAGITRICKLE_CONF"; then { awk '/^(groups|subscriptions):/ { exit } { print }' "$MAGITRICKLE_CONF" awk '/^groups:/ { on = 1 } on' "$new" } > "$out" else cp -f "$new" "$out" fi rm -f "$new" [ -s "$out" ] && grep -q '^groups:' "$out" || { rm -f "$out"; return 1; } mv -f "$out" "$MAGITRICKLE_CONF" } _mt_wait_running() { local i=0 while [ "$i" -lt 10 ]; do pidof magitrickled >/dev/null 2>&1 && return 0 sleep 1; i=$((i + 1)) done return 1 } _mt_list_known() { [ -f "$MAGITRICKLE_CONF" ] || return 1 grep -Fq 'name: Google_ai' "$MAGITRICKLE_CONF" || grep -Fq 'name: Meta (WA+FB+Instagram)' "$MAGITRICKLE_CONF" || grep -Fq 'url: https://sw.ext.io/ipset/ipset_cf.list' "$MAGITRICKLE_CONF" } do_mixomo_install() { if [ ! -x "$MIHOMO_BIN" ] && _fk_present; then echo "ОШИБКА: стоит Forkozz — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите Forkozz" return 1 fi _ensure_deps echo "==> Устанавливаем Mixomo (Mihomo + hev-socks5-tunnel + MagiTrickle)" echo "==> Устанавливаем зависимости (unzip, ca-certificates, модули ядра TUN/nftables)" $UPDATE >&2 if [ "$PKG" = "apk" ]; then $INSTALL unzip ca-certificates kmod-tun kmod-nft-tproxy kmod-nft-nat curl >&2 else $INSTALL unzip ca-certificates kmod-tun kmod-nft-tproxy kmod-nft-nat curl libcurl4 ca-bundle >&2 fi command -v curl >/dev/null 2>&1 || { echo "ОШИБКА: не найден curl"; return 1; } if [ ! -f /etc/ssl/certs/ca-certificates.crt ] && [ ! -f /etc/ssl/certs/ca-bundle.crt ]; then echo "ОШИБКА: не найден пакет ca-certificates" return 1 fi if [ ! -c /dev/net/tun ]; then modprobe tun >/dev/null 2>&1 [ -c /dev/net/tun ] || { echo "ОШИБКА: в ядре нет поддержки TUN"; return 1; } fi local avail_tmp avail_root avail_tmp=$(df -k /tmp | awk 'NR==2{print $4}') if [ "$avail_tmp" -lt 16000 ]; then echo "ОШИБКА: недостаточно места в /tmp (нужно около 16 МБ свободных)" return 1 fi avail_root=$(df -k /usr/bin | awk 'NR==2{print $4}') if [ "$avail_root" -lt 18000 ]; then echo "ОШИБКА: недостаточно места на диске (нужно около 18 МБ свободных)" return 1 fi [ -f /etc/init.d/mihomo ] && /etc/init.d/mihomo stop >/dev/null 2>&1 local arch arch=$(_mixomo_arch) || { echo "ОШИБКА: архитектура $(uname -m) не распознана"; return 1; } echo "==> Архитектура: $(uname -m) -> $arch" mkdir -p "$MIHOMO_DIR" "$MIHOMO_DIR/proxy-providers" "$MIHOMO_DIR/rule-providers" "$MIHOMO_DIR/rule-files" echo "$arch" > "$MIHOMO_DIR/.arch" echo "==> Определяем последнюю версию Mihomo" local tag tag=$(curl -Ls --connect-timeout 5 --max-time 10 -o /dev/null -w '%{url_effective}' "https://github.com/MetaCubeX/mihomo/releases/latest" 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1) [ -n "$tag" ] || { echo "ОШИБКА: не удалось определить версию Mihomo"; return 1; } echo "==> Версия: $tag" local file url tmp file="mihomo-linux-${arch}-${tag}.gz" url="${GH_MAIN}/MetaCubeX/mihomo/releases/download/${tag}/${file}" tmp="/tmp/mihomo.gz" echo "==> Скачиваем $file" curl -sSLf --connect-timeout 5 --max-time 90 --retry 3 --retry-delay 2 "$url" -o "$tmp" >&2 || { echo "ОШИБКА: не удалось скачать $file ($url)"; return 1; } gunzip -c "$tmp" > "$MIHOMO_BIN" 2>/dev/null || { echo "ОШИБКА: не удалось распаковать архив"; rm -f "$tmp"; return 1; } chmod +x "$MIHOMO_BIN" rm -f "$tmp" "$MIHOMO_BIN" -v >/dev/null 2>&1 || { echo "ОШИБКА: ядро Mihomo не запускается — возможно, неверная архитектура"; return 1; } if [ -f "$MIHOMO_CONF" ] && grep -q "mixed-port: 7890" "$MIHOMO_CONF"; then echo "==> Используем существующую конфигурацию" else if [ -f "$MIHOMO_CONF" ]; then cp "$MIHOMO_CONF" "$MIHOMO_CONF.bak" echo "==> Найден старый конфиг без mixed-port — сохранён как config.yaml.bak" fi echo "==> Создаём базовую конфигурацию" printf '%s\n' \ 'mode: rule' 'ipv6: false' 'mixed-port: 7890' 'log-level: error' 'allow-lan: true' \ 'unified-delay: true' 'tcp-concurrent: false' 'find-process-mode: off' \ 'external-controller: 0.0.0.0:9090' 'external-ui: ./ui' 'routing-mark: 2' \ 'profile:' ' store-selected: true' ' store-fake-ip: true' ' tracing: true' \ 'sniffer:' ' enable: true' ' force-dns-mapping: true' ' parse-pure-ip: true' \ ' sniff:' ' HTTP:' ' ports: [80]' ' override-destination: true' \ ' TLS:' ' ports: [443, 8443]' ' QUIC:' ' ports: [443, 8443]' \ ' skip-domain:' ' - Mijia Cloud' ' - +.lan' ' - +.local' \ ' - +.msftconnecttest.com' ' - +.msftncsi.com' ' - +.3gppnetwork.org' \ ' - +.openwrt.org' ' - +.vsean.net' ' - cudy.net' '' \ 'dns:' ' enable: true' ' listen: 0.0.0.0:7880' ' ipv6: false' ' nameserver:' \ ' - https://8.8.8.8/dns-query' ' - https://8.8.4.4/dns-query' \ ' - https://1.1.1.1/dns-query' ' - https://1.0.0.1/dns-query' \ ' - https://9.9.9.9/dns-query' ' - https://149.112.112.112/dns-query' \ ' - https://94.140.14.140/dns-query' ' - https://94.140.14.141/dns-query' \ ' - https://77.88.8.8/dns-query' ' - https://77.88.8.1/dns-query' '' \ 'proxies:' ' - name: Домашний интернет' ' type: direct' '' \ 'proxy-groups:' '' 'rule-providers:' '' 'rules:' ' - MATCH,Домашний интернет' \ > "$MIHOMO_CONF" fi echo "==> Создаём службу mihomo" printf '%s\n' '#!/bin/sh /etc/rc.common' 'START=99' 'USE_PROCD=1' '' \ "MIHOMO_BIN=\"$MIHOMO_BIN\"" "MIHOMO_DIR=\"$MIHOMO_DIR\"" "MIHOMO_CONF=\"$MIHOMO_CONF\"" '' \ 'start_service() {' ' [ -x "$MIHOMO_BIN" ] || return 1' ' [ -s "$MIHOMO_CONF" ] || return 1' '' \ ' procd_open_instance "main"' ' procd_set_param command "$MIHOMO_BIN" -d "$MIHOMO_DIR" -f "$MIHOMO_CONF"' \ ' procd_set_param stdout 1' ' procd_set_param stderr 1' ' procd_set_param respawn' ' procd_close_instance' '}' '' \ 'service_triggers() {' ' procd_add_reload_trigger "mihomo"' '}' \ > /etc/init.d/mihomo chmod +x /etc/init.d/mihomo /etc/init.d/mihomo enable >/dev/null 2>&1 echo "==> Устанавливаем hev-socks5-tunnel" $UPDATE >&2 $INSTALL hev-socks5-tunnel >&2 if [ "$PKG" = "apk" ]; then apk info -e hev-socks5-tunnel >/dev/null 2>&1 || { echo "ОШИБКА: не удалось установить hev-socks5-tunnel — проверьте интернет и репозитории пакетов"; return 1; } else opkg list-installed 2>/dev/null | grep -q '^hev-socks5-tunnel ' || { echo "ОШИБКА: не удалось установить hev-socks5-tunnel — проверьте интернет и репозитории пакетов"; return 1; } fi mkdir -p /etc/hev-socks5-tunnel printf '%s\n' 'tunnel:' ' name: Mihomo' ' mtu: 8500' ' multi-queue: false' ' ipv4: 198.18.0.1' \ 'socks5:' ' port: 7890' ' address: 127.0.0.1' " udp: 'udp'" > /etc/hev-socks5-tunnel/main.yml chmod 600 /etc/hev-socks5-tunnel/main.yml echo "==> Настраиваем сетевой интерфейс и firewall" uci -q delete network.Mihomo local fw_section for fw_section in $(uci show firewall 2>/dev/null | grep -E "\.name='Mihomo'" | sed "s/\.name.*//"); do uci -q delete "$fw_section" done for fw_section in $(uci show firewall 2>/dev/null | grep -E "\.(src|dest)='Mihomo'" | sed -E "s/\.(src|dest).*//"); do uci -q delete "$fw_section" done uci -q delete firewall.Mihomo uci -q delete firewall.lan_to_Mihomo uci commit firewall /etc/init.d/firewall restart >/dev/null 2>&1 sleep 1 if ! uci -q get hev-socks5-tunnel.@instance[0] >/dev/null 2>&1; then uci add hev-socks5-tunnel instance >/dev/null fi uci set hev-socks5-tunnel.@instance[0].enabled='1' uci set hev-socks5-tunnel.@instance[0].conffile='/etc/hev-socks5-tunnel/main.yml' uci commit hev-socks5-tunnel /etc/init.d/hev-socks5-tunnel restart >/dev/null 2>&1 sleep 2 uci set network.Mihomo=interface uci set network.Mihomo.proto='none' uci set network.Mihomo.device='Mihomo' uci commit network /etc/init.d/network reload >/dev/null 2>&1 local fw_zone fw_fwd fw_zone=$(uci add firewall zone) uci set "firewall.${fw_zone}.name=Mihomo" uci set "firewall.${fw_zone}.input=REJECT" uci set "firewall.${fw_zone}.output=REJECT" uci set "firewall.${fw_zone}.forward=REJECT" uci set "firewall.${fw_zone}.masq=1" uci set "firewall.${fw_zone}.mtu_fix=1" uci add_list "firewall.${fw_zone}.network=Mihomo" fw_fwd=$(uci add firewall forwarding) uci set "firewall.${fw_fwd}.src=$(_zm_lan_zone)" uci set "firewall.${fw_fwd}.dest=Mihomo" uci commit firewall /etc/init.d/firewall restart >/dev/null 2>&1 echo "==> Устанавливаем MagiTrickle" _mt_install || echo "!! MagiTrickle не установился — Mixomo работает и без него, поставить можно позже кнопкой «Переустановить/обновить»" if [ -x /etc/init.d/magitrickle ] && ! _mt_list_known; then echo "==> Включаем список Internet Helper в MagiTrickle" _mt_apply_list "$MT_URL_IH1" || echo "!! Не удалось скачать список Internet Helper — его можно выбрать позже на вкладке Mixomo" fi echo "==> Запускаем сервисы" /etc/init.d/mihomo restart >/dev/null 2>&1 if [ -x /etc/init.d/magitrickle ]; then /etc/init.d/magitrickle enable >/dev/null 2>&1 /etc/init.d/magitrickle restart >/dev/null 2>&1 _mt_wait_running || echo "!! MagiTrickle перезапущен, но процесс magitrickled не найден — проверьте системный журнал" fi if [ ! -f "$MIHOMO_DIR/.ui_panel" ]; then echo "==> Устанавливаем веб-панель MetaCubeXD (по умолчанию)" do_mixomo_ui_install metacubexd || echo "!! Не удалось установить веб-панель — можно поставить позже вручную" fi echo "==> Готово, Mixomo установлен" } do_mixomo_remove() { local fw_section echo "==> Останавливаем Mihomo" _zm_svc_off mihomo killall mihomo >/dev/null 2>&1 echo "==> Удаляем Mihomo, его настройки, подписку и веб-панель" rm -f /etc/init.d/mihomo /etc/rc.d/*mihomo "$MIHOMO_BIN" if [ -s "$MIXOMO_WARP_CONF" ]; then if command -v awg >/dev/null 2>&1 || [ -d /etc/zm-awg ] || [ -s /etc/zm-steer/owned ]; then echo " · $MIXOMO_WARP_CONF оставляем — он нужен AmneziaWG/Steer" else rm -f "$MIXOMO_WARP_CONF" fi fi rm -rf "$MIHOMO_DIR" /tmp/mihomo* echo " ✓ Файлы Mihomo удалены" echo "==> Убираем hev-socks5-tunnel" _zm_svc_off hev-socks5-tunnel if [ -x /etc/init.d/bytetube ]; then echo " · hev-socks5-tunnel нужен ByeTube — пакет оставляем, убираем только настройки Mixomo" else $DELETE hev-socks5-tunnel fi rm -rf /etc/hev-socks5-tunnel uci -q delete hev-socks5-tunnel.@instance[0] uci commit hev-socks5-tunnel 2>/dev/null echo "==> Удаляем интерфейс Mihomo и зону файрвола" uci -q delete network.Mihomo for fw_section in $(uci show firewall 2>/dev/null | grep -E "\.name='Mihomo'" | sed "s/\.name.*//"); do uci -q delete "$fw_section" done for fw_section in $(uci show firewall 2>/dev/null | grep -E "\.(src|dest)='Mihomo'" | sed -E "s/\.(src|dest).*//"); do uci -q delete "$fw_section" done uci -q delete firewall.Mihomo uci -q delete firewall.lan_to_Mihomo uci commit network uci commit firewall /etc/init.d/network reload >/dev/null 2>&1 /etc/init.d/firewall restart >/dev/null 2>&1 echo " ✓ Интерфейс и правила файрвола убраны" echo "==> Удаляем MagiTrickle и его списки" _zm_svc_off magitrickle nft delete table inet magitrickle >/dev/null 2>&1 $DELETE magitrickle rm -rf /etc/magitrickle /etc/init.d/magitrickle /etc/rc.d/*magitrickle _zm_cron_drop 'mihomo|magitrickle' "Mixomo" /etc/init.d/dnsmasq restart >/dev/null 2>&1 echo "==> Mixomo удалён полностью" _zm_after_remove "Mixomo" } mixomo_action() { local action="$1" case "$action" in install|update) if [ ! -x "$MIHOMO_BIN" ] && _fk_present; then echo '{"error":"Стоит Forkozz — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите Forkozz."}' return 1 fi if ! _mx_installed && _bt_installed; then echo '{"error":"Стоит ByeTube — он тоже использует hev-socks5-tunnel. Чтобы поставить Mixomo, удалите ByeTube."}' return 1 fi job_start mixomo_install do_mixomo_install ;; remove) job_start mixomo_remove do_mixomo_remove ;; start) [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } /etc/init.d/mihomo start >/dev/null 2>&1 [ -x /etc/init.d/hev-socks5-tunnel ] && /etc/init.d/hev-socks5-tunnel start >/dev/null 2>&1 [ -x /etc/init.d/magitrickle ] && /etc/init.d/magitrickle start >/dev/null 2>&1 mixomo_status ;; stop) [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } [ -x /etc/init.d/magitrickle ] && /etc/init.d/magitrickle stop >/dev/null 2>&1 [ -x /etc/init.d/hev-socks5-tunnel ] && /etc/init.d/hev-socks5-tunnel stop >/dev/null 2>&1 /etc/init.d/mihomo stop >/dev/null 2>&1 mixomo_status ;; restart) [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } /etc/init.d/mihomo restart >/dev/null 2>&1 [ -x /etc/init.d/hev-socks5-tunnel ] && /etc/init.d/hev-socks5-tunnel restart >/dev/null 2>&1 [ -x /etc/init.d/magitrickle ] && /etc/init.d/magitrickle restart >/dev/null 2>&1 mixomo_status ;; *) echo '{"error":"неизвестное действие"}' ;; esac } mixomo_config_get() { [ -f "$MIHOMO_CONF" ] || { echo '{"error":"конфигурация не найдена"}'; return 1; } printf '{"content":"%s"}\n' "$(esc_ml "$(cat "$MIHOMO_CONF")")" } mixomo_config_set() { local content="$1" [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } cp "$MIHOMO_CONF" "$MIHOMO_CONF.bak" 2>/dev/null printf '%s' "$content" > "$MIHOMO_CONF" { [ -s "$MIXOMO_EXCL" ] || [ -s "$MIXOMO_HIDE" ]; } && _mx_filter_apply /etc/init.d/mihomo restart >/dev/null 2>&1 sleep 1 if pidof mihomo >/dev/null 2>&1; then printf '{"ok":true}\n' else cp "$MIHOMO_CONF.bak" "$MIHOMO_CONF" 2>/dev/null /etc/init.d/mihomo restart >/dev/null 2>&1 echo '{"error":"mihomo не запустился с новой конфигурацией — изменения отменены, проверьте синтаксис"}' return 1 fi } _mx_filter_rx() { ZM_EXCL="$(head -n1 "$MIXOMO_EXCL" 2>/dev/null)" ZM_HIDE="$MIXOMO_HIDE" awk ' function plain(x) { gsub(/[][\\^$.|?*+(){}`"]/, ".", x); return x } BEGIN { o = "" c = split(ENVIRON["ZM_EXCL"], a, /[|,]/) for (i = 1; i <= c; i++) { t = a[i]; gsub(/^[ \t]+|[ \t]+$/, "", t); if (t != "") o = o (o == "" ? "" : "|") plain(t) } f = ENVIRON["ZM_HIDE"] while ((getline l < f) > 0) if (l != "" && !(l in hid)) { hid[l] = 1; o = o (o == "" ? "" : "|") "^" plain(l) "$" } printf "%s", o }' } _mx_filter_cur() { awk ' { t = $0; sub(/\r$/, "", t) } t ~ /^[^ \t#]/ { inpp = (t ~ /^proxy-providers:[ \t]*(#.*)?$/); next } inpp && t ~ /^[ \t]+exclude-filter:/ { sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t); print t; exit } ' "$MIHOMO_CONF" 2>/dev/null } _mx_filter_val() { printf '%s\n' "$1" | awk '{ s = $0; sub(/^exclude-filter:[ \t]*/, "", s); q = substr(s, 1, 1); o = ""; n = length(s) if (q == "\"") { for (i = 2; i <= n; i++) { c = substr(s, i, 1); if (c == "\\") { o = o c substr(s, i + 1, 1); i++; continue } if (c == "\"") break; o = o c } } else { if (q == "\047") { st = 2 } else { st = 1; sub(/[ \t]+#.*$/, "", s); sub(/[ \t]+$/, "", s); n = length(s) } for (i = st; i <= n; i++) { c = substr(s, i, 1) if (q == "\047" && c == "\047") { if (substr(s, i + 1, 1) == "\047") { o = o c; i++; continue } break } if (c == "\\") o = o "\\\\"; else if (c == "\"") o = o "\\\""; else o = o c } } print o }' } _mx_api() { local port sec local ec host port sec ec="$(sed -n 's/^external-controller:[[:space:]]*//p' "$MIHOMO_CONF" 2>/dev/null | head -n1 | sed 's/[[:space:]]*#.*$//; s/^["'\'']//; s/["'\''][[:space:]]*$//; s/[[:space:]]*$//')" port="${ec##*:}" case "$port" in ''|*[!0-9]*) port=9090 ;; esac host="${ec%:*}"; host="${host#[}"; host="${host%]}" case "$host" in ''|0.0.0.0|::|'*'|localhost) host=127.0.0.1 ;; esac case "$host" in *:*) host="[$host]" ;; esac sec="$(sed -n 's/^secret:[[:space:]]*//p' "$MIHOMO_CONF" 2>/dev/null | head -n1)" case "$sec" in \"*) sec="${sec#\"}"; sec="${sec%%\"*}" ;; \'*) sec="${sec#\'}"; sec="${sec%%\'*}" ;; *) sec="$(printf '%s' "$sec" | sed 's/[[:space:]]*#.*$//; s/[[:space:]]*$//')" ;; esac curl -s --connect-timeout 3 --max-time 8 ${sec:+-H "Authorization: Bearer $sec"} "http://$host:$port$1" 2>/dev/null } _mx_names_tail() { printf ',"hide_names":[%s],"exclude":"%s","all_names":[%s],"own_filter":"%s"}\n' \ "$(_zm_json_lines "$MIXOMO_HIDE")" "$(esc "$(cat "$MIXOMO_EXCL" 2>/dev/null)")" \ "$(_zm_json_lines "$MIHOMO_DIR/.zm_names_all")" "$(esc "$(head -n1 "$MIHOMO_DIR/.zm_filter_user" 2>/dev/null)")" } mixomo_proxies() { local j nf="$MIHOMO_DIR/.zm_names" af="$MIHOMO_DIR/.zm_names_all" [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } pidof mihomo >/dev/null 2>&1 || { printf '{"error":"Mihomo не запущен"'; _mx_names_tail; return 0; } j="$(_mx_api /providers/proxies | tr '\n' ' ')" case "$j" in *'"providers"'*) if printf '%s' "$j" | jsonfilter -e '@.providers' >/dev/null 2>&1; then printf '%s' "$j" | jsonfilter -e '@.providers[@.vehicleType!="Compatible"].proxies[*].name' 2>/dev/null | grep -v '^$' > "$nf.new" if [ -s "$nf.new" ]; then mv -f "$nf.new" "$nf" cat "$af" "$nf" 2>/dev/null | awk 'NF && !s[$0]++' | tail -n 1000 > "$af.new" && mv -f "$af.new" "$af" fi rm -f "$nf.new" else printf '{"error":"Mihomo отдал неполный список серверов — нажмите «Обновить список»"'; _mx_names_tail; return 0 fi ;; '{'*'"message"'*) printf '{"error":"Mihomo не пустил к списку серверов — проверьте secret в конфигурации"'; _mx_names_tail; return 0 ;; *) j=null ;; esac printf '{"data":%s' "$j"; _mx_names_tail } _mx_filter_apply() { local rx line tmp prev="" cur user="" uf="$MIHOMO_DIR/.zm_filter_user" [ -f "$MIHOMO_CONF" ] || return 0 grep -q '^proxy-providers:' "$MIHOMO_CONF" || return 0 [ -s "$MIHOMO_DIR/.zm_filter_line" ] && prev="$(cat "$MIHOMO_DIR/.zm_filter_line")" cur="$(_mx_filter_cur)" case "$cur" in *'# zm-filter') cur="" ;; esac if [ -n "$cur" ] && [ "$cur" != "$prev" ]; then user="$(_mx_filter_val "$cur")" if [ -n "$user" ]; then printf '%s\n' "$user" > "$uf"; else rm -f "$uf"; fi fi [ -s "$uf" ] && user="$(head -n1 "$uf")" rx="$(_mx_filter_rx)" [ -n "$user" ] && rx="$user${rx:+|$rx}" line="" [ -n "$rx" ] && line="exclude-filter: \"$rx\"" tmp="$MIHOMO_CONF.zm" ZM_LINE="$line" ZM_PREV="$prev" awk ' function ind(x) { match(x, /^ */); return RLENGTH } BEGIN { line = ENVIRON["ZM_LINE"]; prev = ENVIRON["ZM_PREV"] } { raw = $0; t = raw; cr = sub(/\r$/, "", t) blank = (t ~ /^[ \t]*(#.*)?$/) if (!blank && t ~ /^[^ \t]/) { inpp = (t ~ /^proxy-providers:[ \t]*(#.*)?$/); pind = -1; pend = 0; print raw; next } if (!inpp || blank) { print raw; next } i = ind(t) if (pind < 0) pind = i if (i == pind) { pend = (line != "" && t ~ /:[ \t]*(#.*)?$/); print raw; next } if (t ~ /^[ \t]*exclude-filter:/) next if (pend) { sp = ""; for (k = 0; k < i; k++) sp = sp " "; print sp line (cr ? "\r" : ""); pend = 0 } print raw }' "$MIHOMO_CONF" > "$tmp" && [ -s "$tmp" ] && mv -f "$tmp" "$MIHOMO_CONF" || { rm -f "$tmp"; return 0; } if [ -n "$line" ]; then printf '%s\n' "$line" > "$MIHOMO_DIR/.zm_filter_line"; else rm -f "$MIHOMO_DIR/.zm_filter_line"; fi } mixomo_filter_set() { local m="$1" was=0 nf="$JOBS_DIR/mixomo.hide.in" mk="" hasmk=0 [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } case "$m" in '{'*) mkdir -p "$MIHOMO_DIR" if [ "$(printf '%s' "$m" | jsonfilter -e '@.reset' 2>/dev/null)" = true ]; then rm -f "$MIXOMO_HIDE" "$MIXOMO_EXCL" else case "$m" in *'"markers":'*|*'"markers" :'*) mk="$(printf '%s' "$m" | jsonfilter -e '@.markers' 2>/dev/null)"; hasmk=1 ;; esac if [ "$hasmk" = 1 ]; then case "$mk" in *" "*|*"$(printf '\r')"*) echo '{"error":"фразы — одной строкой через |"}'; return 1 ;; esac [ "${#mk}" -le 1600 ] || { echo '{"error":"слишком длинный список фраз"}'; return 1; } fi printf '%s' "$m" | jsonfilter -e '@.names[*]' > "$nf" 2>/dev/null rm -f "$nf.bak" [ -f "$MIXOMO_HIDE" ] && cp -f "$MIXOMO_HIDE" "$nf.bak" _zm_names_save "$nf" "$MIXOMO_HIDE" rm -f "$nf" fi if [ "$hasmk" = 1 ]; then m="$mk"; else m="$(cat "$MIXOMO_EXCL" 2>/dev/null)"; fi ;; esac case "$m" in *" "*) echo '{"error":"маркеры — одной строкой через |"}'; return 1 ;; esac [ "${#m}" -le 1600 ] || { echo '{"error":"слишком длинный список маркеров"}'; return 1; } mkdir -p "$MIHOMO_DIR" if [ -s "$MIHOMO_DIR/.zm_names_all" ] && ZM_EXCL="$m" ZM_HIDE="$MIXOMO_HIDE" awk ' BEGIN { c = split(ENVIRON["ZM_EXCL"], a, /[|,]/); n = 0 for (i = 1; i <= c; i++) { t = a[i]; gsub(/^[ \t]+|[ \t]+$/, "", t); if (t != "") m[++n] = t } f = ENVIRON["ZM_HIDE"]; while ((getline l < f) > 0) if (l != "") h[l] = 1 } NF { tot++; if ($0 in h) next; for (i = 1; i <= n; i++) if (index($0, m[i])) next; left++ } END { exit !(tot > 0 && left == 0) }' "$MIHOMO_DIR/.zm_names_all"; then if [ -f "$nf.bak" ]; then mv -f "$nf.bak" "$MIXOMO_HIDE"; else rm -f "$MIXOMO_HIDE"; fi echo '{"error":"так исключаются все серверы подписки — тогда Mihomo останется без серверов. Оставьте хотя бы один"}' return 1 fi rm -f "$nf.bak" if [ -n "$(printf '%s' "$m" | tr -d ' |,')" ]; then printf '%s\n' "$m" > "$MIXOMO_EXCL"; else rm -f "$MIXOMO_EXCL"; fi [ -s "$MIXOMO_HIDE" ] || [ -s "$MIXOMO_EXCL" ] || m="" grep -q '^proxy-providers:' "$MIHOMO_CONF" 2>/dev/null || { printf '{"ok":true,"providers":false}\n'; return 0; } _mx_filter_apply if { [ -s "$MIXOMO_EXCL" ] || [ -s "$MIXOMO_HIDE" ]; } && ! grep -q '^[[:space:]]*exclude-filter:' "$MIHOMO_CONF" 2>/dev/null; then printf '{"ok":true,"providers":false}\n' return 0 fi pidof mihomo >/dev/null 2>&1 && was=1 [ "$was" = 1 ] && _zm_run 60 /etc/init.d/mihomo restart >/dev/null 2>&1 printf '{"ok":true,"providers":true,"restarted":%s}\n' "$([ "$was" = 1 ] && echo true || echo false)" } mixomo_subscription_set() { local sub_url="$1" case "$sub_url" in http://*|https://*) ;; *) echo '{"error":"ссылка должна начинаться с http:// или https://"}'; return 1 ;; esac case "$sub_url" in *[[:space:]\"\\]*) echo '{"error":"в ссылке не должно быть пробелов, кавычек и обратных косых черт"}'; return 1 ;; esac [ -x /etc/init.d/mihomo ] || { echo '{"error":"Mixomo не установлен"}'; return 1; } /etc/init.d/mihomo stop >/dev/null 2>&1 rm -rf "$MIHOMO_DIR/proxy-providers" "$MIHOMO_DIR/proxies" rm -f "$MIHOMO_DIR/.zm_names" "$MIHOMO_DIR/.zm_names_all" if grep -q '^[[:space:]]*proxy-providers:' "$MIHOMO_CONF" 2>/dev/null; then local tmp tmp=$(mktemp) if ZM_URL="$sub_url" awk 'BEGIN { url = ENVIRON["ZM_URL"]; updated = 0; in_provider = 0; section = "" } { if ($0 ~ /^[a-zA-Z_-]+:/) { section = $0; sub(/:.*/, "", section) } if (!updated && section == "proxy-providers" && $0 ~ /^[[:space:]]*type:[[:space:]]*http[[:space:]]*$/) { in_provider = 1; print; next } if (!updated && in_provider && $0 ~ /^[[:space:]]*url:[[:space:]]*["\047]?https?:/) { match($0, /^[[:space:]]*/); print substr($0, 1, RLENGTH) "url: \"" url "\""; updated = 1; in_provider = 0; next } print } END { exit (updated ? 0 : 1) }' "$MIHOMO_CONF" > "$tmp" then mv "$tmp" "$MIHOMO_CONF" { [ -s "$MIXOMO_EXCL" ] || [ -s "$MIXOMO_HIDE" ]; } && _mx_filter_apply /etc/init.d/mihomo restart >/dev/null 2>&1 printf '{"ok":true,"mode":"updated"}\n' return 0 fi rm -f "$tmp" fi printf '%s\n' \ 'mixed-port: 7890' 'allow-lan: false' 'tcp-concurrent: true' 'mode: rule' \ 'log-level: info' 'ipv6: false' 'external-controller: 0.0.0.0:9090' \ 'external-ui: ui' 'secret:' 'unified-delay: true' \ 'profile:' ' store-selected: true' ' store-fake-ip: true' '' \ 'proxy-groups:' ' - name: GLOBAL' ' type: select' ' proxies:' \ ' - DIRECT' ' use:' ' - Подписка' '' \ ' - name: YouTube' ' type: select' ' proxies:' \ ' - DIRECT' ' use:' ' - Подписка' '' \ 'rules:' ' - RULE-SET,youtube,YouTube' ' - MATCH,GLOBAL' '' \ 'proxy-providers:' ' Подписка:' ' type: http' ' proxy: DIRECT' \ ' header:' ' x-hwid:' ' - b6e2d53b1b2c8618719f42fb95ab1e43' \ " url: \"$sub_url\"" ' interval: 43200' ' health-check:' \ ' enable: true' ' interval: 300' \ ' url: "https://google.com/generate_204"' ' expected-status: 204' '' \ 'rule-providers:' ' youtube:' ' type: http' ' format: yaml' \ ' behavior: classical' \ ' url: "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/YouTube/YouTube.yaml"' \ ' path: ./rule-providers/youtube.yaml' ' interval: 86400' \ > "$MIHOMO_CONF" rm -f "$MIHOMO_DIR/.zm_filter_user" "$MIHOMO_DIR/.zm_filter_line" { [ -s "$MIXOMO_EXCL" ] || [ -s "$MIXOMO_HIDE" ]; } && _mx_filter_apply /etc/init.d/mihomo restart >/dev/null 2>&1 printf '{"ok":true,"mode":"created"}\n' } mixomo_magitrickle_list_set() { local id="$1" url case "$id" in itdog) url="$MT_URL_ITDOG" ;; ih1) url="$MT_URL_IH1" ;; ih2) url="$MT_URL_IH2" ;; *) echo '{"error":"неизвестный список"}'; return 1 ;; esac [ -x /etc/init.d/magitrickle ] || { echo '{"error":"MagiTrickle не установлен"}'; return 1; } _mt_apply_list "$url" || { echo '{"error":"не удалось скачать список — текущие группы MagiTrickle не тронуты"}'; return 1; } /etc/init.d/magitrickle enable >/dev/null 2>&1 /etc/init.d/magitrickle restart >/dev/null 2>&1 [ -x /etc/init.d/mihomo ] && /etc/init.d/mihomo restart >/dev/null 2>&1 printf '{"ok":true}\n' } mixomo_autorestart_set() { local mode="$1" value="$2" mkdir -p "$(dirname "$CRON_FILE")" sed -i "\\|$MIXOMO_CRON_CMD|d" "$CRON_FILE" 2>/dev/null case "$mode" in off) ;; every) case "$value" in 2|4|6|8|10|12|14|16|18|20|22) echo "0 */$value * * * $MIXOMO_CRON_CMD" >> "$CRON_FILE" ;; *) echo '{"error":"допустимы только чётные значения от 2 до 22"}'; return 1 ;; esac ;; daily) case "$value" in ''|*[!0-9]*) echo '{"error":"введите число от 0 до 23"}'; return 1 ;; esac if [ "$value" -lt 0 ] || [ "$value" -gt 23 ]; then echo '{"error":"допустимый диапазон 0-23"}' return 1 fi echo "0 $value * * * $MIXOMO_CRON_CMD" >> "$CRON_FILE" ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac /etc/init.d/cron restart >/dev/null 2>&1 printf '{"ok":true}\n' } do_mixomo_ui_install() { local which="$1" [ -d "$MIHOMO_DIR" ] || { echo "ОШИБКА: Mixomo не установлен"; return 1; } rm -rf "$MIHOMO_DIR/ui" mkdir -p "$MIHOMO_DIR/ui" case "$which" in zashboard) echo "==> Скачиваем Zashboard" local tmp="/tmp/zashboard.zip" ok=0 i for i in 1 2 3; do curl -sSfL --connect-timeout 3 --max-time 7 -o "$tmp" "${GH_MAIN}/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip" >&2 && { ok=1; break; } sleep 1 done [ "$ok" = "1" ] || { echo "ОШИБКА: не удалось скачать Zashboard"; return 1; } command -v unzip >/dev/null 2>&1 || $INSTALL unzip >&2 rm -rf /tmp/zashboard unzip -oq "$tmp" -d /tmp/zashboard || { echo "ОШИБКА: не удалось распаковать архив"; rm -rf "$tmp" /tmp/zashboard; return 1; } cp -r /tmp/zashboard/dist/* "$MIHOMO_DIR/ui/" rm -rf "$tmp" /tmp/zashboard echo "zashboard" > "$MIHOMO_DIR/.ui_panel" echo "==> Готово, Zashboard установлен" ;; metacubexd) echo "==> Скачиваем MetaCubeXD" local tmp="/tmp/metacubexd.tgz" ok=0 i for i in 1 2 3; do curl -sSfL --connect-timeout 3 --max-time 7 -o "$tmp" "${GH_MAIN}/MetaCubeX/metacubexd/releases/latest/download/compressed-dist.tgz" >&2 && { ok=1; break; } sleep 1 done [ "$ok" = "1" ] || { echo "ОШИБКА: не удалось скачать MetaCubeXD"; return 1; } rm -rf /tmp/metacubexd; mkdir -p /tmp/metacubexd tar -xzf "$tmp" -C /tmp/metacubexd || { echo "ОШИБКА: не удалось распаковать архив"; rm -rf "$tmp" /tmp/metacubexd; return 1; } cp -r /tmp/metacubexd/* "$MIHOMO_DIR/ui/" rm -rf "$tmp" /tmp/metacubexd echo "metacubexd" > "$MIHOMO_DIR/.ui_panel" echo "==> Готово, MetaCubeXD установлен" ;; *) echo "ОШИБКА: неизвестная панель"; return 1 ;; esac } mixomo_ui_action() { job_start mixomo_ui_install do_mixomo_ui_install "$1" } MIXOMO_WARP_CONF="/root/WARP.conf" MIXOMO_EXCL="$MIHOMO_DIR/.zm_exclude" MIXOMO_HIDE="$MIHOMO_DIR/.zm_hide" MIXOMO_WARP_PRIMARY="https://santa-atmo.ru/warp/warp.php" MIXOMO_AWG_JC=4 MIXOMO_AWG_JMIN=40 MIXOMO_AWG_JMAX=70 MIXOMO_AWG_H1=1 MIXOMO_AWG_H2=2 MIXOMO_AWG_H3=3 MIXOMO_AWG_H4=4 MIXOMO_AWG_S1=0 MIXOMO_AWG_S2=0 MIXOMO_AWG_I1="" mixomo_warp_status() { local exists="false" content="" if [ -s "$MIXOMO_WARP_CONF" ]; then exists="true" content=$(cat "$MIXOMO_WARP_CONF") fi printf '{"exists":%s,"content":"%s"}\n' "$exists" "$(esc_ml "$content")" } mixomo_warp_config_set() { local content="$1" [ -n "$content" ] || { echo '{"error":"пустая конфигурация — сохранение отменено"}'; return 1; } echo "$content" | grep -q '^\[Interface\]' || { echo '{"error":"файл должен начинаться с секции [Interface]"}'; return 1; } echo "$content" | grep -q '^\[Peer\]' || { echo '{"error":"в файле нет секции [Peer]"}'; return 1; } echo "$content" | grep -q '^PrivateKey' || { echo '{"error":"в секции [Interface] нет PrivateKey"}'; return 1; } echo "$content" | grep -q '^PublicKey' || { echo '{"error":"в секции [Peer] нет PublicKey"}'; return 1; } mkdir -p "$(dirname "$MIXOMO_WARP_CONF")" printf '%s' "$content" > "$MIXOMO_WARP_CONF" chmod 600 "$MIXOMO_WARP_CONF" printf '{"ok":true}\n' } _mixomo_warp_best_endpoint() { local warp_tmp="$JOBS_DIR/mixomo_warp" pings count=0 ip pings="$warp_tmp/pings" mkdir -p "$warp_tmp" rm -f "$pings" "$warp_tmp/pick.info" for ip in $(_awg_rand_hosts 4 | head -n 60); do ( trace_data=$(curl -s --connect-timeout 2 --max-time 4 -w "\n%{time_total}" -H "Host: trace.cloudflare.com" "http://${ip}/cdn-cgi/trace" 2>/dev/null) [ -n "$trace_data" ] || exit 0 colo=$(echo "$trace_data" | awk -F'=' '$1=="colo"{print $2}') [ -n "$colo" ] || exit 0 _st_warp_is_ru "$colo" && exit 0 ping_ms=$(echo "$trace_data" | tail -n1 | awk '{printf "%d", $1 * 1000}') [ -n "$ping_ms" ] && echo "$ping_ms $ip $colo" >> "$pings" ) & count=$((count + 1)) [ $((count % 20)) -eq 0 ] && wait done wait if [ -s "$pings" ]; then sort -n "$pings" | head -n1 | awk '{ print $2 ":4500" }' sort -n "$pings" | head -n1 | awk '{ print " ближайший зарубежный узел: " $3 ", " $1 " мс (оценка по HTTP, без туннеля)" }' > "$warp_tmp/pick.info" else echo "engage.cloudflareclient.com:4500" fi rm -f "$pings" } do_mixomo_warp_register() { local endpoint_mode="$1" ZM_KEYS_INSTALL=1 ZM_GEN_NOPHASE=1 if [ "$endpoint_mode" = auto ] && _awg_installed; then echo "==> AmneziaWG установлен — проверяем точки входа настоящим туннелем" do_awg_gen check return $? fi if [ "$endpoint_mode" = auto ]; then echo "==> AmneziaWG не установлен — точку входа подбираем приблизительно, без проверки туннелем" do_awg_gen std auto else do_awg_gen std "engage.cloudflareclient.com:4500" fi } mixomo_warp_action() { local endpoint_mode="$1" _job_running awg && { echo '{"error":"на вкладке AmneziaWG идёт операция — дождитесь её окончания"}'; return 1; } job_start mixomo_warp do_mixomo_warp_register "$endpoint_mode" } do_mixomo_warp_integrate() { [ -s "$MIXOMO_WARP_CONF" ] || { echo "ОШИБКА: сначала сгенерируйте WARP.conf"; return 1; } [ -x /etc/init.d/mihomo ] || { echo "ОШИБКА: Mixomo не установлен"; return 1; } echo "==> Интегрируем WARP.conf в Mihomo" local tmp; tmp=$(mktemp) awk -v OUT="$tmp" ' function trim(s){ gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", s); return s } function lc(s){ return tolower(s) } function yaml_quote(s, o, i, c){ o=""; for(i=1;i<=length(s);i++){ c=substr(s,i,1); if(c=="\\") o=o "\\\\"; else if(c=="\"") o=o "\\\""; else if(c!="\r") o=o c } return "\"" o "\"" } function split_endpoint(s, a,n){ s=trim(s); n=split(s,a,":"); if(n<2){ host=s; port="" } else { port=a[n]; host=a[1]; for(i=2;i "/dev/stderr"; exit 2 } split_endpoint(endpoint) ip=addr4; sub(/\/32$/, "", ip) ipv6=addr6; sub(/\/128$/, "", ipv6) if(allowed=="") allowed="0.0.0.0/0,::/0" n=split(allowed, aip, ",") allowed_block="" for(i=1;i<=n;i++){ if(aip[i]=="") continue; allowed_block = allowed_block " - " yaml_quote(aip[i]) "\n" } print "mixed-port: 7890" > OUT print "allow-lan: false" >> OUT print "tcp-concurrent: true" >> OUT print "mode: rule" >> OUT print "log-level: error" >> OUT print "ipv6: false" >> OUT print "external-controller: 0.0.0.0:9090" >> OUT print "external-ui: ./ui" >> OUT print "unified-delay: true" >> OUT print "profile:" >> OUT print " store-selected: true" >> OUT print " store-fake-ip: true" >> OUT print "" >> OUT print "proxy-groups:" >> OUT print " - name: GLOBAL" >> OUT print " type: select" >> OUT print " proxies:" >> OUT print " - WARP" >> OUT print " - DIRECT" >> OUT print "" >> OUT print "rules:" >> OUT print " - \"MATCH,GLOBAL\"" >> OUT print "" >> OUT print "proxies:" >> OUT print " - name: WARP" >> OUT print " type: wireguard" >> OUT print " server: " host >> OUT if(port!="") print " port: " port >> OUT print " private-key: " yaml_quote(priv) >> OUT print " udp: true" >> OUT if(ip!="") print " ip: " ip >> OUT if(ipv6!="") print " ipv6: " ipv6 >> OUT print " public-key: " yaml_quote(pub) >> OUT if(psk!="") print " pre-shared-key: " yaml_quote(psk) >> OUT print " allowed-ips:" >> OUT printf "%s", allowed_block >> OUT if(mtu!="") print " mtu: " mtu >> OUT if(keep!="") print " persistent-keepalive: " keep >> OUT if(s1!="" || s2!="" || jc!="" || jmin!="" || jmax!="" || h1!="" || h2!="" || h3!="" || h4!="" || i1!=""){ print " amnezia-wg-option:" >> OUT if(s1!="") print " s1: " s1 >> OUT if(s2!="") print " s2: " s2 >> OUT if(jc!="") print " jc: " jc >> OUT if(jmin!="") print " jmin: " jmin >> OUT if(jmax!="") print " jmax: " jmax >> OUT if(h1!="") print " h1: " h1 >> OUT if(h2!="") print " h2: " h2 >> OUT if(h3!="") print " h3: " h3 >> OUT if(h4!="") print " h4: " h4 >> OUT if(i1!="") print " i1: " yaml_quote(i1) >> OUT } }' "$MIXOMO_WARP_CONF" 2>"$tmp.err" if [ -s "$tmp.err" ]; then echo "ОШИБКА: в WARP.conf отсутствуют обязательные поля" rm -f "$tmp" "$tmp.err" return 1 fi rm -f "$tmp.err" cp "$MIHOMO_CONF" "$MIHOMO_CONF.bak" 2>/dev/null chmod 600 "$tmp" mv -f "$tmp" "$MIHOMO_CONF" /etc/init.d/mihomo restart >/dev/null 2>&1 echo "==> Готово, WARP интегрирован в Mihomo" } mixomo_warp_integrate_action() { job_start mixomo_warp_integrate do_mixomo_warp_integrate } BYEDPI_REPO="DPITrickster/ByeDPI-OpenWrt" _bytetube_fetch() { 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 } health() { local zr=0 zr2=0 bt=0 tg=0 mx=0 doh=0 hs=0 inst=0 bad=0 out="" rb=0 if [ -f /etc/init.d/zapret ]; then zr=2; pgrep -f "/opt/zapret/" >/dev/null 2>&1 && zr=1; fi if [ -f /etc/init.d/zapret2 ]; then zr2=2; /etc/init.d/zapret2 status >/dev/null 2>&1 && zr2=1; fi if [ -x /usr/bin/bytetube ]; then bt=2 out=$(/usr/bin/bytetube status 2>/dev/null) case "$out" in *'"enabled":true'*'"byedpi":true'*'"hev":true'*) bt=1 ;; esac fi if [ -f /etc/init.d/tg-ws-proxy ]; then inst=1; pidof tg-ws-proxy >/dev/null 2>&1 || bad=1; fi if [ -f "$TG_INIT_GO" ]; then inst=1; pidof tg-ws-proxy-go >/dev/null 2>&1 || bad=1; fi if [ -f "$TG_INIT_RS" ]; then inst=1; pidof tg-ws-proxy-rs >/dev/null 2>&1 || bad=1; fi if [ -x /etc/init.d/tgws ]; then inst=1; [ -n "$(tgws status 2>/dev/null)" ] || bad=1; fi if [ "$inst" = "1" ]; then tg=1; [ "$bad" = "1" ] && tg=2; fi if [ -x "$MIHOMO_BIN" ]; then mx=1 pidof mihomo >/dev/null 2>&1 || mx=2 if [ -x /etc/init.d/magitrickle ]; then /etc/init.d/magitrickle status >/dev/null 2>&1 || mx=2; fi if [ -x /etc/init.d/hev-socks5-tunnel ]; then /etc/init.d/hev-socks5-tunnel status >/dev/null 2>&1 || mx=2; fi fi if [ "$PKG" = "apk" ]; then apk info -e https-dns-proxy >/dev/null 2>&1 && doh=2 else opkg list-installed 2>/dev/null | grep -q '^https-dns-proxy ' && doh=2; fi if [ "$doh" = "2" ]; then pidof https-dns-proxy >/dev/null 2>&1 && doh=1; fi out=$(hosts_status 2>/dev/null) case "$out" in *'"enabled":true'*|*'"geohide":"'[a-z]*) hs=1 ;; esac local sr=0 w _st_legacy_check if _st_installed; then if [ -f /etc/zm-steer/stopped ] || ! grep -qx 'steer-spec' /etc/zm-steer/owned 2>/dev/null || [ "$(_st_exit)" = none ]; then sr=5 else sr=2 if stl_running; then if [ "$(_st_exit)" = vpn ]; then [ -d "/sys/class/net/$ST_VPN_OUT" ] && { _st_vpn_live; [ $? -ne 1 ] && sr=1; } else for w in $(awk '{print $1}' /etc/zm-steer/warp.up 2>/dev/null) zmwarp; do [ -d "/sys/class/net/$w" ] && { sr=1; break; } done fi fi fi fi local sx="" if _st_installed; then sx="$(_st_exit)"; [ "$sx" = warp ] && _st_warp_own && sx=own; fi local fw=false v6=false [ "$(uci -q get firewall.@defaults[0].flow_offloading)" = 1 ] && [ -f /usr/share/firewall4/templates/ruleset.uc ] && [ "$(_flow_offloading_fix_applied)" = false ] && fw=true [ -f "$CONF" ] && [ "$(_ipv6_enabled_in_zapret)" = false ] && ip -6 route show default 2>/dev/null | grep -q . && v6=true printf '{"zapret":%s,"zapret2":%s,"bytetube":%s,"tg":%s,"mixomo":%s,"doh":%s,"hosts":%s,"steer":%s,"steer_off":%s,"steer_exit":"%s","awg":%s,"forkop":%s,"flow_warn":%s,"ipv6_warn":%s,"reboot_hint":"%s"}\n' \ "$zr" "$zr2" "$bt" "$tg" "$mx" "$doh" "$hs" "$sr" \ "$([ -f /etc/zm-steer/stopped ] && echo true || echo false)" "$sx" "$(_awg_health)" "$(_fk_health)" "$fw" "$v6" "$(esc "$(head -n1 "$ZM_REBOOT_HINT" 2>/dev/null)")" } VERSIONS_CACHE="$ZM_STATE_DIR/versions.json" _ver_norm() { printf '%s' "$1" | sed 's/^[vV]//; s/-r[0-9]*$//; s/[[:space:]]//g'; } _ver_gh_latest() { curl -Ls --connect-timeout 5 --max-time 10 -o /dev/null -w '%{url_effective}' "https://github.com/$1/releases/latest" 2>/dev/null | sed -n 's#.*/tag/##p' | grep -oE '[0-9]+(\.[0-9]+)+' | head -n1 } _ver_feed_latest() { if [ "$PKG" = apk ]; then apk list "$1" 2>/dev/null | sed -n "s/^$1-\([0-9][^ ]*\) .*/\1/p" | head -n1 else opkg list "$1" 2>/dev/null | awk -v p="$1" '$1 == p { v = $3 } END { print v }' fi } _ver_item() { local cur latest cur="$(_ver_norm "$2")"; latest="$(_ver_norm "$3")" [ -n "$cur" ] || return 0 [ -n "$latest" ] && ! _st_ver_lt "$cur" "$latest" && latest="$cur" printf '{"name":"%s","installed":"%s","latest":"%s"}' "$(esc "$1")" "$(esc "$cur")" "$(esc "$latest")" } _jf() { jsonfilter -s "$1" -e "$2" 2>/dev/null; } do_versions_refresh() { local out="" sep="" j it tmp="$VERSIONS_CACHE.tmp" add() { [ -n "$1" ] && { out="$out$sep$1"; sep=","; }; } j="$(zm_update_status)" if [ "$(_jf "$j" '@.newer')" = true ]; then add "$(_ver_item 'Zapret Manager' "$(_jf "$j" '@.current')" "$(_jf "$j" '@.latest')")" else add "$(_ver_item 'Zapret Manager' "$(_jf "$j" '@.current')" "$(_jf "$j" '@.current')")" fi if [ -f /etc/init.d/zapret ]; then j="$(status)" add "$(_ver_item Zapret "$(_jf "$j" '@.zapret_version')" "$(_zapret_latest_version)")" fi if [ -x "$MIHOMO_BIN" ] || [ -x /etc/init.d/magitrickle ] || [ -x /etc/init.d/hev-socks5-tunnel ]; then j="$(mixomo_status)" add "$(_ver_item Mihomo "$(_jf "$j" '@.mihomo_version')" "$(_jf "$j" '@.mihomo_latest')")" add "$(_ver_item MagiTrickle "$(_jf "$j" '@.magitrickle_version')" "$(_jf "$j" '@.magitrickle_latest')")" add "$(_ver_item hev-socks5-tunnel "$(_jf "$j" '@.hev_version')" "$(_ver_feed_latest hev-socks5-tunnel)")" fi j="$(tg_status)" add "$(_ver_item 'TG WS Proxy (Go, MTProto)' "$(_jf "$j" '@.mtproto_version')" "$(_jf "$j" '@.mtproto_latest')")" add "$(_ver_item 'TG WS Proxy (SOCKS5)' "$(_jf "$j" '@.socks5_version')" "$(_jf "$j" '@.socks5_latest')")" add "$(_ver_item 'TG WS Proxy (Rust)' "$(_jf "$j" '@.rust_version')" "$(_jf "$j" '@.rust_latest')")" if [ -x /etc/init.d/tgws ]; then j="$(tgws_status)" add "$(_ver_item sTGWS "$(_jf "$j" '@.version')" "$(_jf "$j" '@.latest')")" fi _st_legacy_check stl_present && add "$(_ver_item 'Ядро steer' "$(stl_version)" "$(stl_latest)")" _fk_installed && add "$(_ver_item Forkozz "$(_fk_version)" "$(_fk_latest)")" local feeds=0 v [ -n "$(_ver_feed_latest busybox)" ] || [ -n "$(_zm_busy_job)" ] || { $UPDATE >/dev/null 2>&1; } if _fk_installed && v="$(_fk_sb_var)" && [ -n "$v" ]; then case "$v" in extended) add "$(_ver_item 'sing-box расширенный' "$(_fk_sb_ver)" "$(_fk_sbx_latest)")" ;; lite) add "$(_ver_item 'sing-box расширенный облегчённый' "$(_fk_sb_ver)" "")" ;; manual) add "$(_ver_item sing-box "$(_fk_sb_ver)" "")" ;; *) add "$(_ver_item sing-box "$(_fk_sb_ver)" "$(_ver_feed_latest "$(_fk_sb_pkg)")")" ;; esac fi if [ -f /etc/init.d/zapret2 ]; then v="$(_awg_pkg_ver zapret2)" add "$(_ver_item Zapret2 "$v" "$(_ver_feed_latest zapret2)")" fi if [ -x /usr/bin/ciadpi ] || [ -x /usr/bin/byedpi ]; then add "$(_ver_item ByeDPI "$(_awg_pkg_ver byedpi)" "$(_ver_gh_latest "$BYEDPI_REPO")")" fi if ! { [ -x "$MIHOMO_BIN" ] || [ -x /etc/init.d/magitrickle ]; } && _pkg_is_installed hev-socks5-tunnel; then add "$(_ver_item hev-socks5-tunnel "$(_awg_pkg_ver hev-socks5-tunnel)" "$(_ver_feed_latest hev-socks5-tunnel)")" fi if _awg_installed; then add "$(_ver_item AmneziaWG "$(_awg_pkg_ver amneziawg-tools)" '')" fi if _pkg_is_installed https-dns-proxy; then add "$(_ver_item 'DNS over HTTPS (https-dns-proxy)' "$(_awg_pkg_ver https-dns-proxy)" "$(_ver_feed_latest https-dns-proxy)")" fi printf '{"ts":"%s","items":[%s]}\n' "$(date '+%d.%m %H:%M')" "$out" > "$tmp" && mv "$tmp" "$VERSIONS_CACHE" echo "==> Версии проверены" } versions_status() { local busy=false local age=180 [ "$1" = auto ] && age=2 if [ "$1" = refresh ] || [ ! -s "$VERSIONS_CACHE" ] || [ -n "$(find "$VERSIONS_CACHE" -mmin +$age 2>/dev/null)" ]; then _job_running versions || ZM_VER_FORCE=1 job_start versions do_versions_refresh >/dev/null fi _job_running versions && busy=true if [ -s "$VERSIONS_CACHE" ]; then sed "s/}\$/,\"pending\":$busy}/" "$VERSIONS_CACHE" else printf '{"ts":"","items":[],"pending":%s}\n' "$busy" fi } bytetube_installed() { printf '{"installed":%s,"mixomo":%s}\n' "$([ -x /usr/bin/bytetube ] && echo true || echo false)" "$(_mx_installed && echo true || echo false)" } _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 >&2 _pkg_is_installed dnsmasq-dhcpv6 && $DELETE dnsmasq-dhcpv6 >&2 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 >&2; 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 >&2 [ -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; } $INSTALL "$f" || { echo "ОШИБКА: не удалось установить byedpi"; return 1; } NEW_BYEDPI=1 } _bytetube_install_payload() { mkdir -p /etc/config [ -f /etc/config/bytetube ] || cat > /etc/config/bytetube <<'YTB_FILE_END_7f3a9c' config bytetube 'main' option enabled '1' option byedpi_port '1088' 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' option ipv6 '1' option quic 'block' option default_domains '1' YTB_FILE_END_7f3a9c chmod 644 /etc/config/bytetube mkdir -p /etc/hotplug.d/firewall cat > /etc/hotplug.d/firewall/90-bytetube <<'YTB_FILE_END_7f3a9c' #!/bin/sh [ -f /var/run/bytetube.started ] || exit 0 nft list table inet bytetube >/dev/null 2>&1 && exit 0 logger -t bytetube "таблица nft пропала после reload firewall — восстанавливаю" /etc/init.d/bytetube restart >/dev/null 2>&1 exit 0 YTB_FILE_END_7f3a9c chmod 755 /etc/hotplug.d/firewall/90-bytetube mkdir -p /etc/init.d cat > /etc/init.d/bytetube <<'YTB_FILE_END_7f3a9c' #!/bin/sh /etc/rc.common START=99 STOP=10 USE_PROCD=1 NAME=bytetube LIBEXEC=/usr/libexec/bytetube RUNDIR=/var/etc/bytetube DOMAINS_DEFAULT=/usr/share/bytetube/domains.list STARTED_FLAG=/var/run/bytetube.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 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 d r=0 for d in $(dnsmasq_confdirs); do [ -f "$d/bytetube.conf" ] && { rm -f "$d/bytetube.conf"; r=1; } done [ "$r" = 1 ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 return 0 } dns_apply() { local ipv6="$1" d domains suffix new r=0 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 }') for d in $(dnsmasq_confdirs); do [ "$(cat "$d/bytetube.conf" 2>/dev/null)" = "$new" ] && continue mkdir -p "$d" printf '%s\n' "$new" > "$d/bytetube.conf" r=1 done [ "$r" = 1 ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 return 0 } 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" } 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 "bytetube" && return 0 [ -f /usr/share/nftables.d/chain-pre/forward/50-bytetube.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" procd_open_instance byedpi procd_set_param command "$byedpi" -i 127.0.0.1 -p "$byedpi_port" set -f [ -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 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/bytetube mkdir -p /lib/upgrade/keep.d cat > /lib/upgrade/keep.d/bytetube <<'YTB_FILE_END_7f3a9c' /etc/bytetube/ YTB_FILE_END_7f3a9c chmod 644 /lib/upgrade/keep.d/bytetube mkdir -p /usr/bin cat > /usr/bin/bytetube <<'YTB_FILE_END_7f3a9c' #!/bin/sh . /lib/functions.sh . /usr/libexec/bytetube/common.sh svc_running() { ubus call service list '{"name":"bytetube"}' 2>/dev/null \ | jsonfilter -e "@.bytetube.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 bytetube 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)/bytetube.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 bytetube && 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 2>/dev/null | awk '{ print $1 }' | 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 "Запустите: bytetube diag [секунд, по умолчанию 20]" exit 0 fi 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 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/bytetube/test.sh "$@" ;; set-strategy) opts="$2" if [ -z "$opts" ] || [ "$(printf '%s' "$opts" | wc -l)" -gt 0 ]; then echo '{"error":"пустая или многострочная стратегия"}' exit 0 fi uci set bytetube.main.byedpi_opts="$opts" && uci commit bytetube /etc/init.d/bytetube restart >/dev/null 2>&1 echo '{"ok":true}' ;; *) echo "usage: bytetube status|flush|list|diag|test|set-strategy" >&2 exit 1 ;; esac YTB_FILE_END_7f3a9c chmod 755 /usr/bin/bytetube mkdir -p /usr/libexec/bytetube cat > /usr/libexec/bytetube/common.sh <<'YTB_FILE_END_7f3a9c' #!/bin/sh TUN=ytb0 MARK=0x10000 MASK=0x10000 TABLE=89 PREF=8900 NFT_TABLE=bytetube dnsmasq_confdirs() { local d d=$(sed -n 's/^conf-dir=\([^,]*\).*/\1/p' /var/etc/dnsmasq.conf.* 2>/dev/null | sort -u) [ -n "$d" ] || d=/tmp/dnsmasq.d echo "$d" } dnsmasq_confdir() { dnsmasq_confdirs | head -n 1; } dnsmasq_has_nftset() { dnsmasq --version 2>/dev/null | grep -Eq '(^| )nftset( |$)' } 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_opts_clean() { printf '%s' "$1" | tr -d "\"'" | tr '\n\r\t' ' ' | sed 's/^ *//; s/ *$//; s/ */ /g' } YTB_FILE_END_7f3a9c chmod 755 /usr/libexec/bytetube/common.sh cat > /usr/libexec/bytetube/net.sh <<'YTB_FILE_END_7f3a9c' #!/bin/sh . /lib/functions.sh . /usr/libexec/bytetube/common.sh config_load bytetube 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" ip link show "$TUN" >/dev/null 2>&1 && /usr/libexec/bytetube/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/bytetube/net.sh cat > /usr/libexec/bytetube/route-up.sh <<'YTB_FILE_END_7f3a9c' #!/bin/sh . /lib/functions.sh . /usr/libexec/bytetube/common.sh config_load bytetube 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" echo 2 > "/proc/sys/net/ipv4/conf/$TUN/rp_filter" 2>/dev/null exit 0 YTB_FILE_END_7f3a9c chmod 755 /usr/libexec/bytetube/route-up.sh cat > /usr/libexec/bytetube/test.sh <<'YTB_FILE_END_7f3a9c' #!/bin/sh . /lib/functions.sh . /usr/libexec/bytetube/common.sh TEST_DIR=/tmp/bytetube-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/bytetube STRATS_DEFAULT=/usr/share/bytetube/strategies.txt DOMS_DEFAULT=/usr/share/bytetube/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() { 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' } 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" ( "$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() { 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() { 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 trap cleanup EXIT trap 'exit 130' INT TERM config_load bytetube 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" 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: bytetube test start|stop|status|log|results|clear|list" >&2; exit 1 ;; esac exit 0 YTB_FILE_END_7f3a9c chmod 755 /usr/libexec/bytetube/test.sh mkdir -p /usr/share/nftables.d/chain-pre/forward cat > /usr/share/nftables.d/chain-pre/forward/50-bytetube.nft <<'YTB_FILE_END_7f3a9c' oifname "ytb0" accept comment "bytetube" YTB_FILE_END_7f3a9c chmod 644 /usr/share/nftables.d/chain-pre/forward/50-bytetube.nft mkdir -p /usr/share/bytetube cat > /usr/share/bytetube/domains.list <<'YTB_FILE_END_7f3a9c' 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/bytetube/domains.list cat > /usr/share/bytetube/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/bytetube/strategies.txt cat > /usr/share/bytetube/test-domains.txt <<'YTB_FILE_END_7f3a9c' 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 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/bytetube/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 >&2 if ! _pkg_is_installed kmod-tun; then echo "==> Ставим kmod-tun" $INSTALL kmod-tun >&2 || { 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 >&2 || { 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" >&2 || 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/bytetube enable >/dev/null 2>&1 echo "==> Запускаем" /etc/init.d/bytetube 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/bytetube 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/bytetube ] && /usr/bin/bytetube test stop >/dev/null 2>&1 _zm_svc_off bytetube echo "==> Снимаем правила файрвола и DNS ByeTube" if [ -x /usr/libexec/bytetube/net.sh ]; then /usr/libexec/bytetube/net.sh purge >/dev/null 2>&1 . /usr/libexec/bytetube/common.sh for d in $(dnsmasq_confdirs 2>/dev/null || dnsmasq_confdir); do rm -f "$d/bytetube.conf"; done fi echo "==> Удаляем файлы и настройки ByeTube" rm -rf /etc/config/bytetube /etc/init.d/bytetube /etc/rc.d/*bytetube /etc/hotplug.d/firewall/90-bytetube \ /usr/bin/bytetube /usr/libexec/bytetube /usr/share/bytetube \ /usr/share/nftables.d/chain-pre/forward/50-bytetube.nft \ /var/etc/bytetube /var/run/bytetube.started /tmp/bytetube-test \ /etc/bytetube /lib/upgrade/keep.d/bytetube _zm_cron_drop 'bytetube' "ByeTube" /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 " ✓ ByeTube удалён" [ "$ZM_NO_AFTER" = 1 ] || _zm_after_remove "ByeTube" } do_bytetube_purge() { ZM_NO_AFTER=1 do_bytetube_uninstall echo "==> Удаляем пакет byedpi" _zm_svc_off byedpi $DELETE byedpi if [ -x "$MIHOMO_BIN" ] && [ -x /etc/init.d/magitrickle ]; then echo "==> hev-socks5-tunnel используется Mixomo — пакет оставляем" else echo "==> Удаляем пакет hev-socks5-tunnel" _zm_svc_off hev-socks5-tunnel $DELETE hev-socks5-tunnel rm -rf /etc/hev-socks5-tunnel fi echo "==> ByeTube удалён полностью" _zm_after_remove "ByeTube" } bytetube_action() { local action="$1" case "$action" in install|update) if ! _bt_installed && _mx_installed; then echo '{"error":"Стоит Mixomo — он тоже использует hev-socks5-tunnel. Чтобы поставить ByeTube, удалите Mixomo."}' return 1 fi 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" DOH_OFF_FLAG="/opt/zapret-manager-luci/doh_off" _doh_pkg() { if [ "$PKG" = "apk" ]; then apk info -e https-dns-proxy >/dev/null 2>&1 else opkg list-installed 2>/dev/null | grep -q '^https-dns-proxy '; fi } _doh_steer_active() { grep -qx 'steer-spec' /etc/zm-steer/owned 2>/dev/null && [ ! -f /etc/zm-steer/stopped ]; } DOH_FORCE_MODE_FILE="/opt/zapret-manager-luci/doh_force" _doh_force_mode() { local m m="$(cat "$DOH_FORCE_MODE_FILE" 2>/dev/null)" case "$m" in off) echo off ;; *) echo auto ;; esac } _doh_force_want() { if [ "$(_doh_force_mode)" = off ]; then echo 0; else echo 1; fi } _doh_main_text() { local n echo "config main 'config'" echo " option dnsmasq_config_update '*'" echo " option force_dns '$1'" echo " option notrack_dns '1'" echo " list force_dns_port '53'" echo " list force_dns_port '853'" for n in $(_zm_lan_nets); do echo " list force_dns_src_interface '$n'"; done echo " option procd_trigger_wan6 '0'" echo " option heartbeat_domain 'heartbeat.mossdef.org'" echo " option heartbeat_sleep_timeout '10'" echo " option heartbeat_wait_timeout '10'" echo " option user 'nobody'" echo " option group 'nogroup'" echo " option listen_addr '127.0.0.1'" echo " option force_ip_family 'auto'" if [ "$1" = 1 ]; then echo " option canary_domains_icloud '1'" echo " option canary_domains_mozilla '1'" fi } _doh_other_sections() { awk '/^config[ \t]/ { skip = ($2 == "main") } !skip' "$_doh_file" 2>/dev/null | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' | sed '/./,$!d' } _doh_force_apply() { local want new cur rest [ -f "$_doh_file" ] || return 0 [ -f "$DOH_OFF_FLAG" ] && return 0 want="$(_doh_force_want)" new="$(_doh_main_text "$want")" cur="$(awk '/^config[ \t]/ { on = ($2 == "main") } on' "$_doh_file" | sed '/^[[:space:]]*$/d')" [ "$cur" = "$new" ] && return 0 rest="$(_doh_other_sections)" { printf '%s\n' "$new"; [ -n "$rest" ] && printf '\n%s\n' "$rest"; } > "$_doh_file.zmtmp" && mv -f "$_doh_file.zmtmp" "$_doh_file" /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 return 0 } doh_force_set() { case "$1" in auto|off) ;; on) set -- auto ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac if [ ! -f "$_doh_file" ] || [ -f "$DOH_OFF_FLAG" ] || [ ! -x /etc/init.d/https-dns-proxy ]; then echo '{"error":"DNS over HTTPS не установлен — сначала нажмите «Установить DNS over HTTPS»"}' return 1 fi mkdir -p "$(dirname "$DOH_FORCE_MODE_FILE")" if [ "$1" = auto ]; then rm -f "$DOH_FORCE_MODE_FILE"; else echo "$1" > "$DOH_FORCE_MODE_FILE"; fi _doh_force_apply printf '{"ok":true,"mode":"%s","force_dns":%s}\n' "$1" "$([ "$(_doh_force_want)" = 1 ] && echo true || echo false)" } do_doh_install() { local installed _fk_present && { echo "ОШИБКА: стоит Forkozz — он сам шифрует DNS. Удалите Forkozz, чтобы поставить DoH"; return 1; } installed=$(doh_status | grep -o '"installed":[a-z]*' | cut -d: -f2) if [ "$installed" = "true" ]; then echo "==> DNS over HTTPS уже установлен" return 0 fi if _doh_pkg; then rm -f "$DOH_OFF_FLAG" echo "==> Пакет https-dns-proxy уже стоит — включаем службу" /etc/init.d/https-dns-proxy enable >/dev/null 2>&1 _doh_force_apply echo "==> Готово — выберите провайдера ниже" return 0 fi _ensure_deps echo "==> Обновляем список пакетов" $UPDATE >&2 echo "==> Устанавливаем https-dns-proxy и luci-app-https-dns-proxy" $INSTALL https-dns-proxy luci-app-https-dns-proxy >&2 || { echo "ОШИБКА установки"; return 1; } _doh_force_apply echo "==> Готово, DNS over HTTPS установлен — выберите провайдера ниже" } do_doh_remove() { local r echo "==> Останавливаем DNS over HTTPS — dnsmasq возвращается к прежним серверам" _zm_svc_off https-dns-proxy killall https-dns-proxy >/dev/null 2>&1 echo "==> Удаляем пакеты" $DELETE luci-i18n-https-dns-proxy-ru luci-app-https-dns-proxy https-dns-proxy echo "==> Удаляем настройки и правила перехвата DNS" rm -f /etc/config/https-dns-proxy /etc/config/https-dns-proxy-opkg /etc/init.d/https-dns-proxy /etc/rc.d/*https-dns-proxy "$DOH_OFF_FLAG" "$DOH_FORCE_MODE_FILE" "$DOH_BOOT_FILE" _zm_dns_scrub '^127\.0\.0\.1#50[0-9][0-9]$' for r in $(uci -q show firewall | sed -n "s/^firewall\.\([^.=]*\)\.name='[^']*[Hh]ttps[-_ ]dns[-_ ]proxy[^']*'\$/\1/p"); do uci -q delete "firewall.$r"; done uci -q commit firewall /etc/init.d/firewall reload >/dev/null 2>&1 _zm_cron_drop 'https-dns-proxy' "DoH" echo "==> Перезапускаем DNS роутера" /etc/init.d/dnsmasq restart >/dev/null 2>&1 echo "==> Готово, DNS over HTTPS удалён полностью" } doh_remove() { job_start doh_remove do_doh_remove } doh_install() { job_start doh_install do_doh_install } _doh_provider_of() { case "$1" in https://cloudflare-dns.com/*|https://1.1.1.1/*|https://1.0.0.1/*) echo cloudflare ;; https://dns.google/*|https://8.8.8.8/*|https://8.8.4.4/*) echo google ;; https://dns.quad9.net/*) echo quad9 ;; https://xbox-dns.ru/*) echo xbox ;; https://dns.comss.one/*) echo comss ;; https://dns.dns-ai.ru/*) echo dnsai ;; https://family.dot.dns.yandex.net/*) echo yandex_family ;; https://safe.dot.dns.yandex.net/*) echo yandex_safe ;; https://eu.geohide.ru/*) echo geohide_eu ;; https://us.geohide.ru/*) echo geohide_us ;; https://geohide.ru/*|https://dns.geohide.ru*) echo geohide_ru ;; *) echo "" ;; esac } _hosts_extra() { [ -s "$HOSTS_FILE" ] || { echo false; return; } awk -v me="$(uci -q get system.@system[0].hostname 2>/dev/null)" ' BEGIN { split("localhost localhost.localdomain ip6-localhost ip6-loopback ip6-localnet ip6-mcastprefix ip6-allnodes ip6-allrouters ip6-allhosts broadcasthost", a, " "); for (i in a) ok[a[i]] = 1; r = "false" } { sub(/#.*/, ""); if (NF < 2) next for (i = 2; i <= NF; i++) { n = tolower($i); if (n in ok || n == tolower(me) || n ~ /\.lan$/) continue; r = "true"; exit } } END { print r }' "$HOSTS_FILE" 2>/dev/null || echo false } doh_status() { local installed="false" current="" running=false force=false list="" sep="" i url port prov n=0 provs="" _doh_pkg && [ ! -f "$DOH_OFF_FLAG" ] && installed="true" if [ -f "$_doh_file" ]; then i=0 while url="$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].resolver_url")"; do port="$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].listen_port")" [ -n "$port" ] || port=$((5053 + i)) prov="$(_doh_provider_of "$url")" list="$list$sep{\"url\":\"$(esc "$url")\",\"port\":\"$(esc "$port")\",\"provider\":\"$prov\",\"bootstrap\":\"$(esc "$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].bootstrap_dns")")\"}" sep=","; n=$((n + 1)); provs="$prov" i=$((i + 1)) [ "$i" -gt 16 ] && break done [ "$n" = 1 ] && current="$provs" [ "$(uci -q get https-dns-proxy.config.force_dns)" != "0" ] && force=true fi /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && running=true printf '{"installed":%s,"current":"%s","running":%s,"force_dns":%s,"force_mode":"%s","resolvers":[%s],"steer_active":%s,"steer_installed":%s,"forkozz":%s,"bootstrap_custom":"%s","hosts_extra":%s}\n' \ "$installed" "$(esc "$current")" "$running" "$force" "$(_doh_force_mode)" "$list" \ "$(_doh_steer_active && echo true || echo false)" \ "$(grep -qx 'engine' /etc/zm-steer/owned 2>/dev/null && stl_present && echo true || echo false)" \ "$(_fk_present && echo true || echo false)" "$(esc "$(cat "$DOH_BOOT_FILE" 2>/dev/null)")" "$(_hosts_extra)" } DOH_BOOT_FILE="/opt/zapret-manager-luci/doh_bootstrap" _doh_boot_of() { case "$1" in cloudflare) echo "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001" ;; google) echo "8.8.8.8,8.8.4.4,2001:4860:4860::8888,2001:4860:4860::8844" ;; quad9) echo "9.9.9.9,149.112.112.112,2620:fe::fe,2620:fe::9" ;; yandex_family) echo "77.88.8.7,77.88.8.3" ;; yandex_safe) echo "77.88.8.88,77.88.8.2" ;; esac } _doh_ip_ok() { printf '%s' "$1" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$' && { local IFS=. o for o in $1; do [ "$o" -le 255 ] 2>/dev/null || return 1; done return 0 } printf '%s' "$1" | grep -qE '^[0-9A-Fa-f:]+$' && printf '%s' "$1" | grep -q ':.*:' } doh_bootstrap_set() { local v="$1" ip out="" n=0 i url b case "$v" in *[*?\[]*) echo '{"error":"укажите IP-адреса через запятую, например 77.88.8.8, 1.1.1.1"}'; return 1 ;; esac for ip in $(printf '%s' "$v" | tr ',;' ' '); do _doh_ip_ok "$ip" || { printf '{"error":"%s"}\n' "$(esc "«$ip» — не IP-адрес. Укажите IP через запятую, например 77.88.8.8, 1.1.1.1")"; return 1; } case ",$out," in *",$ip,"*) continue ;; esac out="$out${out:+,}$ip"; n=$((n + 1)) done [ "$n" -le 6 ] || { echo '{"error":"укажите не больше 6 адресов"}'; return 1; } mkdir -p "$(dirname "$DOH_BOOT_FILE")" if [ -n "$out" ]; then printf '%s\n' "$out" > "$DOH_BOOT_FILE"; else rm -f "$DOH_BOOT_FILE"; fi if [ -f "$_doh_file" ] && _doh_pkg; then i=0 while url="$(uci -q get "https-dns-proxy.@https-dns-proxy[$i].resolver_url")"; do b="$out" [ -n "$b" ] || b="$(_doh_boot_of "$(_doh_provider_of "$url")")" if [ -n "$b" ]; then uci -q set "https-dns-proxy.@https-dns-proxy[$i].bootstrap_dns=$b" else uci -q delete "https-dns-proxy.@https-dns-proxy[$i].bootstrap_dns"; fi i=$((i + 1)); [ "$i" -gt 16 ] && break done uci -q commit https-dns-proxy if [ ! -f "$DOH_OFF_FLAG" ]; then _zm_run 30 /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 fi fi printf '{"ok":true,"bootstrap":"%s"}\n' "$(esc "$out")" } doh_set() { local provider="$1" url bootstrap="" n case "$provider" in cloudflare) url="https://cloudflare-dns.com/dns-query"; bootstrap="1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001" ;; google) url="https://dns.google/dns-query"; bootstrap="8.8.8.8,8.8.4.4,2001:4860:4860::8888,2001:4860:4860::8844" ;; quad9) url="https://dns.quad9.net/dns-query"; bootstrap="9.9.9.9,149.112.112.112,2620:fe::fe,2620:fe::9" ;; xbox) url="https://xbox-dns.ru/dns-query" ;; comss) url="https://dns.comss.one/dns-query" ;; dnsai) url="https://dns.dns-ai.ru/dns-query" ;; yandex_family) url="https://family.dot.dns.yandex.net/dns-query"; bootstrap="77.88.8.7,77.88.8.3" ;; yandex_safe) url="https://safe.dot.dns.yandex.net/dns-query"; bootstrap="77.88.8.88,77.88.8.2" ;; geohide_ru) url="https://geohide.ru/dns-query" ;; geohide_eu) url="https://eu.geohide.ru/dns-query" ;; geohide_us) url="https://us.geohide.ru/dns-query" ;; *) echo '{"error":"неизвестный провайдер"}'; return 1 ;; esac [ -s "$DOH_BOOT_FILE" ] && bootstrap="$(head -n1 "$DOH_BOOT_FILE")" local installed; installed=$(doh_status | grep -o '"installed":[a-z]*' | cut -d: -f2) if [ "$installed" != "true" ]; then echo '{"error":"DNS over HTTPS не установлен — сначала нажмите «Установить DNS over HTTPS»"}' return 1 fi local force force="$(_doh_force_want)" { _doh_main_text "$force" echo "" echo "config https-dns-proxy" echo " option resolver_url '$url'" [ -n "$bootstrap" ] && echo " option bootstrap_dns '$bootstrap'" echo " option listen_port '5053'" } > "$_doh_file" /etc/init.d/https-dns-proxy enable >/dev/null 2>&1 /etc/init.d/https-dns-proxy reload >/dev/null 2>&1 /etc/init.d/https-dns-proxy restart >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 printf '{"ok":true,"provider":"%s"}\n' "$provider" } RB_SHARE="/usr/share/zm-redbtn" ZM_LISTS_DIR="/opt/zapret-manager-luci/lists" ZM_LISTS_ITD="${GH_RAW}/itdoginfo/allow-domains/main" _rb_list_src() { case "$1" in svc_youtube.lst) echo "$ZM_LISTS_ITD/Services/youtube.lst" ;; svc_telegram.lst) echo "$ZM_LISTS_ITD/Services/telegram.lst" ;; svc_discord.lst) echo "$ZM_LISTS_ITD/Services/discord.lst" ;; svc_meta.lst) echo "$ZM_LISTS_ITD/Services/meta.lst" ;; svc_twitter.lst) echo "$ZM_LISTS_ITD/Services/twitter.lst" ;; telegram.lst) echo "$ZM_LISTS_ITD/Subnets/IPv4/telegram.lst" ;; discord.lst) echo "$ZM_LISTS_ITD/Subnets/IPv4/discord.lst" ;; meta.lst|whatsapp.lst) echo "$ZM_LISTS_ITD/Subnets/IPv4/meta.lst" ;; twitter_x.lst) echo "$ZM_LISTS_ITD/Subnets/IPv4/twitter.lst" ;; own_github.lst) echo "${GH_RAW}/v2fly/domain-list-community/master/data/github" ;; *) return 1 ;; esac } _rb_list_norm() { tr -d '\r' < "$2" | sed 's/[[:space:]]*#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//' | tr 'A-Z' 'a-z' > "$2.n" case "$1" in svc_*|own_*) grep -v '^include:\|^regexp:\|^keyword:\|[[:space:]]@' "$2.n" | sed 's/^domain://; s/^full://; s/^suffix://; s/^\*\.//; s/^\.//' | grep -E '^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$' | grep -vE '^[0-9.]+$' | awk '!s[$0]++' ;; *) grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$' "$2.n" | awk '!s[$0]++' ;; esac rm -f "$2.n" } _rb_list_get() { local f="$1" p="$ZM_LISTS_DIR/$1" u tmp u="$(_rb_list_src "$f")" || return 1 if [ -s "$p" ] && [ -z "$(find "$p" -mtime +6 2>/dev/null)" ]; then return 0; fi if [ -s "$p" ] && [ -n "$(find "$ZM_LISTS_DIR/.$f.fail" -mmin -60 2>/dev/null)" ]; then return 0; fi mkdir -p "$ZM_LISTS_DIR" 2>/dev/null tmp="$ZM_LISTS_DIR/.$f.$$" if _st_fetch "$u" "$tmp"; then _rb_list_norm "$f" "$tmp" > "$tmp.ok" if [ -s "$tmp.ok" ]; then mv -f "$tmp.ok" "$p" rm -f "$tmp" "$ZM_LISTS_DIR/.$f.fail" echo " ✓ $f: скачан, строк $(wc -l < "$p" | tr -d ' ')" >&2 return 0 fi fi rm -f "$tmp" "$tmp.ok" : > "$ZM_LISTS_DIR/.$f.fail" 2>/dev/null if [ -s "$p" ]; then echo " !! $f не обновился — беру прошлую копию" >&2 return 0 fi echo " !! $f не скачался" >&2 return 1 } _rb_say() { echo "==> $*"; } _rb_warn() { echo "!! $*"; } _rb_svc_src() { grep -hv '^#' "$RB_SHARE/services.conf" 2>/dev/null; [ -f "$ST_CAT_OFF" ] || awk -F'|' -v skip="$ST_CAT_SKIP" '$9 != "" && $9 != "Свои списки каталога" { k = $1; sub(/^c_/, "", k); if (!index(skip, " " k " ")) print }' "$ST_CAT_SVC" 2>/dev/null; } _rb_svc_field() { _rb_svc_src | grep "^$1|" | head -n1 | cut -d'|' -f"$2"; } _rb_svc_names() { local id; for id in $1; do printf '%s, ' "$(_rb_svc_field "$id" 2)"; done | sed 's/, $//'; } _rb_routable() { [ "$1" = custom ] && [ -n "$(_rb_svc_field custom 1)" ] && return 0; [ -n "$(_rb_svc_field "$1" 3)$(_rb_svc_field "$1" 4)$(_rb_svc_field "$1" 8)" ]; } _rb_in() { grep -qxF "$1" "$2" 2>/dev/null; } _job_alive() { _job_running "$1"; } _kill_match() { local p for p in $(ps w | grep -F -- "$1" | grep -v -e grep -e "_kill_match" | awk '{print $1}'); do [ "$p" = "$$" ] || kill "$p" 2>/dev/null done } _rb_rpcd_ensure() { local i=0 while [ "$i" -lt 3 ]; do ubus list zapret-manager >/dev/null 2>&1 && return 0 /etc/init.d/rpcd reload >/dev/null 2>&1 sleep 3 i=$((i + 1)) done ubus list zapret-manager >/dev/null 2>&1 } ST_DIR="/etc/zm-steer" ST_OWNED="$ST_DIR/owned" ST_SEL="$ST_DIR/services" ST_CAT_SVC="$ST_DIR/services.remote" ST_CAT_IDX="$ST_DIR/catalog.idx" ST_CAT_META="$ST_DIR/catalog.meta" ST_CAT_URL="$ST_DIR/catalog.url" ST_CAT_OFF="$ST_DIR/catalog.off" ST_SKIP="$ST_DIR/skip" ST_OFF="$ST_DIR/stopped" ST_WARP_CONF="$ST_DIR/warp.conf" ST_RUN="$JOBS_DIR/steer" ST_STOP_FLAG="$ST_RUN/stop" ST_PHASE_FILE="$ST_RUN/phase" ST_WARP_IF="zmwarp" ST_WARP_ZONE="zmwarp" ST_AWG_MIRRORS="${GH_MAIN}/2Grey/awg-openwrt/releases/download ${GH_MAIN}/Slava-Shchipunov/awg-openwrt/releases/download" ST_AWG_MIRROR_FLAT="https://gitlab.com/xyzmean/brb/-/raw/main/deps/awg" ST_CRON_TAG="# zm-steer" ST_CRON_CMD="/opt/zapret-manager-luci/backend.sh steer_action cron_restart x >/dev/null 2>&1" ST_WARP_N=3 ST_WARP_MAX=3 ST_OWN_IF="zmwarp4" ST_WARP_MODE="$ST_DIR/warp.mode" ST_WARP_OWN="$ST_DIR/warp.own.conf" ST_WARP_OWN_KEEP="/etc/zm-warp-own.conf" ST_WARP_UP_AUTO="$ST_DIR/warp.up.auto" ST_WIF1="zmwarp"; ST_WCONF1="$ST_WARP_CONF" _st_mode_vars() { if [ "$(cat "$ST_WARP_MODE" 2>/dev/null)" = own ]; then ST_WARP_N=1; ST_WIF1="$ST_OWN_IF"; ST_WCONF1="$ST_WARP_OWN" else ST_WARP_N="$ST_WARP_MAX"; ST_WIF1="zmwarp"; ST_WCONF1="$ST_DIR/warp.conf" fi ST_WARP_IF="$ST_WIF1"; ST_WARP_CONF="$ST_WCONF1" } _st_mode_vars ST_WARP_UP="$ST_DIR/warp.up" ST_WARP_GEO="$ST_DIR/warp.geo" ST_RU_COLOS="DME SVX LED KJA REN OVB KZN AER VVO" ST_DEFAULT_SEL="" # ---- Слой steer ------------------------------------------------------------------------------- # Единственное место, где Zapret Manager говорит с ядром steer 2.x. Остальной бекенд говорит на # своём языке (выход WARP или VPN, сервисы и их списки, подписка), а слой переводит это в спеку v2 # (/etc/steer/spec.json) и зовёт CLI ядра: клиент steer сам отдаёт команды демону steerd. # ядро и пакеты stl_present, stl_version, stl_v2, stl_has vpn, stl_busy_by, stl_latest, # stl_engine_install ВЕРСИЯ [vpn], stl_engine_remove ПАКЕТЫ, stl_leftovers # настройка stl_apply МОДЕЛЬ, stl_spec_whose, stl_spec_restore, stl_rules # служба stl_running, stl_enabled, stl_restart, stl_stop, stl_reload # состояние stl_state warp|vpn, stl_vpn_json, stl_diag, stl_diag_lines, stl_explain ЦЕЛЬ # подписка stl_sub_fetch, stl_sub_check, stl_nodes, stl_tsv, stl_probe # МОДЕЛЬ — файл, строка на запись, поля через табуляцию: # lan УСТРОЙСТВО... откуда клиенты # warp УСТРОЙСТВО... выход — туннели WARP, по предпочтению # vpn ПОДПИСКА УЗЛЫ МАРКЕРЫ выход — подписка VLESS; УЗЛЫ — номера через запятую или «-» # (первый рабочий), МАРКЕРЫ — куски имён через | или «-» # svc ID ИМЯ сервис; за ним его списки: # srs|dom|pfx ФАЙЛ набор sing-box, домены, подсети STL_TMP="$ST_RUN" STL_KEEP="$ST_DIR/spec.before" STL_CACHE="$ZM_STATE_DIR/steer.latest" STL_ETC="${STL_ETC:-/etc/steer}" STL_SPEC="$STL_ETC/spec.json" STL_YAML="$STL_ETC/spec.yaml" STL_SOCK="$STL_ETC/steer.sock" STL_INIT="${STL_INIT:-/etc/init.d/steer}" STL_SBIN="${STL_SBIN:-/usr/sbin}" STL_STATE="${STL_STATE:-/var/lib/steer}" STL_MIN="2.0.0" STL_MODS="vless xsteer obfs tgws hysteria2 proxy" STL_REL_URLS="${STL_REL_URLS:-https://raw.githubusercontent.com/splify2/releases/main/version.json https://cdn.jsdelivr.net/gh/splify2/releases@main/version.json https://splify2.github.io/releases/version.json}" STL_PKG_URLS="${STL_PKG_URLS:-https://github.com/splify2/releases/releases/download/steer-v@V@ https://github.com/splify2/steer/releases/download/v@V@ https://gitlab.com/xyzmean/steer/-/raw/dist}" STL_WARP_OUT="zm_warp" STL_VPN_OUT="zm_vpn" STL_VPN_DEV="zm_vpn" _stl_say() { echo "==> $*"; } _stl_t() { if command -v timeout >/dev/null 2>&1; then timeout "$@"; else shift; "$@"; fi; } _stl_out() { case "$1" in vpn) echo "$STL_VPN_OUT" ;; *) echo "$STL_WARP_OUT" ;; esac; } _stl_js() { printf '"%s"' "$(esc "$1")"; } _stl_ident() { local n n="$(printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '_')" case "$n" in ''|all|lan|[-.]*) n="z$n" ;; esac [ "${#n}" -le 31 ] || n="$(printf '%s' "$n" | cut -c1-26)-$(printf '%s' "$1" | md5sum | cut -c1-4)" printf '%s' "$n" } _stl_label() { local n n="$(printf '%s' "$1" | tr -d '"\\\000-\037')" [ -n "$n" ] && [ "$(printf '%s' "$n" | wc -c)" -le 31 ] && { printf '%s' "$n"; return; } _stl_ident "$2" } _stl_ver_ok() { echo "$1" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' && [ "${1%%.*}" -ge 2 ]; } stl_present() { command -v steer >/dev/null 2>&1; } stl_version() { steer --version 2>/dev/null | awk 'NR == 1 { print $2 }'; } stl_v2() { _stl_ver_ok "$(stl_version)"; } stl_has() { stl_v2 || return 1; case "$1" in vpn) [ -x "$STL_SBIN/steer-vless" ] ;; *) return 1 ;; esac; } stl_busy_by() { local i=/etc/init.d/sing-box b by="" grep -q steer-box-connector "$i" 2>/dev/null || return 1 for b in podkop forkop; do [ -e "${i%/*}/$b" ] && by="${by:+$by, }$b"; done echo "${by:-steer-box-connector}" } _stl_mods_present() { local m; for m in $STL_MODS; do [ -x "$STL_SBIN/steer-$m" ] && printf '%s ' "$m"; done; } _stl_rel() { local f="$STL_TMP/version.json" u mkdir -p "$STL_TMP" [ -s "$f" ] && [ -z "$(find "$f" -mmin +60 2>/dev/null)" ] && { echo "$f"; return 0; } for u in $STL_REL_URLS; do curl -fsSL --connect-timeout 6 --max-time 20 -o "$f.tmp" "$u" 2>/dev/null || continue [ "$(jsonfilter -i "$f.tmp" -e '@.schema' 2>/dev/null)" = 1 ] || continue mv -f "$f.tmp" "$f"; echo "$f"; return 0 done rm -f "$f.tmp" [ -s "$f" ] && { echo "$f"; return 0; } return 1 } stl_latest() { local v="" f u if [ -z "$ZM_VER_FORCE" ] && [ -s "$STL_CACHE" ] && [ -z "$(find "$STL_CACHE" -mmin +360 2>/dev/null)" ]; then cat "$STL_CACHE"; return 0; fi f="$(_stl_rel)" && v="$(jsonfilter -i "$f" -e '@.products.steer.stable' 2>/dev/null)" if ! _stl_ver_ok "$v"; then for u in $STL_PKG_URLS; do case "$u" in *@V@*) continue ;; esac v="$(curl -fsSL --connect-timeout 6 --max-time 12 "$u/VERSION" 2>/dev/null | tr -d '[:space:]')" _stl_ver_ok "$v" && break v="" done fi if _stl_ver_ok "$v"; then mkdir -p "$(dirname "$STL_CACHE")"; echo "$v" > "$STL_CACHE"; echo "$v" elif [ -s "$STL_CACHE" ]; then cat "$STL_CACHE" else echo "$STL_MIN"; fi } _stl_fetch() { local ver="$1" file="$2" out="$3" f sum="" u urls="" if f="$(_stl_rel)"; then urls="$(jsonfilter -i "$f" -e "@.products.steer.versions[@.version='$ver'].assets[@.name='$file'].urls[*]" 2>/dev/null | grep -E '^https://[A-Za-z0-9._~:/%+@=-]+$')" sum="$(jsonfilter -i "$f" -e "@.products.steer.versions[@.version='$ver'].assets[@.name='$file'].sha256" 2>/dev/null)" fi for u in $urls $(echo "$STL_PKG_URLS" | sed "s/@V@/$ver/g"); do case "$u" in */"$file") ;; *) u="$u/$file" ;; esac _rb_fetch_pkg "$u" "$out" >&2 || continue [ -z "$sum" ] || [ "$(sha256sum "$out" | cut -d' ' -f1)" = "$sum" ] && return 0 echo " у файла не та контрольная сумма: $u" >&2 rm -f "$out" done return 1 } stl_engine_install() { local ver="$1" arch m p n f mods="" pkgs files="" old="" back="" legacy="" keep="$STL_TMP/spec.keep" was_on="" rc shift _stl_ver_ok "$ver" || { echo "ОШИБКА: версия ядра steer $ver — нужна 2.0.0 или новее"; return 1; } arch="$(_rb_arch)" [ -n "$arch" ] || { echo "ОШИБКА: не удалось определить архитектуру роутера"; return 1; } for m in "$@"; do case "$m" in vpn) mods="$mods vless" ;; esac; done if stl_present && ! stl_v2; then for n in steer steer-extended libsteer libsteer-wolfssl; do _pkg_is_installed "$n" && legacy="$legacy $n"; done case " $legacy " in *" steer-extended "*) mods="$mods vless" ;; esac fi mods="$(for m in $mods $(_stl_mods_present); do echo "$m"; done | awk '!s[$0]++' | tr '\n' ' ')" pkgs="steer-core$(for m in $mods; do printf ' steer-%s' "$m"; done)" stl_v2 && _pkg_is_installed steer-extended && pkgs="$pkgs steer-extended" STL_NEW=""; STL_PKGS="$pkgs" for p in $pkgs; do _pkg_is_installed "$p" || STL_NEW="$STL_NEW $p"; done mkdir -p "$STL_TMP" for p in $pkgs; do f="$STL_TMP/$p-$ver-1_$arch.$RAZ" _stl_fetch "$ver" "$p-$ver-1_$arch.$RAZ" "$f" || { rm -f $files; echo "ОШИБКА: не скачался пакет $p $ver для $arch"; return 1; } files="$files $f" done stl_enabled && was_on=1 _stl_say "Ставим ядро steer $ver: $(echo $pkgs | sed 's/ /, /g')" $UPDATE >&2 [ -s "$STL_SPEC" ] && cp -f "$STL_SPEC" "$keep" if [ "$PKG" = apk ]; then for n in $legacy; do if [ "$n" = steer ]; then apk add steer >/dev/null 2>&1; else old="$old !$n"; fi; done $INSTALL $files $old >&2; rc=$? [ "$rc" = 0 ] && for n in $legacy; do apk del "$n" >/dev/null 2>&1; done else if [ -n "$legacy" ]; then old="$(stl_version)"; back="" for n in $legacy; do f="$STL_TMP/$n-$old-1_$arch.$RAZ" _pkg_is_installed "$n" && case "$n" in steer|steer-extended) _stl_fetch "$old" "$n-$old-1_$arch.$RAZ" "$f" && back="$back $f" ;; esac done if [ -z "$back" ]; then rm -f $files "$keep" echo "ОШИБКА: не скачался пакет прежнего ядра steer $old для возврата — прежнее ядро оставлено" return 1 fi _stl_say "Снимаем прежнее ядро steer $old — оно мешает новому" "$STL_INIT" stop >/dev/null 2>&1 $DELETE $legacy >&2 fi $INSTALL $files >&2; rc=$? if [ "$rc" != 0 ] && [ -n "$legacy" ]; then _stl_say "Новое ядро не встало — возвращаем прежнее ядро steer $old" $INSTALL $back >&2 fi [ -n "$legacy" ] && rm -f $back fi rm -f $files [ -s "$keep" ] && [ ! -s "$STL_SPEC" ] && { mkdir -p "$STL_ETC"; cp -f "$keep" "$STL_SPEC"; } rm -f "$keep" if [ "$rc" != 0 ] || ! stl_v2; then [ -n "$legacy" ] && ! stl_present && echo "!! Прежнее ядро steer снято, а новое не встало — нажмите установку ещё раз, когда появится интернет" [ -n "$was_on" ] && stl_present && stl_restart >/dev/null 2>&1 echo "ОШИБКА: ядро steer $ver не установилось" return 1 fi if [ -n "$was_on" ]; then stl_restart >/dev/null 2>&1; else stl_stop; fi _stl_say "Ядро steer $(stl_version) установлено$( [ -n "$mods" ] && printf ', модули: %s' "$(echo $mods | sed 's/ /, /g')")" return 0 } stl_engine_remove() { local p mods="" core="" stl_stop for p in "$@"; do case "$p" in steer-core|steer|steer-extended|libsteer|libsteer-wolfssl) core="$core $p" ;; *) mods="$mods $p" ;; esac; done [ -n "$mods$core" ] || return 0 _stl_say "Удаляем ядро steer" [ -n "$mods" ] && $DELETE $mods >&2 [ -n "$core" ] && $DELETE $core >&2 return 0 } stl_leftovers() { stl_present && return 0 local t m n=0 for t in "inet steer" "ip steer" "ip6 steer" "inet steer_obfs"; do nft delete table $t >/dev/null 2>&1 && n=$((n + 1)); done for t in $(awk '{print $3}' "$STL_STATE/registry" 2>/dev/null); do ip route flush table "$t" >/dev/null 2>&1; done for m in $(awk '{print $2}' "$STL_STATE/registry" 2>/dev/null); do while ip rule del fwmark "0x$m/0x0ff00000" >/dev/null 2>&1; do n=$((n + 1)); done while ip rule del fwmark "0x$m" >/dev/null 2>&1; do n=$((n + 1)); done done killall steerd >/dev/null 2>&1 rm -rf "$STL_STATE" [ "$n" -gt 0 ] && echo " ✓ Убраны оставшиеся правила ядра steer: $n" return 0 } stl_spec_whose() { [ -s "$STL_YAML" ] && { echo foreign; return; } [ -s "$STL_SPEC" ] || { echo none; return; } grep -q "\"$STL_WARP_OUT\"\|\"$STL_VPN_OUT\"" "$STL_SPEC" && { echo zm; return; } tr '\n\r' ' ' < "$STL_SPEC" | grep -q '"\(channels\|rules\)"[[:space:]]*:[[:space:]]*\[[[:space:]]*{' && { echo foreign; return; } echo none } stl_spec_restore() { if [ -s "$STL_KEEP" ]; then mv -f "$STL_KEEP" "$STL_SPEC"; else rm -f "$STL_SPEC"; fi; } stl_rules() { grep -o '"out":' "$STL_SPEC" 2>/dev/null | wc -l | tr -d ' '; } _stl_rule() { [ -n "$lid" ] && [ -n "$srs$dom$pfx" ] || return 0 body="" [ -n "$srs" ] && body="\"srs\":[$srs]" [ -n "$dom" ] && body="$body${body:+,}\"domains_file\":[$dom]" [ -n "$pfx" ] && body="$body${body:+,}\"prefixes_file\":[$pfx]" lists="$lists${lists:+,}$(_stl_js "$lid"):{$body}" rules="$rules${rules:+,}{\"name\":$(_stl_js "$lab"),\"to\":$(_stl_js "$lid"),\"out\":\"$out\"}" } _stl_spec() { local tab k a b c lists="" rules="" outs="" lan="" sep="" lid="" lab="" srs="" dom="" pfx="" d m tab="$(printf '\t')" local out="" body while IFS="$tab" read -r k a b c; do case "$k" in warp) out="$STL_WARP_OUT" ;; vpn) out="$STL_VPN_OUT" ;; esac done < "$1" [ -n "$out" ] || return 1 while IFS="$tab" read -r k a b c; do case "$k" in lan) lan="$(printf '%s\n' "$a" "$b" "$c" | tr "$tab" '\n' | grep . | while read -r d; do printf ',%s' "$(_stl_js "$d")"; done | sed 's/^,//')" ;; warp) set -- $(printf '%s\t%s\t%s' "$a" "$b" "$c" | tr "$tab" ' ') if [ $# = 1 ]; then outs="\"$STL_WARP_OUT\":{\"kind\":\"interface\",\"device\":$(_stl_js "$1"),\"ipv6\":\"off\",\"on_fail\":\"direct\"}" else m=""; for d; do m="$m${m:+,}$(_stl_js "$d")"; outs="$outs,$(_stl_js "$d"):{\"kind\":\"interface\",\"device\":$(_stl_js "$d"),\"ipv6\":\"off\"}"; done outs="\"$STL_WARP_OUT\":{\"kind\":\"group\",\"pick\":\"latency\",\"members\":[$m],\"ipv6\":\"off\",\"on_fail\":\"direct\"}$outs" fi ;; vpn) outs="\"$STL_VPN_OUT\":{\"kind\":\"tunnel\",\"protocol\":\"vless\",\"device\":\"$STL_VPN_DEV\",\"subscription\":$(_stl_js "$a")" [ -n "$b" ] && [ "$b" != - ] && outs="$outs,\"nodes\":[$(echo "$b" | tr -cd '0-9,' | sed 's/,,*/,/g; s/^,//; s/,$//')]" [ "$c" = - ] && c="" m="$(printf '%s' "$c" | tr '|,' '\n\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | grep . | awk '!s[$0]++' | while IFS= read -r d; do printf ',%s' "$(_stl_js "$d")"; done | sed 's/^,//')" [ -n "$m" ] && outs="$outs,\"exclude_name\":[$m]" outs="$outs,\"on_fail\":\"direct\"}" ;; svc) _stl_rule; lid="$(_stl_ident "$a")"; lab="$(_stl_label "$b" "$a")"; srs=""; dom=""; pfx="" ;; srs) srs="$srs${srs:+,}$(_stl_js "$a")" ;; dom) dom="$dom${dom:+,}$(_stl_js "$a")" ;; pfx) pfx="$pfx${pfx:+,}$(_stl_js "$a")" ;; esac done < "$1" _stl_rule [ -n "$rules" ] || return 1 printf '{"version":2,"lan":{"devices":[%s]},"lists":{%s},"outputs":{%s},"rules":[%s]}\n' "${lan:-\"br-lan\"}" "$lists" "$outs" "$rules" } _stl_check() { local r c if stl_running && [ -S "$STL_SOCK" ]; then r="$(_stl_t 150 steer ctl check < "$1" 2>/dev/null)" c="$(printf '%s' "$r" | jsonfilter -e '@.code' 2>/dev/null)" case "$c" in 0) return 0 ;; [1-9]*) printf '%s' "$r" | jsonfilter -e '@.stderr' 2>/dev/null; return 1 ;; esac fi _stl_t 150 steer apply --dry-run --spec "$1" 2>&1 >/dev/null } stl_apply() { local new="$STL_SPEC.zm.$$" prev="$STL_TMP/spec.prev" err w=0 mkdir -p "$STL_ETC" "$STL_TMP" _stl_spec "$1" > "$new" || { rm -f "$new"; echo "ОШИБКА: в настройке нет ни одного списка"; return 1; } _stl_say "Проверяем правила: $(grep -o '"out":' "$new" | wc -l | tr -d ' ')" if ! err="$(_stl_check "$new")"; then rm -f "$new" echo "ОШИБКА: ядро steer отвергло настройку" printf '%s\n' "$err" | tail -n 5 return 1 fi [ "$(stl_spec_whose)" = none ] && [ -s "$STL_SPEC" ] && [ ! -f "$STL_KEEP" ] && cp -f "$STL_SPEC" "$STL_KEEP" rm -f "$prev"; [ -s "$STL_SPEC" ] && cp -f "$STL_SPEC" "$prev" mv -f "$new" "$STL_SPEC" "$STL_INIT" enable >/dev/null 2>&1 if stl_running; then if ! err="$(_stl_t 300 steer apply 2>&1 >/dev/null)"; then if [ -s "$prev" ]; then mv -f "$prev" "$STL_SPEC"; else rm -f "$STL_SPEC"; fi echo "ОШИБКА: ядро steer не применило правила — остались прежние" printf '%s\n' "$err" | tail -n 5 return 1 fi rm -f "$prev" _stl_say "Правила Steer применены — ядро тронуло только изменившееся" return 0 fi rm -f "$prev" "$STL_INIT" start >/dev/null 2>&1 while [ "$w" -lt 10 ] && ! stl_running; do sleep 1; w=$((w + 1)); done stl_running || { echo "ОШИБКА: служба steer не запустилась — загляните в системный журнал"; return 1; } _stl_say "Правила Steer применены" } stl_running() { [ -x "$STL_INIT" ] && "$STL_INIT" running >/dev/null 2>&1; } stl_enabled() { [ -x "$STL_INIT" ] && "$STL_INIT" enabled >/dev/null 2>&1; } stl_restart() { [ -x "$STL_INIT" ] || return 1 "$STL_INIT" enable >/dev/null 2>&1 "$STL_INIT" restart >/dev/null 2>&1 sleep 1 stl_running } stl_stop() { [ -x "$STL_INIT" ] || return 0; "$STL_INIT" stop >/dev/null 2>&1; "$STL_INIT" disable >/dev/null 2>&1; return 0; } stl_reload() { stl_running || return 0; _stl_t 120 steer reload >/dev/null 2>&1; } stl_state() { local o j o="$(_stl_out "$1")" STL_UP=false; STL_FAILED=false; STL_DEV=""; STL_PSTATE=""; STL_PNODE=""; STL_PTOTAL="" stl_present || return 1 j="$(_stl_t 10 steer status 2>/dev/null)" case "$j" in '{'*) ;; *) return 1 ;; esac eval "$(printf '%s' "$j" | jsonfilter -e "STL_UP=@.outputs.$o.up" -e "STL_FAILED=@.outputs.$o.failed" -e "STL_DEV=@.outputs.$o.device" \ -e "STL_PSTATE=@.outputs.$o.probe.state" -e "STL_PNODE=@.outputs.$o.probe.node" -e "STL_PTOTAL=@.outputs.$o.probe.total" 2>/dev/null)" case "$STL_UP" in 1|true) STL_UP=true ;; *) STL_UP=false ;; esac case "$STL_FAILED" in 1|true) STL_FAILED=true ;; *) STL_FAILED=false ;; esac printf '%s' "$STL_DEV" | grep -qE '^[A-Za-z0-9_.-]+$' || STL_DEV="" case "$STL_PSTATE" in probing|failed|no_such_node) ;; *) STL_PSTATE="" ;; esac case "$STL_PNODE" in ''|*[!0-9]*) STL_PNODE=0 ;; esac case "$STL_PTOTAL" in ''|*[!0-9]*) STL_PTOTAL=0 ;; esac return 0 } stl_vpn_json() { stl_state vpn || { echo null; return; } printf '{"up":%s,"failed":%s' "$STL_UP" "$STL_FAILED" [ -n "$STL_PSTATE" ] && printf ',"probe":{"state":"%s","node":%s,"total":%s}' "$STL_PSTATE" "$STL_PNODE" "$STL_PTOTAL" printf '}' } stl_diag() { local d d="$(_stl_t 30 steer diag 2>/dev/null | tr '\n' ' ')" case "$d" in '{'*'}'*) printf '%s' "$d" ;; *) echo null ;; esac } stl_diag_lines() { local f="$STL_TMP/stl-diag.json" n i=0 mkdir -p "$STL_TMP" _stl_t 30 steer diag > "$f" 2>/dev/null n="$(jsonfilter -i "$f" -e '@.checks[*].id' 2>/dev/null | wc -l)" while [ "$i" -lt "$n" ]; do printf '%s\t%s\t%s\n' "$(jsonfilter -i "$f" -e "@.checks[$i].verdict" 2>/dev/null)" \ "$(jsonfilter -i "$f" -e "@.checks[$i].what" 2>/dev/null)" "$(jsonfilter -i "$f" -e "@.checks[$i].why" 2>/dev/null)" i=$((i + 1)) done rm -f "$f" } stl_explain() { local t line STL_X_VERDICT=unknown; STL_X_OUT=""; STL_X_DEV=""; STL_X_SET=""; STL_X_ADDR=""; STL_X_FAKE=false t="$(_stl_t 20 steer explain "$1" 2>&1)" STL_X_TEXT="$t" line="$(printf '%s\n' "$t" | grep -m1 -- '-> output "')" STL_X_ADDR="$(printf '%s\n' "$t" | head -n1 | sed -n 's/^[^ ]* -> \([0-9a-fA-F.:]*\) (.*/\1/p')" printf '%s\n' "$t" | head -n1 | grep -q 'fake-IP' && STL_X_FAKE=true if [ -n "$line" ]; then STL_X_OUT="$(printf '%s' "$line" | sed -n 's/.*-> output "\([^"]*\)".*/\1/p')" STL_X_SET="$(printf '%s' "$line" | sed -n 's/.*"\([^"]*\)" -> output ".*/\1/p')" STL_X_DEV="$(printf '%s' "$line" | sed -n 's/.*-> dev \([^ ]*\).*/\1/p')" case "$STL_X_OUT" in "$STL_WARP_OUT") STL_X_VERDICT=warp ;; "$STL_VPN_OUT") STL_X_VERDICT=vpn ;; *) STL_X_VERDICT=other ;; esac [ -z "$STL_X_DEV" ] && printf '%s' "$line" | grep -q -- '-> direct' && STL_X_VERDICT=direct elif printf '%s' "$t" | grep -q 'no channel matches'; then STL_X_VERDICT=direct; fi } stl_sub_fetch() { local r STL_SUB_URL=""; STL_SUB_TITLE=""; STL_SUB_WARN=""; STL_SUB_ERR="" rm -f "$2" r="$(steer sub-fetch "$1" --out "$2" --info "$3" 2>/dev/null)" if [ "$(printf '%s' "$r" | jsonfilter -e '@.ok' 2>/dev/null)" != true ] || [ ! -s "$2" ]; then rm -f "$2" STL_SUB_ERR="$(printf '%s' "$r" | jsonfilter -e '@.error' 2>/dev/null)" return 1 fi STL_SUB_URL="$(printf '%s' "$r" | jsonfilter -e '@.url' 2>/dev/null)" STL_SUB_TITLE="$(printf '%s' "$r" | jsonfilter -e '@.title' 2>/dev/null)" STL_SUB_WARN="$(printf '%s' "$r" | jsonfilter -e '@.warn' 2>/dev/null)" return 0 } stl_sub_check() { local j j="$(steer vless-nodes "$1" 2>/dev/null)" STL_SUB_USABLE="$(printf '%s' "$j" | jsonfilter -e '@.usable' 2>/dev/null)" STL_SUB_WHY="$(printf '%s' "$j" | jsonfilter -e '@.skipped_reasons[*].reason' 2>/dev/null | head -n 3)" [ "${STL_SUB_USABLE:-0}" -gt 0 ] 2>/dev/null } stl_nodes() { local j j="$(steer vless-nodes "$1" 2>/dev/null | tr '\n' ' ')" case "$j" in '{'*) printf '%s' "$j" ;; *) return 1 ;; esac } stl_tsv() { awk '{ s = $0 while (match(s, /\{"index":[0-9]+,"name":"([^"\\]|\\.)*"/)) { r = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH) i = r; sub(/^\{"index":/, "", i); sub(/,.*/, "", i) n = r; sub(/^\{"index":[0-9]+,"name":"/, "", n); sub(/"$/, "", n) gsub(/\\"/, "\"", n) c = split(n, q, /\\\\/); n = q[1]; for (k = 2; k <= c; k++) n = n "\\" q[k] print i "\t" n } }' } stl_probe() { local j j="$(steer vless-probe "$1" --node "$2" --timeout 5 2>/dev/null | tr '\n' ' ')" case "$j" in '{'*) printf '%s' "$j" ;; *) return 1 ;; esac } _st_phase() { printf '%s\n' "$1" > "$ST_PHASE_FILE"; } _st_stopped() { [ -f "$ST_STOP_FLAG" ]; } _st_own() { mkdir -p "$ST_DIR"; grep -qxF "$1" "$ST_OWNED" 2>/dev/null || echo "$1" >> "$ST_OWNED"; } _st_owns() { grep -qxF "$1" "$ST_OWNED" 2>/dev/null; } _st_running() { _job_alive steer; } _st_installed() { stl_present && { _st_owns "engine" || _st_owns "pkg steer" || _st_owns "net zmwarp" || _st_owns "net $ST_OWN_IF"; }; } _st_warp_on() { _st_owns "net $ST_WARP_IF" && [ -s "$ST_WARP_CONF" ]; } _st_warp_own() { [ "$(cat "$ST_WARP_MODE" 2>/dev/null)" = own ]; } _st_sel() { [ -s "$ST_SEL" ] || return 0; local id; for id in $(cat "$ST_SEL"); do _rb_routable "$id" || continue; [ "$id" = custom ] && [ ! -s "$ST_USER_DIR/custom.lst" ] && [ ! -s "$ST_USER_DIR/custom.pfx" ] && continue; echo "$id"; done; } _st_migrate() { local old=/etc/zm-redbtn re='^(pkg (steer|kmod-amneziawg|amneziawg-tools|luci-proto-amneziawg)|net zmwarp|fw zmwarp|steer-spec)$' f id l grep -qE "$re" "$old/owned" 2>/dev/null || return 0 mkdir -p "$ST_DIR" grep -E "$re" "$old/owned" | while read -r l; do _rb_in "$l" "$ST_OWNED" || echo "$l" >> "$ST_OWNED"; done sed -i -E "/$re/d" "$old/owned" for f in warp.conf warp.colo spec.before; do [ -f "$old/$f" ] && [ ! -f "$ST_DIR/$f" ] && mv "$old/$f" "$ST_DIR/$f"; done if [ ! -s "$ST_SEL" ]; then { grep '|warp$' "$old/services" 2>/dev/null | cut -d'|' -f1 cat "$old/warp.pick" 2>/dev/null } | sort -u | while read -r id; do _rb_in "$id" "$old/warp.skip" || echo "$id"; done > "$ST_SEL" fi [ -s "$old/warp.skip" ] && [ ! -s "$ST_SKIP" ] && mv "$old/warp.skip" "$ST_SKIP" rm -f "$old/warp.pick" "$old/warp.skip" sed -i 's/# zm-autobypass$/# zm-steer/' "$CRON_FILE" 2>/dev/null } _st_sel_remap() { local f for f in "$ST_SEL" "$ST_SKIP"; do grep -q '^c_itdoginfo_' "$f" 2>/dev/null || continue awk -v conf="$RB_SHARE/services.conf" 'BEGIN { while ((getline l < conf) > 0) { split(l, a, "|"); if (a[1] != "" && a[1] !~ /^#/) b[a[1]] = 1 } } { k = $0; if (k ~ /^c_itdoginfo_/ && (substr(k, 13) in b)) k = substr(k, 13); if (k != "" && !(k in s)) { s[k] = 1; print k } }' "$f" > "$f.tmp" && mv -f "$f.tmp" "$f" done } _st_migrate _st_sel_remap mkdir -p "$ST_RUN" 2>/dev/null _st_blocker() { local b if [ -x /etc/init.d/splify2 ] || ubus list splify2 >/dev/null 2>&1; then echo splify2; return; fi if b="$(stl_busy_by)"; then echo "Ядро steer ведёт $b — Zapret Manager его не трогает."; return; fi if _st_spec_foreign; then echo steer; return; fi echo "" } _st_spec_foreign() { case "$(stl_spec_whose)" in foreign) return 0 ;; zm) _st_owns "steer-spec" || _st_own "steer-spec" ;; esac return 1 } _rb_arch() { awk -F\' '/DISTRIB_ARCH/ {print $2}' /etc/openwrt_release; } _rb_release() { awk -F\' '/DISTRIB_RELEASE/ {print $2}' /etc/openwrt_release; } _rb_fetch_pkg() { local err rm -f "$2" if ! err=$(curl -fsSL --connect-timeout 8 --max-time 120 -o "$2" "$1" 2>&1); then echo " не скачалось: $1${err:+ — $(printf '%s' "$err" | tail -n1)}" rm -f "$2"; return 1 fi [ -s "$2" ] || { echo " пустой файл: $1"; rm -f "$2"; return 1; } if head -c 512 "$2" | grep -qi ' m ? n : m for (i = 1; i <= k; i++) { if (x[i] + 0 < y[i] + 0) exit 0; if (x[i] + 0 > y[i] + 0) exit 1 } exit 1 }' } _st_vpn_ok() { stl_has vpn || { stl_present && ! stl_v2; }; } _st_install_steer() { local want cur feat="" p want="$(stl_latest)"; cur="$(stl_version)" stl_v2 && _st_ver_lt "$want" "$cur" && want="$cur" { [ "$1" = vpn ] || [ -s "$ST_SUB" ]; } && feat=vpn if stl_v2 && [ "$cur" = "$want" ] && { [ -z "$feat" ] || stl_has vpn; }; then _rb_say "Ядро steer уже последней версии: $cur" return 0 fi if stl_present && ! stl_v2; then _rb_say "Ядро steer $cur — переводим на $want" elif stl_present && [ "$cur" != "$want" ]; then _rb_say "Ядро steer $cur — обновляем до $want"; fi stl_engine_install "$want" $feat || return 1 if _st_owns "pkg steer" || _st_owns "pkg steer-extended"; then sed -i '/^pkg steer$/d; /^pkg steer-extended$/d' "$ST_OWNED" STL_NEW="$STL_PKGS" fi for p in $STL_NEW; do _st_own "pkg $p"; done _rb_rpcd_ensure return 0 } _st_awg_loaded() { grep -q '^amneziawg ' /proc/modules 2>/dev/null || [ -d /sys/module/amneziawg ]; } _awg_board() { ubus call system board 2>/dev/null | jsonfilter -e "$1" 2>/dev/null; } _awg_rel_ver() { local v; v="$(_awg_board '@.release.version')"; [ -n "$v" ] || v="$(_rb_release)"; echo "$v"; } _awg_rel_arch() { local a; a="$(_awg_board '@.release.arch')" [ -n "$a" ] || a="$(_rb_arch)" [ -n "$a" ] || a="$(opkg print-architecture 2>/dev/null | awk 'BEGIN { m = 0 } $3 > m { m = $3; a = $2 } END { print a }')" [ -n "$a" ] || a="$(apk --print-arch 2>/dev/null)" echo "$a" } _awg_rel_target() { local t; t="$(_awg_board '@.release.target')"; [ -n "$t" ] || t="$(awk -F\' '/DISTRIB_TARGET/ {print $2}' /etc/openwrt_release)"; echo "$t"; } _awg_luci_pkg() { local v="$1" ma mi pa ma="$(echo "$v" | cut -d. -f1)"; mi="$(echo "$v" | cut -d. -f2)"; pa="$(echo "$v" | cut -d. -f3 | sed 's/[^0-9].*//')" case "$ma.$mi.$pa" in *[!0-9.]*|*..*|.*|*.) echo luci-app-amneziawg; return ;; esac if [ "$ma" -gt 24 ] || { [ "$ma" -eq 24 ] && [ "$mi" -gt 10 ]; } || { [ "$ma" -eq 24 ] && [ "$mi" -eq 10 ] && [ "$pa" -ge 3 ]; } || { [ "$ma" -eq 23 ] && [ "$mi" -eq 5 ] && [ "$pa" -ge 6 ]; }; then echo luci-proto-amneziawg else echo luci-app-amneziawg fi } _awg_legacy_feeds() { local f for f in /etc/opkg/customfeeds.conf /etc/apk/repositories /etc/apk/repositories.d/*.list; do [ -f "$f" ] && grep -qF 'slava-shchipunov.github.io/awg-openwrt' "$f" || continue _rb_say "Убираем старый фид awg-openwrt из $f — его ключ конфликтует с пакетами 2Grey" sed '\|slava-shchipunov\.github\.io/awg-openwrt|d' "$f" > "$f.zmtmp" && cat "$f.zmtmp" > "$f" rm -f "$f.zmtmp" done } _awg_fetch() { local e for e in "$RAZ" "$([ "$RAZ" = apk ] && echo ipk || echo apk)"; do if _rb_fetch_pkg "$1/$2$3.$e" "$ST_RUN/$2.$e" >&2; then echo "$ST_RUN/$2.$e"; return 0; fi done return 1 } _awg_kmod_load() { local kv ko dir out dm need p free msg _st_awg_loaded && return 0 _rb_say "Загружаем модуль ядра" modprobe amneziawg >/dev/null 2>&1 _st_awg_loaded && return 0 kv="$(uname -r)" ko="$(find /lib/modules -name 'amneziawg.ko' 2>/dev/null | head -n1)" if [ -z "$ko" ]; then echo "ОШИБКА: файла модуля amneziawg.ko нет — пакет kmod-amneziawg встал не полностью" free="$(df -k /overlay 2>/dev/null | awk 'NR==2 {print $4}')" [ -n "$free" ] && [ "$free" -lt 2048 ] && echo "!! На флеше свободно всего $((free / 1024)) МБ — освободите место" echo "!! Нажмите «Переустановить»" return 1 fi dir="${ko#/lib/modules/}"; dir="${dir%%/*}" if [ "$dir" != "$kv" ]; then echo "ОШИБКА: модуль собран для ядра $dir, а у роутера ядро $kv" echo "!! Прошивка отличается от официальной OpenWrt $(_awg_rel_ver) — готовый модуль к ней не подходит. Поставьте официальную прошивку этой версии" return 1 fi for p in kmod-udptunnel4 kmod-udptunnel6 kmod-crypto-lib-chacha20poly1305 kmod-crypto-lib-curve25519; do _pkg_is_installed "$p" || need="$need $p" done if [ -n "$need" ]; then _rb_say "Не хватает модулей ядра:$need — ставим" $INSTALL $need modprobe amneziawg >/dev/null 2>&1 _st_awg_loaded && return 0 fi for p in udp_tunnel ip6_udp_tunnel libchacha20poly1305 libcurve25519 libcurve25519-generic; do modprobe "$p" >/dev/null 2>&1; done out="$(insmod "$ko" 2>&1)" _st_awg_loaded && return 0 dm="$(dmesg 2>/dev/null | grep -i 'amneziawg' | tail -n 3)" case "$out $dm" in *[Uu]nknown\ symbol*) msg="модулю не хватает функций ядра — в этой прошивке нет нужных модулей" ;; *magic*|*[Ii]nvalid\ module\ format*|*disagrees*|*[Ee]xec\ format*) msg="модуль не подходит к ядру этой прошивки" ;; *) msg="ядро не приняло модуль" ;; esac echo "ОШИБКА: $msg" [ -n "$out" ] && echo " insmod: $out" [ -n "$dm" ] && printf '%s\n' "$dm" | sed 's/^/ ядро: /' echo "!! Ядро $kv, OpenWrt $(_awg_rel_ver), $(_awg_rel_target). Перезагрузка не поможет — нужна официальная прошивка OpenWrt этой версии" return 1 } _st_install_awg() { local mode="$1" if [ "$mode" != update ] && _st_awg_loaded && command -v awg >/dev/null 2>&1 && _awg_proto_ok; then return 0; fi local rel arch tgt sub post luci old_luci base bases m p f ok=0 was_loaded=0 kver0 kver1 need="" force="" _st_awg_loaded && was_loaded=1 kver0="$(_awg_pkg_ver kmod-amneziawg)" for p in kmod-amneziawg amneziawg-tools; do if [ "$mode" = update ] || ! _pkg_is_installed "$p"; then need="$need $p"; fi done [ "$p" = amneziawg-tools ] && ! command -v awg >/dev/null 2>&1 && case " $need " in *" amneziawg-tools "*) ;; *) need="$need amneziawg-tools" ;; esac if [ -n "$need" ]; then rel="$(_awg_rel_ver)"; arch="$(_awg_rel_arch)"; tgt="$(_awg_rel_target)" sub="${tgt#*/}"; tgt="${tgt%%/*}" [ -n "$rel" ] && [ -n "$arch" ] && [ -n "$tgt" ] && [ -n "$sub" ] || { echo "ОШИБКА: не удалось определить версию, архитектуру или цель OpenWrt"; return 1; } post="_v${rel}_${arch}_${tgt}_${sub}" luci="$(_awg_luci_pkg "$rel")" old_luci=luci-app-amneziawg; [ "$luci" = luci-app-amneziawg ] && old_luci=luci-proto-amneziawg { [ "$mode" = update ] || ! _pkg_is_installed "$luci"; } && need="$need $luci" [ "$mode" = update ] && [ "$PKG" = opkg ] && force="--force-reinstall" _rb_say "Устанавливаем AmneziaWG для OpenWrt $rel ($arch)" mkdir -p "$ST_RUN" _awg_legacy_feeds _rb_say "Обновляем список пакетов" $UPDATE || _rb_warn "Список пакетов обновился не полностью — продолжаем" bases="" for m in $ST_AWG_MIRRORS; do bases="$bases $m/v$rel"; done bases="$bases $ST_AWG_MIRROR_FLAT/$rel" for base in $bases; do ok=1 for p in $need; do _rb_say "Скачиваем $p" if f="$(_awg_fetch "$base" "$p" "$post")"; then if [ "$p" = "$luci" ] && _pkg_is_installed "$old_luci"; then _rb_say "$old_luci конфликтует с $luci — снимаем" $DELETE "$old_luci" >&2 fi _rb_say "Ставим $p" if $INSTALL $force "$f"; then [ "${ST_AWG_OWN:-1}" = 1 ] && _st_own "pkg $p" elif [ "$p" != "$luci" ]; then ok=0 else _rb_warn "$p не встал — туннели работают, но не видны в LuCI" fi rm -f "$f" elif [ "$p" = "$luci" ]; then _rb_warn "$p для этой версии нет — это не мешает" else ok=0 fi [ "$ok" = 1 ] || break done if [ "$ok" = 1 ]; then if ! _pkg_is_installed luci-i18n-amneziawg-ru && f="$(_awg_fetch "$base" luci-i18n-amneziawg-ru "$post" 2>/dev/null)"; then $INSTALL "$f" >&2 && { [ "${ST_AWG_OWN:-1}" = 1 ] && _st_own "pkg luci-i18n-amneziawg-ru"; } rm -f "$f" fi break fi _rb_warn "Здесь нет всех пакетов — пробуем другой источник" done _rb_rpcd_ensure if [ "$ok" != 1 ]; then echo "ОШИБКА: для OpenWrt $rel ($arch) нет готовых пакетов AmneziaWG" return 1 fi fi kver1="$(_awg_pkg_ver kmod-amneziawg)" if [ "$was_loaded" = 1 ] && [ -n "$kver0" ] && [ "$kver0" != "$kver1" ]; then if [ -z "$(awg show interfaces 2>/dev/null)" ] && rmmod amneziawg >/dev/null 2>&1; then _rb_say "Модуль ядра AmneziaWG перезагружен ($kver0 → $kver1)" else _rb_warn "Модуль ядра обновлён ($kver0 → $kver1) — заработает после перезагрузки роутера" fi fi command -v awg >/dev/null 2>&1 || { echo "ОШИБКА: утилита awg не установилась — нажмите «Переустановить»"; return 1; } _awg_kmod_load || return 1 if ! _awg_proto_ok; then _rb_say "Перезапускаем сеть — страница может ненадолго пропасть" /etc/init.d/network restart >/dev/null 2>&1 _zm_sleep 8 _rb_rpcd_ensure fi return 0 } _st_warp_field() { sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$ST_WARP_CONF" | head -n1; } ST_WARP_API="https://api.cloudflareclient.com/v0a2158/reg https://api.cloudflareclient.com/v0a1922/reg" _st_warp_conf_write() { mkdir -p "$ST_DIR" printf '%s\n' \ "[Interface]" "PrivateKey = $1" "Address = ${3}${4:+, $4}" "MTU = 1280" \ "S1 = $MIXOMO_AWG_S1" "S2 = $MIXOMO_AWG_S2" "Jc = $MIXOMO_AWG_JC" "Jmin = $MIXOMO_AWG_JMIN" "Jmax = $MIXOMO_AWG_JMAX" \ "H1 = $MIXOMO_AWG_H1" "H2 = $MIXOMO_AWG_H2" "H3 = $MIXOMO_AWG_H3" "H4 = $MIXOMO_AWG_H4" "I1 = $MIXOMO_AWG_I1" "" \ "[Peer]" "PublicKey = $2" "AllowedIPs = 0.0.0.0/0" "Endpoint = engage.cloudflareclient.com:4500" "PersistentKeepalive = 25" \ > "$ST_WARP_CONF" chmod 600 "$ST_WARP_CONF" } _st_warp_register() { [ -s "$ST_WARP_CONF" ] && [ -n "$(_st_warp_field PrivateKey)" ] && return 0 mkdir -p "$ST_DIR" "$ST_RUN" _awg_keys_get || { echo "ОШИБКА: ключи WARP не получены — ни один источник не ответил"; return 1; } _st_warp_conf_write "$G_PRIV" "$G_PEER" "$G_V4" "$G_V6" _rb_say "Ключи WARP получены" } _st_warp_iface_write() { local priv addr peer priv="$(_st_warp_field PrivateKey)" addr="$(_st_warp_field Address | tr ',' '\n' | sed 's/[[:space:]]//g' | grep -v ':' | head -n1)" peer="$(_st_warp_field PublicKey)" case "$addr" in */*) ;; *) addr="$addr/32" ;; esac uci -q delete "network.$ST_WARP_IF" uci -q delete "network.${ST_WARP_IF}_peer" _st_peers_drop "$ST_WARP_IF" uci set "network.$ST_WARP_IF=interface" uci set "network.$ST_WARP_IF.proto=amneziawg" uci set "network.$ST_WARP_IF.private_key=$priv" uci add_list "network.$ST_WARP_IF.addresses=$addr" uci set "network.$ST_WARP_IF.mtu=1280" uci set "network.$ST_WARP_IF.awg_jc=$MIXOMO_AWG_JC" uci set "network.$ST_WARP_IF.awg_jmin=$MIXOMO_AWG_JMIN" uci set "network.$ST_WARP_IF.awg_jmax=$MIXOMO_AWG_JMAX" uci set "network.$ST_WARP_IF.awg_s1=$MIXOMO_AWG_S1" uci set "network.$ST_WARP_IF.awg_s2=$MIXOMO_AWG_S2" uci set "network.$ST_WARP_IF.awg_h1=$MIXOMO_AWG_H1" uci set "network.$ST_WARP_IF.awg_h2=$MIXOMO_AWG_H2" uci set "network.$ST_WARP_IF.awg_h3=$MIXOMO_AWG_H3" uci set "network.$ST_WARP_IF.awg_h4=$MIXOMO_AWG_H4" uci set "network.$ST_WARP_IF.awg_i1=$MIXOMO_AWG_I1" uci set "network.${ST_WARP_IF}_peer=amneziawg_$ST_WARP_IF" uci set "network.${ST_WARP_IF}_peer.description=WARP ${ST_WARP_IF#zmwarp}" uci set "network.${ST_WARP_IF}_peer.public_key=$peer" uci add_list "network.${ST_WARP_IF}_peer.allowed_ips=0.0.0.0/0" uci set "network.${ST_WARP_IF}_peer.route_allowed_ips=0" uci set "network.${ST_WARP_IF}_peer.persistent_keepalive=25" uci set "network.${ST_WARP_IF}_peer.endpoint_host=$1" uci set "network.${ST_WARP_IF}_peer.endpoint_port=$2" uci commit network _st_own "net $ST_WARP_IF" } _st_warp_zone() { local want i changed=0 want="$(for i in $(_st_wifs_every); do { _st_owns "net $i" || _st_wifs_all | grep -qx "$i"; } && echo "$i"; done | tr '\n' ' ' | sed 's/ $//')" if [ "$(uci -q get "firewall.$ST_WARP_ZONE")" = "zone" ] && [ "$(uci -q get "firewall.$ST_WARP_ZONE.network")" != "$want" ]; then uci -q delete "firewall.$ST_WARP_ZONE.network" for i in $want; do uci add_list "firewall.$ST_WARP_ZONE.network=$i"; done uci commit firewall /etc/init.d/firewall reload >/dev/null 2>&1 fi if [ "$(uci -q get "firewall.$ST_WARP_ZONE")" != "zone" ]; then uci set "firewall.$ST_WARP_ZONE=zone" uci set "firewall.$ST_WARP_ZONE.name=$ST_WARP_ZONE" for i in $want; do uci add_list "firewall.$ST_WARP_ZONE.network=$i"; done uci set "firewall.$ST_WARP_ZONE.input=REJECT" uci set "firewall.$ST_WARP_ZONE.output=ACCEPT" uci set "firewall.$ST_WARP_ZONE.forward=REJECT" uci set "firewall.$ST_WARP_ZONE.masq=1" uci set "firewall.$ST_WARP_ZONE.mtu_fix=1" uci set "firewall.${ST_WARP_ZONE}_fwd=forwarding" uci set "firewall.${ST_WARP_ZONE}_fwd.src=$(_zm_lan_zone)" uci set "firewall.${ST_WARP_ZONE}_fwd.dest=$ST_WARP_ZONE" uci commit firewall _st_own "fw $ST_WARP_ZONE" /etc/init.d/firewall reload >/dev/null 2>&1 fi _zm_fwd_fix "${ST_WARP_ZONE}_fwd" } _zm_fwd_fix() { local lz [ "$(uci -q get "firewall.$1")" = forwarding ] || return 0 lz="$(_zm_lan_zone)" [ "$(uci -q get "firewall.$1.src")" = "$lz" ] && return 0 uci set "firewall.$1.src=$lz" uci commit firewall /etc/init.d/firewall reload >/dev/null 2>&1 } _st_peers_drop() { local p for p in $(uci -q -X show network | sed -n "s/^network\.\([^.=]*\)=amneziawg_$1\$/\1/p"); do uci -q delete "network.$p"; done } _st_warp_is_ru() { case " $ST_RU_COLOS " in *" $1 "*) return 0 ;; esac; return 1; } _st_wif() { [ "$1" = 1 ] && echo "$ST_WIF1" || echo "zmwarp$1"; } _st_wconf() { [ "$1" = 1 ] && echo "$ST_WCONF1" || echo "$ST_DIR/warp$1.conf"; } _st_with() { local n="$1" oi="$ST_WARP_IF" oc="$ST_WARP_CONF" rc shift ST_WARP_IF="$(_st_wif "$n")"; ST_WARP_CONF="$(_st_wconf "$n")" "$@"; rc=$? ST_WARP_IF="$oi"; ST_WARP_CONF="$oc" return $rc } _st_wifs_all() { local n=1; while [ "$n" -le "$ST_WARP_N" ]; do _st_wif "$n"; n=$((n + 1)); done; } _st_wifs_auto() { echo zmwarp; local n=2; while [ "$n" -le "$ST_WARP_MAX" ]; do echo "zmwarp$n"; n=$((n + 1)); done; } _st_wifs_every() { _st_wifs_auto; echo "$ST_OWN_IF"; } _st_warp_first() { local i [ -s "$ST_WARP_UP" ] && while read -r i _; do [ -d "/sys/class/net/$i" ] && { echo "$i"; return 0; }; done < "$ST_WARP_UP" echo "$ST_WARP_IF" } ST_WARP_POOLS="188.114.96. 188.114.97. 188.114.98. 188.114.99. 162.159.192. 162.159.193. 162.159.195. 8.34.70. 8.34.146. 8.39.214. 8.39.204. 8.6.112. 8.35.211. 8.39.125. 8.47.69." ST_WARP_PORTS="2408 500 1701 4500" ST_WARP_PORTS_EXT="854 859 864 878 880 890 891 894 903 908 928 934 939 942 943 945 946 955 968 987 988 1002 1010 1014 1018 1070 1074 1180 1387 1843 2371 2506 3138 3476 3581 3854 4177 4198 4233 5279 5956 7103 7152 7156 7281 7559 8319 8742 8854 8886" ST_WARP_RAND=10 ST_WARP_HS_WAIT=8 ST_WARP_BURST=10 ST_WARP_TORN=3 _st_warp_link() { local dev="$1" peer="$2" push w=0 hs awg set "$dev" peer "$peer" remove 2>/dev/null awg set "$dev" peer "$peer" endpoint "$3:$4" allowed-ips 0.0.0.0/0 persistent-keepalive 25 2>/dev/null || return 1 ping -I "$dev" -c 1 -W 2 1.1.1.1 >/dev/null 2>&1 & push=$! while [ "$w" -lt $((ST_WARP_HS_WAIT * 4)) ]; do hs=$(awg show "$dev" latest-handshakes 2>/dev/null | awk '{print $2; exit}') if [ "${hs:-0}" -gt 0 ] 2>/dev/null; then wait "$push" 2>/dev/null; return 0; fi w=$((w + 1)) sleep 0.25 2>/dev/null || sleep 1 done kill "$push" 2>/dev/null; wait "$push" 2>/dev/null return 1 } _st_warp_probe() { local out out=$(ping -I "$1" -c "$ST_WARP_BURST" -i 0.2 -W 2 -w 8 1.1.1.1 2>/dev/null) case "$out" in *"packet loss"*) ;; *) out=$(ping -I "$1" -c 5 -W 2 -w 10 1.1.1.1 2>/dev/null) ;; esac case "$out" in *" 100% packet loss"*|'') out=$(ping -I "$1" -c "$ST_WARP_BURST" -i 0.2 -W 2 -w 8 8.8.8.8 2>/dev/null) ;; esac printf '%s\n' "$out" | awk -v t="$ST_WARP_TORN" ' /seq=[0-9]/ { s = $0; sub(/.*seq=/, "", s); sub(/[^0-9].*/, "", s); s += 0; if (!got++ || s > hi) hi = s } /packets transmitted/ { tx = $1 + 0 } /packet loss/ { l = $0; sub(/%.*/, "", l); sub(/.*[^0-9]/, "", l); loss = l + 0; have = 1 } /min\/avg/ { v = $0; sub(/.*= */, "", v); split(v, a, "/"); rtt = int(a[2]) } END { if (!have) loss = 100 torn = (got > 0 && tx > 0 && tx - 1 - hi >= t) ? 1 : 0 printf "%d %d %d\n", loss, (got > 0 && rtt > 0 ? rtt : (got > 0 ? 1 : 99999)), torn }' } _cf_meta() { local j c s k t j=$(curl -s ${1:+--interface "$1"} --connect-timeout 4 --max-time 8 -H 'Referer: https://speed.cloudflare.com' https://speed.cloudflare.com/meta 2>/dev/null) case "$j" in *'"colo"'*) ;; *) return 1 ;; esac c="$(jsonfilter -s "$j" -e '@.colo.iata' 2>/dev/null)" [ -n "$c" ] || c="$(jsonfilter -s "$j" -e '@.colo' 2>/dev/null | grep -E '^[A-Z]{3}$')" [ -n "$c" ] || return 1 s="$(jsonfilter -s "$j" -e '@.country' 2>/dev/null)" k="$(jsonfilter -s "$j" -e '@.colo.cca2' 2>/dev/null)" t="$(jsonfilter -s "$j" -e '@.colo.city' 2>/dev/null | tr -d '"\\\t\r\n')" echo "$c ${s:--} ${k:--} ${t:--}" } _st_warp_geo() { local i m mkdir -p "$ST_DIR" for i in "$@"; do [ -d "/sys/class/net/$i" ] || continue m="$(_cf_meta "$i")" || continue { grep -v "^$i " "$ST_WARP_GEO" 2>/dev/null; echo "$i $m"; } > "$ST_WARP_GEO.tmp" && mv "$ST_WARP_GEO.tmp" "$ST_WARP_GEO" done return 0 } _st_geo_get() { awk -v i="$1" '$1 == i { $1 = ""; sub(/^ /, ""); print; exit }' "$ST_WARP_GEO" 2>/dev/null; } _st_geo_line() { local i c out="" g while read -r i c; do [ -n "$i" ] || continue g="$(_st_geo_get "$i")" set -- $g [ "$1" = "$c" ] && [ -n "$2" ] && [ "$2" != - ] && out="${out:+$out, }$2 ($c)" done < "$ST_WARP_UP" 2>/dev/null echo "$out" } _st_warp_i1() { local f="$ST_RUN/i1.$1" rc mkdir -p "$ST_RUN" uci -q set "network.$1.awg_i1=$2" awg set "$1" i1 "$2" >/dev/null 2>&1 && return 0 awg showconf "$1" > "$f" 2>/dev/null || { rm -f "$f"; return 1; } awk -v m="$2" '/^\[Interface\]/ { print; print "I1 = " m; next } /^I1[ \t]*=/ { next } { print }' "$f" > "$f.n" awg setconf "$1" "$f.n" >/dev/null 2>&1; rc=$? rm -f "$f" "$f.n" return $rc } _st_colo_of() { local c c=$(curl -sI --interface "$1" --max-time 8 -H 'Host: www.cloudflare.com' http://104.16.132.229/ 2>/dev/null | sed -n 's/^[Cc][Ff]-[Rr][Aa][Yy]: *[0-9a-f]*-\([A-Z][A-Z][A-Z]\).*/\1/p' | head -n1) [ -n "$c" ] || c=$(curl -s --interface "$1" --connect-timeout 5 --max-time 8 https://1.1.1.1/cdn-cgi/trace 2>/dev/null | sed -n 's/^colo=//p' | head -n1) [ -n "$c" ] && echo "$c" } ST_WARP_PORTS_TTL=43200 _st_warp_ports() { local f="$ST_DIR/warp.ports" ok="" p list="$ST_WARP_PORTS" t if [ -s "$f" ]; then t="$(date -r "$f" +%s 2>/dev/null || echo 0)" [ $(( $(date +%s) - t )) -lt "$ST_WARP_PORTS_TTL" ] && { cat "$f"; return 0; } rm -f "$f" fi [ "$4" = ext ] && { list="$ST_WARP_PORTS_EXT"; ST_WARP_HS_WAIT=3; } for p in $list; do _st_stopped && return 1 _st_warp_link "$1" "$2" "$3" "$p" && ok="${ok:+$ok }$p" [ "$(echo "$ok" | wc -w)" -ge "$ST_WARP_MAX" ] && break done [ -n "$ok" ] || return 1 echo "$ok" > "$f" echo "$ok" } _st_port_order() { local n case "$1" in zmwarp) n=0 ;; zmwarp[0-9]*) n=$(( ${1#zmwarp} - 1 )) ;; *) n=0 ;; esac [ "$n" -ge 0 ] 2>/dev/null || n=0 echo $2 | awk -v n="$n" '{ c = NF; if (!c) exit; s = n % c; o = ""; for (i = 0; i < c; i++) o = o (i ? " " : "") $((s + i) % c + 1); print o }' } _st_warp_scan1() { local dev="$1" peer="$2" busy=" $3 " busyip=" $4 " pool="$5" quick="$6" r="$ST_RUN/scan.$1" cand ip ports port loss rtt torn colo seen city meta pick f cls local tried=0 lim=3 ST_WARP_HS_WAIT="$ST_WARP_HS_WAIT" [ -n "$quick" ] && { lim=1; ST_WARP_HS_WAIT=4; } mkdir -p "$ST_RUN" : > "$r"; : > "$r.same"; : > "$r.nc"; : > "$r.ru"; : > "$r.notls"; : > "$r.torn" cand=$(awk -v p="$ST_WARP_POOLS" -v n="$ST_WARP_RAND" -v q="$quick" 'BEGIN { srand(); c = split(p, a, " "); if (q != "") { for (i = 1; i <= 4; i++) print a[i] "1"; for (i = 0; i < 2; i++) print a[int(rand() * c) + 1] int(rand() * 253) + 2; exit } for (i = 1; i <= c; i++) print a[i] "1"; for (i = 0; i < n; i++) print a[int(rand() * c) + 1] int(rand() * 253) + 2 }') ports="" for ip in $cand; do [ -n "$ports" ] && break [ "$tried" -ge "$lim" ] && break tried=$((tried + 1)) ports="$(_st_warp_ports "$dev" "$peer" "$ip")" done if [ -z "$ports" ] && [ -z "$quick" ] && ! _st_stopped; then echo " основные порты ($ST_WARP_PORTS) молчат — перебираем 50 запасных портов WARP, это до трёх минут" >&2 ports="$(_st_warp_ports "$dev" "$peer" "$(echo "$cand" | head -n1)" ext)" [ -n "$ports" ] && echo " открыты запасные порты: $ports" >&2 fi [ -n "$ports" ] || { echo "!! WARP: ни один порт не ответил — ни основные ($ST_WARP_PORTS), ни запасные" >&2; return 1; } ports="$(_st_port_order "$dev" "$ports")" local alt altleft=2 p0 p p0="${ports%% *}" for ip in $cand; do _st_stopped && return 1 case "$busyip" in *" $ip "*) continue ;; esac port="$p0" if ! _st_warp_link "$dev" "$peer" "$ip" "$port"; then alt="" if [ "$altleft" -gt 0 ]; then altleft=$((altleft - 1)) for p in ${ports#"$p0"}; do _st_stopped && return 1 _st_warp_link "$dev" "$peer" "$ip" "$p" && { alt="$p"; break; } done fi [ -n "$alt" ] || { echo " $ip:$port — рукопожатия нет" >&2; continue; } echo " $ip:$port — рукопожатия нет, через порт $alt есть" >&2 port="$alt" fi set -- $(_st_warp_probe "$dev"); loss="$1"; rtt="$2"; torn="$3" [ "$loss" -ge 100 ] 2>/dev/null && { echo " $ip:$port — туннель молчит" >&2; continue; } if [ "$torn" = 1 ]; then echo "$loss $rtt $ip $port ?" >> "$r.torn" echo " $ip:$port — трафик пошёл и оборвался: так DPI рвёт туннель" >&2 [ "$(grep -c . "$r.torn")" -ge 6 ] && ! [ -s "$r" ] && ! [ -s "$r.same" ] && ! [ -s "$r.ru" ] && break continue fi seen=""; city="" if meta="$(_cf_meta "$dev")"; then set -- $meta; colo="$1"; seen="$2"; shift 3; city="$*" [ "$seen" = - ] && seen=""; [ "$city" = - ] && city="" else colo="$(_st_colo_of "$dev")" if [ -z "$colo" ]; then if ping -I "$dev" -c 2 -W 2 1.1.1.1 >/dev/null 2>&1; then echo "$loss $rtt $ip $port ?" >> "$r.nc" echo " $ip:$port — пинг идёт ($rtt мс), но веб-запросы через туннель не проходят" >&2 else echo "$loss $rtt $ip $port ?" >> "$r.torn" echo " $ip:$port — пинг прошёл, а на первом веб-запросе туннель оборвался: так DPI рвёт туннель" >&2 [ "$(grep -c . "$r.torn")" -ge 6 ] && ! [ -s "$r" ] && ! [ -s "$r.same" ] && ! [ -s "$r.ru" ] && break fi continue fi if ! _zm_if_web "$dev"; then echo "$loss $rtt $ip $port $colo" >> "$r.notls"; echo " $ip:$port — колония $colo, но HTTPS через туннель не проходит" >&2; continue fi fi echo " $ip:$port — потери $loss%, $rtt мс, колония $colo${city:+ ($city)}${seen:+, сайты видят $seen}" >&2 if _st_warp_is_ru "$colo"; then echo "$loss $rtt $ip $port $colo" >> "$r.ru"; continue; fi case "$busy" in *" $colo "*) echo "$loss $rtt $ip $port $colo" >> "$r.same"; continue ;; esac echo "$loss $rtt $ip $port $colo" >> "$r" [ "$(grep -c . "$r")" -ge 3 ] && break done if [ -n "$pool" ]; then for f in "$r" "$r.same" "$r.ru" "$r.notls" "$r.nc"; do [ -s "$f" ] || continue case "$f" in *.ru) cls=1 ;; *.notls) cls=2 ;; *.nc) cls=3 ;; *) cls=0 ;; esac awk -v c="$cls" '{ printf "%d %s %s %s\n", c * 100000000 + $1 * 100000 + $2, $3, $4, $5 }' "$f" done | sort -n | awk '{ print $2, $3, $4, $1 }' > "$pool" fi for f in "$r" "$r.same" "$r.ru" "$r.notls" "$r.nc"; do [ -s "$f" ] || continue case "$f" in *.ru) cls=1 ;; *.notls) cls=2 ;; *.nc) cls=3 ;; *) cls=0 ;; esac pick=$(awk -v c="$cls" '{ k = c * 100000000 + $1 * 100000 + $2; printf "%d\t%s %s %s %d\n", k, $3, $4, $5, k }' "$f" | sort -n | head -n1 | cut -f2) case "$f" in *.same) echo " другой колонии нет — та же, но через другой адрес" >&2 ;; *.ru) echo " наружных колоний нет — только российские" >&2 ;; *.notls) echo "!! ни через одну точку не проходит HTTPS" >&2 ;; *.nc) echo "!! ни через одну точку не проходят веб-запросы, только пинг" >&2 ;; esac echo "$pick" return 0 done [ -s "$r.torn" ] && echo "!! через все ответившие точки ($(grep -c . "$r.torn")) DPI обрывает туннель — нужна другая маска" >&2 return 1 } _st_warp_scan() { local dev="$1" peer="$2" busy="$3" busyip="$4" pool="$5" orig name mask got fall="" if got="$(_st_warp_scan1 "$dev" "$peer" "$busy" "$busyip" "$pool")"; then set -- $got [ "${4:-0}" -lt 100000000 ] 2>/dev/null && { echo "$got"; return 0; } fall="$got" [ -n "$pool" ] && cp -f "$pool" "$pool.fall" 2>/dev/null fi _st_stopped && { [ -n "$fall" ] && echo "$fall"; [ -n "$fall" ]; return; } orig="$(uci -q get "network.$dev.awg_i1")" [ -n "$orig" ] || { [ -n "$fall" ] && echo "$fall"; [ -n "$fall" ]; return; } if [ -n "$fall" ]; then echo " хорошей зарубежной точки нет — пробуем другие маски первого пакета (I1), запасная точка остаётся" >&2 else echo " меняем маску первого пакета (I1) и пробуем ещё раз" >&2 fi for name in $AWG_I1_SET; do _st_stopped && break mask="$(_awg_i1 "$name")" [ -n "$mask" ] && [ "$mask" != "$orig" ] || continue _st_warp_i1 "$dev" "$mask" || { echo " модуль AmneziaWG не даёт сменить маску на лету" >&2; break; } echo " маска $(_awg_mask_name "$mask"):" >&2 if got="$(_st_warp_scan1 "$dev" "$peer" "$busy" "$busyip" "$pool" quick)" && set -- $got && [ "${4:-0}" -lt 100000000 ] 2>/dev/null; then printf '%s\n' "$mask" > "$ST_RUN/warp.mask" echo " маска $(_awg_mask_name "$mask") проходит — она останется у туннеля" >&2 [ -n "$pool" ] && rm -f "$pool.fall" echo "$got" return 0 fi done _st_warp_i1 "$dev" "$orig" >/dev/null 2>&1 if [ -n "$fall" ]; then [ -n "$pool" ] && [ -s "$pool.fall" ] && mv -f "$pool.fall" "$pool" set -- $fall case "$(( ${4:-0} / 100000000 ))" in 1) echo " маски не помогли — беру российскую колонию $3: блокировки через неё не снимаются" >&2 ;; 2) echo " маски не помогли — беру точку, где HTTPS не проходит" >&2 ;; *) echo " маски не помогли — оставляю точку, где идёт хотя бы пинг" >&2 ;; esac echo "$fall" return 0 fi return 1 } _st_warp_from_pool() { local dev="$1" peer="$2" busy=" $3 " busyip=" $4 " f="$5" ip port colo k pass good isbusy pp [ -s "$f" ] || return 1 [ -s "$ST_RUN/warp.mask" ] && _st_warp_i1 "$dev" "$(cat "$ST_RUN/warp.mask")" >/dev/null 2>&1 for pass in 1 2 3 4; do while read -r ip port colo k <&3; do [ -n "$ip" ] || continue case "$busyip" in *" $ip "*) continue ;; esac good=0; [ "$k" -lt 100000000 ] 2>/dev/null && good=1 isbusy=0; case "$busy" in *" $colo "*) isbusy=1 ;; esac case "$pass$good$isbusy" in 110|211|300|401) ;; *) continue ;; esac _st_stopped && return 1 pp="$(_st_port_order "$dev" "$(cat "$ST_DIR/warp.ports" 2>/dev/null)")"; pp="${pp%% *}" if [ -n "$pp" ] && [ "$pp" != "$port" ] && _st_warp_link "$dev" "$peer" "$ip" "$pp"; then port="$pp" else _st_warp_link "$dev" "$peer" "$ip" "$port" || { echo " $ip:$port — с этими ключами рукопожатия нет" >&2; continue; }; fi set -- $(_st_warp_probe "$dev") [ "$3" = 1 ] && { echo " $ip:$port — трафик пошёл и оборвался" >&2; continue; } if [ "$good" = 1 ] && ! _zm_if_web "$dev"; then echo " $ip:$port — HTTPS через туннель не проходит" >&2; continue fi echo "$ip $port $colo $k" return 0 done 3< "$f" done return 1 } _st_warp_alive_if() { local hs hs=$(awg show "$1" latest-handshakes 2>/dev/null | awk '{print $2; exit}') [ "${hs:-0}" -gt 0 ] 2>/dev/null && [ $(( $(date +%s) - hs )) -lt 180 ] } _st_warp_ready_if() { ifstatus "$1" 2>/dev/null | grep -q '"up": true' && awg show "$1" peers 2>/dev/null | grep -q .; } _st_warp_order() { local f="$1" i c k pos=0 fresh=0 grep -q ' [0-9][0-9]*$' "$f" && fresh=1 : > "$f.key" while read -r i c k; do [ -n "$i" ] || continue pos=$((pos + 1)) if [ "$fresh" = 0 ]; then k=$(awk -v i="$i" '$1 == i { print NR; exit }' "$ST_WARP_UP" 2>/dev/null) [ -n "$k" ] || k=$((100 + pos)) elif [ "$k" = - ]; then set -- $(_st_warp_probe "$i") k=$(($1 * 100000 + $2)) fi printf '%012d %03d %s %s\n' "$k" "$pos" "$i" "$c" >> "$f.key" done < "$f" sort "$f.key" | awk '{ print $3, $4 }' > "$f" rm -f "$f.key" } _st_warp_own_iface() { local f="$1" kv="$ST_RUN/own.kv" priv pub psk addr a ep host port keep mtu k v i="$ST_WARP_IF" mkdir -p "$ST_RUN" _awg_conf_kv "$f" > "$kv" priv="$(_awg_cv "$kv" interface privatekey)"; pub="$(_awg_cv "$kv" peer publickey)" addr="$(_awg_cv "$kv" interface address)"; ep="$(_awg_cv "$kv" peer endpoint)" psk="$(_awg_cv "$kv" peer presharedkey)"; keep="$(_awg_cv "$kv" peer persistentkeepalive)"; mtu="$(_awg_cv "$kv" interface mtu)" case "$ep" in \[*\]:*) host="${ep%]:*}"; host="${host#[}"; port="${ep##*]:}" ;; ?*:*) host="${ep%:*}"; port="${ep##*:}" ;; esac if [ -z "$priv" ] || [ -z "$pub" ] || [ -z "$addr" ] || [ -z "$host" ] || [ -z "$port" ]; then rm -f "$kv" echo "ОШИБКА: в конфиге нет PrivateKey, Address, PublicKey или Endpoint (адрес:порт)" return 1 fi uci -q delete "network.$i" uci -q delete "network.${i}_peer" _st_peers_drop "$i" uci set "network.$i=interface" uci set "network.$i.proto=amneziawg" uci set "network.$i.private_key=$priv" for a in $(echo "$addr" | tr ',' ' '); do case "$a" in */*) ;; *:*) a="$a/128" ;; *) a="$a/32" ;; esac uci add_list "network.$i.addresses=$a" done uci set "network.$i.mtu=${mtu:-1280}" _awg_iface_set "$f" "$i" uci set "network.${i}_peer=amneziawg_$i" uci set "network.${i}_peer.description=Свой WARP" uci set "network.${i}_peer.public_key=$pub" [ -n "$psk" ] && uci set "network.${i}_peer.preshared_key=$psk" uci add_list "network.${i}_peer.allowed_ips=0.0.0.0/0" case "$addr" in *:*) uci add_list "network.${i}_peer.allowed_ips=::/0" ;; esac uci set "network.${i}_peer.route_allowed_ips=0" uci set "network.${i}_peer.persistent_keepalive=${keep:-25}" uci set "network.${i}_peer.endpoint_host=$host" uci set "network.${i}_peer.endpoint_port=$port" uci commit network _st_own "net $i" rm -f "$kv" } _st_warp_park() { local i for i in "$@"; do _st_owns "net $i" || continue ifdown "$i" >/dev/null 2>&1 uci -q set "network.$i.auto=0" done uci -q commit network return 0 } _st_warp_own_up() { local i="$ST_WARP_IF" w=0 col _st_install_awg || return 1 _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 if [ "$(uci -q get "network.$i.proto")" != amneziawg ]; then _st_own_restore [ -s "$ST_WARP_CONF" ] || { echo "ОШИБКА: своего конфига WARP нет — вставьте его на вкладке WARP"; return 1; } _st_warp_own_iface "$ST_WARP_CONF" || return 1 ubus call network reload >/dev/null 2>&1 sleep 2 fi _st_warp_zone uci -q get "network.$i.auto" >/dev/null && { uci -q delete "network.$i.auto"; uci -q commit network; } _rb_say "Поднимаем свой туннель WARP" ubus call "network.interface.$i" up >/dev/null 2>&1 while [ "$w" -lt 25 ]; do _st_stopped && return 1 _st_warp_alive_if "$i" && break [ -d "/sys/class/net/$i" ] || sleep 1 ping -I "$i" -c 1 -W 1 1.1.1.1 >/dev/null 2>&1 w=$((w + 1)) done if ! _st_warp_alive_if "$i"; then rm -f "$ST_WARP_UP" "$ST_DIR/warp.colo" echo "ОШИБКА: сервер из вашего конфига не отвечает — проверьте ключи и Endpoint" return 1 fi col="$(_st_colo_of "$i")" echo "$i ${col:-?}" > "$ST_WARP_UP" if [ -n "$col" ]; then echo "$col" > "$ST_DIR/warp.colo"; else rm -f "$ST_DIR/warp.colo"; fi _st_tgws_warp _st_warp_geo "$i" _rb_say "Свой WARP работает: $(uci -q get "network.${i}_peer.endpoint_host"):$(uci -q get "network.${i}_peer.endpoint_port")${col:+, колония $col}" col="$(_st_geo_line)" [ -n "$col" ] && _rb_say "Сайты видят WARP как: $col" col="$(awk '{ print $2; exit }' "$ST_WARP_UP")" [ -n "$col" ] && _st_warp_is_ru "$col" && _rb_warn "Туннель идёт через российскую колонию $col: заблокированное через неё не откроется" return 0 } _st_warp_up() { local repick="$1" n i peer got busy="" busyip="" ru=0 c ready="" w need=0 colos="" alt p _st_warp_own && { _st_warp_own_up; return; } _st_install_awg || return 1 [ "$repick" = repick ] && rm -f "$ST_DIR/warp.ports" _rb_say "Поднимаем туннели WARP в разных колониях" n=1 while [ "$n" -le "$ST_WARP_N" ]; do if _st_with "$n" _st_warp_register; then ready="$ready $n" i="$(_st_wif "$n")" if [ "$(uci -q get "network.$i.proto")" != amneziawg ]; then _st_with "$n" _st_warp_iface_write 162.159.192.1 2408 need=1 fi else [ "$n" = 1 ] && { echo "ОШИБКА: не удалось получить ключи WARP"; return 1; } _rb_warn "WARP $n: не удалось получить ключи — туннелей будет меньше" fi n=$((n + 1)) done _st_warp_zone for n in $ready; do i="$(_st_wif "$n")" [ "$(uci -q get "network.$i.auto")" = 0 ] && { uci -q delete "network.$i.auto"; need=1; } done [ "$need" = 1 ] && { uci -q commit network; ubus call network reload >/dev/null 2>&1; } for n in $ready; do ubus call "network.interface.$(_st_wif "$n")" up >/dev/null 2>&1; done for n in $ready; do i="$(_st_wif "$n")"; w=0 while [ "$w" -lt 20 ] && ! _st_warp_ready_if "$i"; do w=$((w + 1)); sleep 1; done done : > "$ST_WARP_UP.tmp" local pool="$ST_RUN/warp.pool" rm -f "$pool" "$ST_RUN/warp.mask" for n in $ready; do _st_stopped && break i="$(_st_wif "$n")" peer="$(_st_with "$n" _st_warp_field PublicKey)" _st_warp_ready_if "$i" || { _rb_warn "WARP $n: устройство не поднялось"; continue; } got="" if [ "$repick" != repick ] && grep -q "^$i " "$ST_WARP_UP" 2>/dev/null && _st_warp_alive_if "$i" && c="$(_st_colo_of "$i")" && ! _st_warp_is_ru "$c"; then got="$(uci -q get "network.${i}_peer.endpoint_host") $(uci -q get "network.${i}_peer.endpoint_port") $c -" _rb_say "WARP $n работает: колония $c" elif [ -s "$pool" ] && got="$(_st_warp_from_pool "$i" "$peer" "$busy" "$busyip" "$pool")"; then set -- $got _rb_say "WARP $n работает: $1:$2, колония $3 — точка из общей разведки" else echo " WARP $n: разведка" if got="$(_st_warp_scan "$i" "$peer" "$busy" "$busyip" "$pool")"; then set -- $got if ! _st_warp_link "$i" "$peer" "$1" "$2"; then alt="" for p in $(cat "$ST_DIR/warp.ports" 2>/dev/null); do [ "$p" = "$2" ] && continue _st_stopped && break _st_warp_link "$i" "$peer" "$1" "$p" && { alt="$p"; break; } done if [ -z "$alt" ]; then _rb_warn "WARP $n: выбранная точка $1:$2 не ответила повторно" continue fi _rb_say "WARP $n: точка $1:$2 не ответила повторно, беру запасной порт $alt" got="$1 $alt $3 $4" set -- $got fi _rb_say "WARP $n работает: $1:$2, колония $3" else _rb_warn "WARP $n не поднялся" continue fi fi set -- $got busy="$busy $3"; busyip="$busyip $1" _st_warp_is_ru "$3" && ru=1 echo "$i $3 ${4:--}" >> "$ST_WARP_UP.tmp" uci set "network.${i}_peer.endpoint_host=$1" uci set "network.${i}_peer.endpoint_port=$2" done if _st_stopped; then uci revert network >/dev/null 2>&1 rm -f "$ST_WARP_UP.tmp" return 1 fi uci commit network _st_warp_order "$ST_WARP_UP.tmp" mv "$ST_WARP_UP.tmp" "$ST_WARP_UP" colos="$(awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $2 }' "$ST_WARP_UP")" for n in $ready; do i="$(_st_wif "$n")" grep -q "^$i " "$ST_WARP_UP" || ifdown "$i" >/dev/null 2>&1 done if [ ! -s "$ST_WARP_UP" ]; then rm -f "$ST_DIR/warp.colo" echo "ОШИБКА: туннель WARP не поднялся ни через одну точку входа" return 1 fi echo "$colos" > "$ST_DIR/warp.colo" _st_tgws_warp _st_warp_geo $(awk '{ print $1 }' "$ST_WARP_UP") c="$(_st_geo_line)" [ -n "$c" ] && _rb_say "Сайты видят WARP как: $c" [ "$ru" = 1 ] && _rb_warn "Часть туннелей WARP идёт через российские колонии: заблокированное через них не откроется" return 0 } ST_TGWS_WARP="/etc/stgws/warp.lst" _st_tgws_warp() { local i c [ -d /etc/stgws ] || return 0 : > "$ST_TGWS_WARP.tmp" while read -r i c; do [ -n "$i" ] || continue _st_warp_is_ru "$c" || echo "$i" >> "$ST_TGWS_WARP.tmp" done < "$ST_WARP_UP" 2>/dev/null mv "$ST_TGWS_WARP.tmp" "$ST_TGWS_WARP" } ST_LISTS_MANIFEST="https://github.com/xyzmean/splify2-lists/releases/latest/download/lists.json" ST_CAT_SKIP=" mydyson " ST_SRS_FALLBACK="https://github.com/itdoginfo/allow-domains/releases/latest/download" _st_fetch() { local i tried="" if [ -f "$ST_DIR/lists.via" ]; then i="$(_st_warp_first)" if [ -n "$i" ] && [ -d "/sys/class/net/$i" ]; then tried=1 curl -fsSL --interface "$i" --connect-timeout 6 --max-time 60 -o "$2" "$1" 2>/dev/null && [ -s "$2" ] && return 0 fi fi curl -fsSL --connect-timeout 6 --max-time 60 -o "$2" "$1" 2>/dev/null && [ -s "$2" ] && return 0 [ -z "$tried" ] || return 1 i="$(_st_warp_first)" [ -n "$i" ] && [ -d "/sys/class/net/$i" ] && curl -fsSL --interface "$i" --connect-timeout 6 --max-time 60 -o "$2" "$1" 2>/dev/null && [ -s "$2" ] } _json_flat() { awk 'BEGIN { RS = "\001" } function emit(v) { p = ""; for (k = 1; k <= d; k++) p = p (k > 1 ? "." : "") key[k]; print p "\t" v; if (arr[d]) key[d]++ } { s = $0; n = length(s); i = 1; d = 0 while (i <= n) { c = substr(s, i, 1) if (c == "{") { d++; arr[d] = 0; key[d] = ""; want = 1; i++; continue } if (c == "[") { d++; arr[d] = 1; key[d] = 0; i++; continue } if (c == "}" || c == "]") { d--; if (d > 0 && arr[d]) key[d]++; i++; continue } if (c == ",") { if (!arr[d]) want = 1; i++; continue } if (c == ":") { want = 0; i++; continue } if (c == "\"") { v = ""; i++ while (i <= n) { c = substr(s, i, 1) if (c == "\\") { e = substr(s, i + 1, 1); v = v (e == "n" || e == "t" || e == "r" ? " " : e); i += 2; continue } if (c == "\"") break v = v c; i++ } i++ if (!arr[d] && want) { key[d] = v; want = 0 } else emit(v) continue } if (c ~ /[-0-9a-z]/) { v = "" while (i <= n && substr(s, i, 1) ~ /[-+.0-9a-zA-Z]/) { v = v substr(s, i, 1); i++ } emit(v); continue } i++ } }' "$1" } _st_cat_build() { local flat="$ST_RUN/cat.flat" bsets bnames mkdir -p "$ST_RUN" "$ST_DIR" _json_flat "$1" > "$flat" 2>/dev/null grep -q '^domain_lists\.0\.id \|^categories\.0\.id ' "$flat" || { rm -f "$flat"; return 1; } bsets=" $(grep -v '^#' "$RB_SHARE/services.conf" | cut -d'|' -f8 | tr ',\n' ' ') " bnames="$(grep -v '^#' "$RB_SHARE/services.conf" | cut -d'|' -f2 | tr 'A-Z' 'a-z' | tr '\n' '|')" awk -F'\t' -v skip="$ST_CAT_SKIP" -v bsets="$bsets" -v bnames="|$bnames" -v idx="$ST_CAT_IDX.tmp" -v meta="$ST_CAT_META.tmp" ' function key(id) { id = tolower(id); sub(/^svc_/, "", id); gsub(/[^a-z0-9_]/, "_", id); return "c_" id } function clean(s) { gsub(/[|\t\r\n]/, " ", s); return s } { p = $1; v = $2 if (p == "version" || p == "base_url" || p == "generated_at") { top[p] = v; next } n = split(p, a, ".") if (a[1] != "domain_lists" && a[1] != "categories") next e = a[1] "." a[2] if (!(e in seen)) { seen[e] = 1; order[++cnt] = e } if (n == 3) f[e, a[3]] = v else if (a[3] == "same_as_ip" && a[4] == "0") f[e, "pair"] = v } END { base = top["base_url"]; sub(/\/+$/, "", base) for (j = 1; j <= cnt; j++) { e = order[j]; id = f[e, "id"]; if (id == "") continue if (index(skip, " " tolower(id) " ")) { bad[e] = 1; continue } if (f[e, "source_name"] == "" && f[e, "source"] == "") { bad[e] = 1; continue } k = key(id); byid[id] = e; ek[e] = k u = f[e, "url"]; fm = f[e, "format"] if (fm != "srs" || u == "") { fm = "lst"; u = (f[e, "file"] != "" && base != "") ? base "/" f[e, "file"] : "" } if (u == "" || u !~ /^https:\/\//) { bad[e] = 1; continue } if (!(k in done)) { done[k] = 1; print k "|" fm "|" u "|" (e ~ /^categories/ ? "s" : "d") > idx; sets++ } } for (j = 1; j <= cnt; j++) { e = order[j] if (e !~ /^domain_lists/ || bad[e] || f[e, "id"] == "") continue pr = f[e, "pair"] if (pr != "" && (pr in byid) && !bad[byid[pr]]) { used[byid[pr]] = 1; st = ek[e]; if (ek[byid[pr]] != st) st = st "," ek[byid[pr]]; line(e, st) } else line(e, ek[e]) } for (j = 1; j <= cnt; j++) { e = order[j] if (e !~ /^categories/ || bad[e] || used[e] || f[e, "id"] == "") continue line(e, ek[e]) } printf "version=%s\nsets=%d\nservices=%d\n", clean(top["version"]), sets, svcs > meta } function line(e, st, nm, grp, u, b) { nm = clean(f[e, "name_ru"]); if (nm == "") nm = f[e, "id"] grp = clean(f[e, "source_name"]); if (grp == "") return if (f[e, "source"] == "itdoginfo/allow-domains") { u = f[e, "url"]; b = u; sub(/.*\//, "", b); sub(/\.srs$/, "", b) if (index(bsets, " " b " ") || index(bnames, "|" tolower(nm) "|")) return } if (ek[e] in out) return out[ek[e]] = 1; svcs++ print ek[e] "|" nm "|||||" "|" st "|" grp }' "$flat" > "$ST_CAT_SVC.tmp" || { rm -f "$flat" "$ST_CAT_SVC.tmp" "$ST_CAT_IDX.tmp" "$ST_CAT_META.tmp"; return 1; } rm -f "$flat" [ -s "$ST_CAT_SVC.tmp" ] && [ -s "$ST_CAT_IDX.tmp" ] || { rm -f "$ST_CAT_SVC.tmp" "$ST_CAT_IDX.tmp" "$ST_CAT_META.tmp"; return 1; } mv -f "$ST_CAT_IDX.tmp" "$ST_CAT_IDX" mv -f "$ST_CAT_SVC.tmp" "$ST_CAT_SVC" mv -f "$ST_CAT_META.tmp" "$ST_CAT_META" } _st_cat_src() { [ -s "$ST_CAT_URL" ] && head -n1 "$ST_CAT_URL" || echo "$ST_LISTS_MANIFEST"; } _st_cat_refresh() { local m="$ST_RUN/lists.json" lk="$ST_RUN/cat.lock" rc=0 t [ -f "$ST_CAT_OFF" ] && return 0 mkdir -p "$ST_DIR" "$ST_RUN" if [ "$1" != force ] && [ -s "$ST_CAT_SVC" ]; then t="$(date -r "$ST_CAT_SVC" +%s 2>/dev/null || echo 0)" [ $(( $(date +%s) - t )) -lt 21600 ] && return 0 fi if ! mkdir "$lk" 2>/dev/null; then [ -n "$(find "$lk" -maxdepth 0 -mmin +5 2>/dev/null)" ] || return 0 rmdir "$lk" 2>/dev/null; mkdir "$lk" 2>/dev/null || return 0 fi if _st_fetch "$(_st_cat_src)" "$m.tmp" && _st_cat_build "$m.tmp"; then mv -f "$m.tmp" "$m" touch "$ST_CAT_SVC" rm -f "$ST_DIR/catalog.err" else rm -f "$m.tmp" echo "$(date +%s)" > "$ST_DIR/catalog.err" rc=1 fi rmdir "$lk" 2>/dev/null return $rc } _st_cat_bg() { [ -f "$ST_CAT_OFF" ] && return 0 [ -d "$ST_RUN/cat.lock" ] && [ -z "$(find "$ST_RUN/cat.lock" -maxdepth 0 -mmin +5 2>/dev/null)" ] && return 0 if grep -q '|Свои списки каталога$' "$ST_CAT_SVC" 2>/dev/null; then ( _st_cat_refresh force >/dev/null 2>&1 & ); return 0; fi [ -f "$ST_DIR/catalog.err" ] && [ $(( $(date +%s) - $(cat "$ST_DIR/catalog.err" 2>/dev/null || echo 0) )) -lt 1800 ] && return 0 if [ -s "$ST_CAT_SVC" ] && [ "$RB_SHARE/services.conf" -nt "$ST_CAT_SVC" ]; then if [ -s "$ST_RUN/lists.json" ] && mkdir "$ST_RUN/cat.lock" 2>/dev/null; then _st_cat_build "$ST_RUN/lists.json"; local rc=$? rmdir "$ST_RUN/cat.lock" 2>/dev/null [ "$rc" = 0 ] && return 0 fi ( _st_cat_refresh force >/dev/null 2>&1 & ) return 0 fi [ -s "$ST_CAT_SVC" ] && [ $(( $(date +%s) - $(date -r "$ST_CAT_SVC" +%s 2>/dev/null || echo 0) )) -lt 21600 ] && return 0 ( _st_cat_refresh >/dev/null 2>&1 & ) } _st_cat_json() { local ver="" sets=0 svcs=0 err=false off=false src [ -s "$ST_CAT_META" ] && { ver="$(sed -n 's/^version=//p' "$ST_CAT_META")"; sets="$(sed -n 's/^sets=//p' "$ST_CAT_META")"; svcs="$(sed -n 's/^services=//p' "$ST_CAT_META")"; } [ -f "$ST_DIR/catalog.err" ] && err=true [ -f "$ST_CAT_OFF" ] && off=true src="$(_st_cat_src)" printf '{"url":"%s","custom":%s,"version":"%s","sets":%s,"services":%s,"error":%s,"off":%s,"busy":%s}' \ "$(esc "$src")" "$([ -s "$ST_CAT_URL" ] && echo true || echo false)" "$(esc "$ver")" "${sets:-0}" "${svcs:-0}" "$err" "$off" \ "$([ -d "$ST_RUN/cat.lock" ] && echo true || echo false)" } _st_srs_url() { local m="$ST_RUN/lists.json" u="" u="$(awk -F'|' -v k="$1" '$1 == k { print $3; exit }' "$ST_CAT_IDX" 2>/dev/null)" [ -n "$u" ] && { echo "$u"; return 0; } [ -s "$m" ] || _st_fetch "$(_st_cat_src)" "$m" || rm -f "$m" [ -s "$m" ] && u=$(grep -o '"url"[[:space:]]*:[[:space:]]*"[^"]*/allow-domains/releases/download/[^"]*/'"$1"'\.srs"' "$m" | head -n1 | sed 's/.*"\(https[^"]*\)"$/\1/') echo "${u:-$ST_SRS_FALLBACK/$1.srs}" } _st_srs_get() { local set="$1" d="$ST_DIR/lists" f fmt x mkdir -p "$d" [ -f "$ST_RUN/srs.$set.done" ] && return 0 fmt="$(awk -F'|' -v k="$set" '$1 == k { print $2; exit }' "$ST_CAT_IDX" 2>/dev/null)" f="$ST_RUN/$set.src" if [ "$fmt" = lst ]; then _st_fetch "$(_st_srs_url "$set")" "$f" || return 1 tr -d '\r' < "$f" | sed 's/[[:space:]]*#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//' | tr 'A-Z' 'a-z' > "$f.n" grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$' "$f.n" | awk '!s[$0]++' > "$d/$set.pfx.tmp" sed 's/^domain://; s/^full://; s/^suffix://; s/^\*\.//; s/^\.//' "$f.n" | grep -E '^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$' | grep -vE '^[0-9.]+$' | awk '!s[$0]++' > "$d/$set.dom.tmp" : > "$d/$set.meta.tmp" rm -f "$f.n" [ -s "$d/$set.dom.tmp" ] || [ -s "$d/$set.pfx.tmp" ] || { rm -f "$f" "$d/$set".*.tmp; return 1; } echo " ✓ $set: скачан, доменов $(wc -l < "$d/$set.dom.tmp" | tr -d ' '), подсетей $(wc -l < "$d/$set.pfx.tmp" | tr -d ' ')" >&2 else if [ ! -f "$ST_RUN/dl.$set" ]; then if _st_fetch "$(_st_srs_url "$set")" "$f" && [ "$(head -c 3 "$f")" = SRS ]; then mv -f "$f" "$d/$set.srs" touch "$ST_RUN/dl.$set" echo " ✓ $set.srs: скачан, $(ls -l "$d/$set.srs" | awk '{ printf "%d КБ", ($5 + 1023) / 1024 }')" >&2 else rm -f "$f" [ -s "$d/$set.srs" ] || return 1 _rb_warn "Набор $set не скачался — беру прошлую копию" >&2 touch "$ST_RUN/dl.$set" fi fi [ -s "$d/$set.srs" ] || return 1 touch "$ST_RUN/srs.$set.done" return 0 fi for x in dom pfx meta; do touch "$d/$set.$x.tmp"; mv "$d/$set.$x.tmp" "$d/$set.$x"; done rm -f "$f" touch "$ST_RUN/srs.$set.done" } _st_svc_lists() { local id="$1" name sets set dom="" pfx="" srs="" f fmt took="" name="$(_rb_svc_field "$id" 2)" sets="$(_rb_svc_field "$id" 8 | tr ',' ' ')" if [ -n "$(echo $sets)" ]; then echo " → «$name»: скачиваем списки $(echo $sets | sed 's/ /, /g')" >&2 elif [ "$id" = custom ]; then echo " → «$name»: доменов $(cat "$ST_USER_DIR/$id.lst" 2>/dev/null | grep -c .), подсетей $(cat "$ST_USER_DIR/$id.pfx" 2>/dev/null | grep -c .)" >&2 else echo " → «$name»: список из репозитория" >&2; fi for set in $sets; do [ -f "$ST_RUN/used.$set" ] && continue if _st_srs_get "$set"; then took="$took $set" f="$ST_DIR/lists/$set" fmt="$(awk -F'|' -v k="$set" '$1 == k { print $2; exit }' "$ST_CAT_IDX" 2>/dev/null)" if [ "$fmt" != lst ]; then srs="$srs $f.srs"; continue; fi [ -s "$f.dom" ] && dom="$dom $f.dom" [ -s "$f.pfx" ] && pfx="$pfx $f.pfx" else if [ -n "$(_rb_svc_field "$id" 3)$(_rb_svc_field "$id" 4)" ]; then _rb_warn "Список $set не скачался или не раскрылся — беру резервный список" >&2 else _rb_warn "Список «$name» не скачался или не раскрылся — пропускаем его в этот раз" >&2; fi sets="" break fi done for set in $took; do [ -n "$sets" ] && touch "$ST_RUN/used.$set"; done if [ -z "$sets" ]; then dom="$(_st_pkg_lists "$(_rb_svc_field "$id" 3 | tr ',' ' ')")" pfx="$(_st_pkg_lists "$(_rb_svc_field "$id" 4 | tr ',' ' ')")" srs="" if [ -z "$dom$pfx" ] && [ "$id" != custom ]; then f="$ST_DIR/lists/$id.base.dom" mkdir -p "$ST_DIR/lists" printf '%s,%s\n' "$(_rb_svc_field "$id" 5)" "$(_rb_svc_field "$id" 6)" | tr ',' '\n' | grep -E '^[a-z0-9.-]+\.[a-z]+$' | awk '!s[$0]++' > "$f" if [ -s "$f" ]; then _rb_warn "Списки «$name» не скачались — пока беру только основные домены сервиса ($(wc -l < "$f" | tr -d ' ')), позже нажмите «Применить» ещё раз" >&2 dom="$f" else rm -f "$f" fi fi fi [ "$id" = custom ] && [ -s "$ST_USER_DIR/$id.lst" ] && dom="$ST_USER_DIR/$id.lst" [ "$id" = custom ] && [ -s "$ST_USER_DIR/$id.pfx" ] && pfx="$ST_USER_DIR/$id.pfx" [ -n "$srs$dom$pfx" ] || return 0 printf 'svc\t%s\t%s\n' "$id" "$name" for f in $srs; do printf 'srs\t%s\n' "$f"; done for f in $dom; do printf 'dom\t%s\n' "$f"; done for f in $pfx; do printf 'pfx\t%s\n' "$f"; done } _st_pkg_lists() { local f for f in $1; do _rb_list_get "$f" && [ -s "$ZM_LISTS_DIR/$f" ] && echo "$ZM_LISTS_DIR/$f"; done } _st_model() { local id rm -f "$ST_RUN"/used.* "$ST_RUN"/srs.*.done mkdir -p "$ST_RUN" printf 'lan'; for id in $(_zm_lan_devs); do printf '\t%s' "$id"; done; echo if _st_use_vpn; then printf 'vpn\t%s\t%s\n' "$ST_SUB" "$(_st_sub_filter)" else printf 'warp' if [ -s "$ST_WARP_UP" ]; then awk '{ printf "\t%s", $1 }' "$ST_WARP_UP"; else printf '\t%s' "$ST_WARP_IF"; fi echo fi for id; do _st_svc_lists "$id"; done } _st_spec_apply() { local m="$ST_RUN/model" _rb_say "Скачиваем списки выбранных сервисов" _st_model "$@" > "$m" grep -q '^svc ' "$m" || { rm -f "$m"; echo "ОШИБКА: для выбранных сервисов нет ни одного списка"; return 1; } stl_apply "$m" || { rm -f "$m"; return 1; } rm -f "$m" _st_own "steer-spec" } _st_spec_clear() { _st_owns "steer-spec" || return 0 stl_stop stl_spec_restore sed -i '/^steer-spec$/d' "$ST_OWNED" _doh_force_apply } _st_kick() { _st_owns "steer-spec" || return 0 [ -f "$ST_OFF" ] && return 0 stl_enabled || return 0 local sel sel="$(_st_sel | tr '\n' ' ')" if [ -n "$(echo $sel)" ] && _st_spec_apply $sel; then return 0; fi stl_restart _rb_say "Steer перезапущен" } _st_dns_conflict() { return 1 [ -f /etc/config/https-dns-proxy ] || return 1 [ -f "$ST_OFF" ] && return 1 /etc/init.d/https-dns-proxy running >/dev/null 2>&1 || return 1 _st_owns "steer-spec" || return 1 [ "$(uci -q get https-dns-proxy.config.force_dns)" != "0" ] } do_steer_dns_fix() { _doh_force_apply [ -f "$ST_OFF" ] && return 0 stl_enabled && stl_restart return 0 } _st_cron_restart() { local i stl_enabled || return 0 for i in $(awk '{print $1}' "$ST_WARP_UP" 2>/dev/null); do ifup "$i"; done sleep 15 stl_restart } _st_cron_refresh() { local cur cur="$(_st_cron_get)" [ -n "$cur" ] || return 0 grep -F "$ST_CRON_TAG" "$CRON_FILE" 2>/dev/null | grep -qF "$ST_CRON_CMD" && return 0 _st_cron_set "$cur" >/dev/null 2>&1 return 0 } _st_cron_get() { local line hour line=$(grep -F "$ST_CRON_TAG" "$CRON_FILE" 2>/dev/null | head -n1) [ -n "$line" ] || return 0 hour=$(echo "$line" | awk '{print $2}') case "$hour" in */*) echo "every:${hour#*/}" ;; *) echo "daily:$hour" ;; esac } _st_cron_set() { local mode="${1%%:*}" value="${1#*:}" spec case "$mode" in off) spec="" ;; every) case "$value" in 2|4|6|8|12) spec="0 */$value * * *" ;; *) echo '{"error":"допустимо каждые 2, 4, 6, 8 или 12 часов"}'; return 1 ;; esac ;; daily) case "$value" in ''|*[!0-9]*) echo '{"error":"введите час от 0 до 23"}'; return 1 ;; esac [ "$value" -ge 0 ] && [ "$value" -le 23 ] || { echo '{"error":"допустимый диапазон 0-23"}'; return 1; } spec="0 $value * * *" ;; *) echo '{"error":"неизвестный режим"}'; return 1 ;; esac mkdir -p "$(dirname "$CRON_FILE")" touch "$CRON_FILE" sed -i "\\|$ST_CRON_TAG|d" "$CRON_FILE" [ -n "$spec" ] && echo "$spec $ST_CRON_CMD $ST_CRON_TAG" >> "$CRON_FILE" /etc/init.d/cron enable >/dev/null 2>&1 /etc/init.d/cron restart >/dev/null 2>&1 printf '{"ok":true}\n' } _st_down() { stl_stop local i for i in $(_st_wifs_all); do _st_owns "net $i" || continue ifdown "$i" >/dev/null 2>&1 uci -q set "network.$i.auto=0" done uci -q commit network } _st_warp_resume() { [ -s "$ST_WARP_UP" ] || return 1 local i ifs need=0 w=0 alive="" ifs="$(awk '{print $1}' "$ST_WARP_UP")" for i in $ifs; do [ "$(uci -q get "network.$i.proto")" = amneziawg ] || continue [ "$(uci -q get "network.$i.auto")" = 0 ] && { uci -q delete "network.$i.auto"; need=1; } done [ "$need" = 1 ] && uci -q commit network for i in $ifs; do _st_warp_ready_if "$i" || ubus call "network.interface.$i" up >/dev/null 2>&1 done while [ "$w" -lt 25 ]; do alive="" for i in $ifs; do _st_warp_alive_if "$i" && alive="$alive $i"; done [ -n "$alive" ] && break sleep 1; w=$((w + 1)) done [ -n "$alive" ] || return 1 _rb_say "Туннели WARP уже подобраны — берём их как есть ($(cat "$ST_DIR/warp.colo" 2>/dev/null || echo "$alive"))" return 0 } _st_apply() { local sel rm -f "$ST_RUN/lists.json" "$ST_RUN"/dl.* if [ ! -f "$ST_CAT_OFF" ]; then _rb_say "Обновляем каталог списков" if _st_cat_refresh force >/dev/null 2>&1; then echo " ✓ Каталог $(sed -n 's/^version=//p' "$ST_CAT_META" 2>/dev/null): наборов $(sed -n 's/^sets=//p' "$ST_CAT_META" 2>/dev/null)" else _rb_warn "Каталог списков не скачался — сервисы из каталога берём по прежнему списку"; fi fi sel="$(_st_sel | tr '\n' ' ')" if [ -z "$(echo $sel)" ]; then _st_spec_clear _st_down _rb_say "Ничего не выбрано — туннель и Steer выключены" return 0 fi case "$(_st_exit)" in vpn) _st_vpn_zone on ;; warp) [ "$1" = tunnel_ready ] || _st_warp_resume || _st_warp_up || return 1 ;; *) _st_spec_clear _st_down _rb_warn "Туннеля нет — подключите WARP или подписку VPN, и выбранные сервисы пойдут через него" return 0 ;; esac if _st_use_vpn; then _rb_say "Через VPN ($(_st_sub_label)): $(_rb_svc_names "$sel")" else _rb_say "Через WARP: $(_rb_svc_names "$sel")"; fi _st_engine_ready || return 1 _st_spec_apply $sel } ST_UP2_FAIL="$ST_DIR/engine2.fail" _st_legacy_check() { stl_present && ! stl_v2 || return 0 _st_installed && _st_owns "engine" || return 0 _st_owns "pkg steer" || _st_owns "pkg steer-extended" || return 0 [ -z "$(_st_blocker)" ] || return 0 _st_running && return 0 _zm_busy_job >/dev/null 2>&1 && return 0 [ -f "$ST_UP2_FAIL" ] && [ -z "$(find "$ST_UP2_FAIL" -mmin +10 2>/dev/null)" ] && return 0 job_start steer do_steer_engine2 >/dev/null 2>&1 return 0 } do_steer_engine2() { _st_phase pkgs rm -f "$ST_STOP_FLAG" _rb_say "Ядро steer $(stl_version) устарело — переводим на 2.x" rm -f "$STL_CACHE" if ! _st_install_steer; then mkdir -p "$ST_DIR"; touch "$ST_UP2_FAIL" _rb_warn "Ядро steer 2.x сейчас не поставить — прежнее работает, попробуем позже" return 1 fi rm -f "$ST_UP2_FAIL" if _st_owns "steer-spec" && [ ! -f "$ST_OFF" ] && [ -n "$(_st_sel)" ]; then _st_phase rules _st_apply || return 1 fi _rb_say "Готово, ядро steer $(stl_version)" } _st_engine_ready() { stl_v2 && { ! _st_use_vpn || stl_has vpn; } && return 0 _st_install_steer $(_st_use_vpn && echo vpn) } _st_failopen() { stl_state "$(_st_exit)" && [ "$STL_FAILED" = true ]; } _st_selfcheck() { local f="$ST_RUN/diag.json" n i v what why bad=0 trace _rb_say "Проверяем, что всё работает" if [ "$(_st_sel)" ] && _st_use_vpn; then _st_vpn_check || bad=1 stl_running && echo "[ OK ] Служба Steer запущена" || { echo "[FAIL] Служба Steer не запущена"; bad=1; } elif [ "$(_st_sel)" ] && _st_warp_on; then trace="$(curl -s --interface "$(_st_warp_first)" --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" case "$trace" in *warp=on*|*warp=plus*) echo "[ OK ] Туннель WARP: трафик идёт через Cloudflare ($(echo "$trace" | sed -n 's/^colo=//p'))" ;; *ip=*) if _st_warp_own; then local tip dip tip="$(echo "$trace" | sed -n 's/^ip=//p')" dip="$(curl -s --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null | sed -n 's/^ip=//p')" if [ -n "$dip" ] && [ "$tip" = "$dip" ]; then echo "[FAIL] Свой туннель: выход $tip совпадает с IP провайдера — конфиг не пускает трафик в интернет"; bad=1 else echo "[ OK ] Свой туннель: трафик идёт (выход $tip${dip:+, провайдер $dip})"; fi else echo "[FAIL] Туннель WARP: трафик идёт мимо Cloudflare WARP"; bad=1; fi ;; *) echo "[FAIL] Туннель WARP: трафик через него не идёт"; bad=1 ;; esac stl_running && echo "[ OK ] Служба Steer запущена" || { echo "[FAIL] Служба Steer не запущена"; bad=1; } fi _st_owns "steer-spec" && stl_v2 || return $bad if [ -n "$(_st_sel)" ] && _st_failopen; then _rb_warn "Ядро steer считает туннель нерабочим и пускает выбранные сервисы напрямую — ждём, пока его сторож проверит туннель снова" i=0 while [ "$i" -lt 30 ] && _st_failopen; do sleep 2; i=$((i + 1)); done if _st_failopen; then echo "[FAIL] Туннель: ядро steer не может пустить через него трафик — сервисы идут напрямую, через провайдера" logread 2>/dev/null | grep -F 'steer[warn]' | tail -n 6 | sed 's/^[^]]*\]: / /' bad=1 else echo "[ OK ] Ядро steer снова ведёт трафик в туннель" fi else [ -n "$(_st_sel)" ] && echo "[ OK ] Ядро steer ведёт выбранные сервисы в туннель" fi stl_diag_lines > "$f" while IFS="$(printf '\t')" read -r v what why; do case "$v" in ok) echo "[ OK ] $what" ;; warn) echo "!! $what${why:+ — $why}" ;; fail) echo "[FAIL] $what${why:+ — $why}"; bad=1 ;; esac done < "$f" rm -f "$f" if [ "$bad" = 0 ]; then _rb_say "Проверка пройдена"; else _rb_warn "Проверка нашла поломку — подробности выше"; fi return $bad } do_steer_install() { local blk _st_phase install rm -f "$ST_STOP_FLAG" blk="$(_st_blocker)" _fk_present && { echo "ОШИБКА: стоит Forkozz — он сам направляет сервисы в туннель. Удалите Forkozz, чтобы поставить Steer"; return 1; } case "$blk" in '') ;; splify2) echo "ОШИБКА: установлен splify2 — туннели и списки настраиваются в нём"; return 1 ;; steer) echo "ОШИБКА: у ядра steer уже есть чужие правила — не перезаписываем их"; return 1 ;; *) echo "ОШИБКА: $blk"; return 1 ;; esac _ensure_deps mkdir -p "$ST_DIR" _st_phase pkgs _st_install_steer || return 1 if ! command -v conntrack >/dev/null 2>&1; then $INSTALL conntrack >&2 && _st_own "pkg conntrack" fi _st_own "engine" rm -f "$ST_OFF" if [ "$(_st_exit)" != none ] && [ -n "$(_st_sel)" ]; then _st_phase rules _st_apply || return 1 _st_phase check sleep 2 _st_selfcheck fi _rb_say "Готово: ядро steer установлено — подключите WARP или подписку VPN на соседних вкладках" } do_steer_warp_setup() { _st_phase awg rm -f "$ST_STOP_FLAG" _st_installed || { echo "ОШИБКА: сначала установите Steer"; return 1; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; return 1; } _ensure_deps _st_install_awg || return 1 _st_phase tunnel _st_warp_up || return 1 [ "$(cat "$ST_EXIT" 2>/dev/null)" = vpn ] && [ -s "$ST_SUB" ] || echo warp > "$ST_EXIT" if [ ! -f "$ST_OFF" ] && [ -n "$(_st_sel)" ]; then _st_phase rules _st_apply tunnel_ready || return 1 _st_phase check sleep 2 _st_selfcheck fi _rb_say "Готово, WARP подключён" } _st_own_keep() { [ -s "$ST_WARP_OWN" ] && cp -f "$ST_WARP_OWN" "$ST_WARP_OWN_KEEP" && chmod 600 "$ST_WARP_OWN_KEEP"; return 0; } _st_own_restore() { [ -s "$ST_WARP_OWN" ] || [ ! -s "$ST_WARP_OWN_KEEP" ] && return 0 mkdir -p "$ST_DIR" cp -f "$ST_WARP_OWN_KEEP" "$ST_WARP_OWN" && chmod 600 "$ST_WARP_OWN" } _st_own_saved() { [ -s "$ST_WARP_OWN" ] || [ -s "$ST_WARP_OWN_KEEP" ]; } do_steer_warp_own() { _st_phase awg rm -f "$ST_STOP_FLAG" _st_installed || { echo "ОШИБКА: сначала установите Steer"; return 1; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; return 1; } if [ -s "$ST_DIR/warp.pending" ]; then mv -f "$ST_DIR/warp.pending" "$ST_WARP_OWN"; fi _st_own_restore [ -s "$ST_WARP_OWN" ] || { echo "ОШИБКА: вставьте конфиг WARP"; return 1; } chmod 600 "$ST_WARP_OWN" _st_own_keep _ensure_deps _st_install_awg || return 1 _st_phase tunnel _rb_say "Свой WARP: один туннель по вашему конфигу" if ! _st_warp_own; then grep -qv "^$ST_OWN_IF " "$ST_WARP_UP" 2>/dev/null && grep -v "^$ST_OWN_IF " "$ST_WARP_UP" > "$ST_WARP_UP_AUTO" _st_owns "net zmwarp" && _rb_say "Автоматические туннели WARP выключаем — они сохранятся, вернуться к ним можно в один клик" fi _st_warp_park $(_st_wifs_auto) echo own > "$ST_WARP_MODE" _st_mode_vars _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 _st_warp_own_iface "$ST_WARP_CONF" || return 1 _st_warp_zone ubus call network reload >/dev/null 2>&1 sleep 2 _st_warp_own_up || return 1 _st_cron_refresh [ "$(cat "$ST_EXIT" 2>/dev/null)" = vpn ] && [ -s "$ST_SUB" ] || echo warp > "$ST_EXIT" if [ ! -f "$ST_OFF" ] && [ -n "$(_st_sel)" ]; then _st_phase rules _st_apply tunnel_ready || return 1 _st_phase check sleep 2 _st_selfcheck fi _rb_say "Готово, свой WARP подключён" } do_steer_warp_auto() { _st_phase awg rm -f "$ST_STOP_FLAG" _st_installed || { echo "ОШИБКА: сначала установите Steer"; return 1; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; return 1; } _ensure_deps _st_install_awg || return 1 _st_phase tunnel _rb_say "Автоматический WARP: три туннеля с ключами от Cloudflare" _st_owns "net $ST_OWN_IF" && _rb_say "Свой туннель $ST_OWN_IF выключаем — он сохранится, вернуться к нему можно в один клик" rm -f "$ST_WARP_MODE" _st_mode_vars _st_warp_park "$ST_OWN_IF" if [ "$(uci -q get "network.zmwarp_peer.description")" = "Свой WARP" ]; then ifdown zmwarp >/dev/null 2>&1 uci -q delete network.zmwarp; uci -q delete network.zmwarp_peer; uci commit network rm -f "$ST_DIR/warp.conf" "$ST_WARP_UP_AUTO" fi rm -f "$ST_WARP_UP" [ -s "$ST_WARP_UP_AUTO" ] && mv -f "$ST_WARP_UP_AUTO" "$ST_WARP_UP" if _st_warp_resume; then awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $2 }' "$ST_WARP_UP" > "$ST_DIR/warp.colo" _st_tgws_warp else _st_warp_up || return 1 fi _st_cron_refresh [ "$(cat "$ST_EXIT" 2>/dev/null)" = vpn ] && [ -s "$ST_SUB" ] || echo warp > "$ST_EXIT" if [ ! -f "$ST_OFF" ] && [ -n "$(_st_sel)" ]; then _st_phase rules _st_apply tunnel_ready || return 1 _st_phase check sleep 2 _st_selfcheck fi _rb_say "Готово, автоматический WARP подключён" } _st_warp_fix() { local n="$1" i c peer got busy="" busyip="" w=0 host port x col _st_warp_own && { echo "ОШИБКА: у Steer свой конфиг WARP — замените его на вкладке WARP страницы Steer"; return 1; } [ "$n" -ge 1 ] 2>/dev/null && [ "$n" -le "$ST_WARP_MAX" ] || { echo "ОШИБКА: $ST_OWN_IF — свой туннель, новые ключи ему не нужны; замените конфиг на странице Steer"; return 1; } i="$(_st_wif "$n")"; c="$(_st_wconf "$n")" _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 if [ "$2" = keys ] || [ ! -s "$c" ] || [ "$(uci -q get "network.$i.proto")" != amneziawg ]; then host="$(uci -q get "network.${i}_peer.endpoint_host")"; port="$(uci -q get "network.${i}_peer.endpoint_port")" if [ "$2" = keys ] || [ ! -s "$c" ]; then [ -f "$c" ] && mv "$c" "$c.old" _rb_say "WARP $n: получаем новые ключи" if ! _st_with "$n" _st_warp_register; then [ -f "$c.old" ] && mv "$c.old" "$c" echo "ОШИБКА: WARP $n — новые ключи не получены, оставляем прежние" return 1 fi rm -f "$c.old" fi _st_with "$n" _st_warp_iface_write "${host:-162.159.192.1}" "${port:-2408}" _st_warp_zone ubus call network reload >/dev/null 2>&1 sleep 2 fi uci -q get "network.$i.auto" >/dev/null && { uci -q delete "network.$i.auto"; uci -q commit network; } ubus call "network.interface.$i" up >/dev/null 2>&1 while [ "$w" -lt 20 ] && ! _st_warp_ready_if "$i"; do w=$((w + 1)); sleep 1; done _st_warp_ready_if "$i" || { echo "ОШИБКА: WARP $n — интерфейс не поднялся"; return 1; } peer="$(_st_with "$n" _st_warp_field PublicKey)" if [ -s "$ST_WARP_UP" ]; then while read -r x col; do [ "$x" = "$i" ] && continue busy="$busy $col"; busyip="$busyip $(uci -q get "network.${x}_peer.endpoint_host")" done < "$ST_WARP_UP" fi _rb_say "WARP $n: подбираем точку входа" if ! got="$(_st_warp_scan "$i" "$peer" "$busy" "$busyip")"; then ifdown "$i" >/dev/null 2>&1 echo "ОШИБКА: WARP $n — ни одна точка входа не ответила; попробуйте новые ключи" return 1 fi set -- $got _st_warp_link "$i" "$peer" "$1" "$2" >/dev/null 2>&1 uci set "network.${i}_peer.endpoint_host=$1" uci set "network.${i}_peer.endpoint_port=$2" uci commit network { [ -s "$ST_WARP_UP" ] && while read -r x col; do [ "$x" = "$i" ] || echo "$x $col -"; done < "$ST_WARP_UP" echo "$i $3 ${4:--}" } > "$ST_WARP_UP.tmp" _st_warp_order "$ST_WARP_UP.tmp" mv "$ST_WARP_UP.tmp" "$ST_WARP_UP" awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $2 }' "$ST_WARP_UP" > "$ST_DIR/warp.colo" _st_tgws_warp _rb_say "WARP $n работает: $1:$2, колония $3" _st_warp_geo "$i" } do_steer_warp_fix() { _st_phase warp _st_need_warp || return 1 rm -f "$ST_STOP_FLAG" _st_warp_fix "$1" "$2" || return 1 _st_kick _rb_say "Готово" } ST_WFIX="$ST_DIR/wfix" ST_WFIX_STATE="$ST_DIR/wfix.state" ST_WFIX_LOG="$ST_DIR/wfix.log" ST_WFIX_TAG="# zm-wfix" ST_WFIX_TRIES=4 _st_wfix_say() { mkdir -p "$ST_DIR" { tail -n 19 "$ST_WFIX_LOG" 2>/dev/null; echo "$(date +%s)|$1"; } > "$ST_WFIX_LOG.tmp" && mv -f "$ST_WFIX_LOG.tmp" "$ST_WFIX_LOG" logger -t zm-steer "автоподбор: $1" 2>/dev/null } _st_wfix_get() { awk -v i="$1" -v f="$2" '$1 == i { print $f; exit }' "$ST_WFIX_STATE" 2>/dev/null; } _st_wfix_put() { mkdir -p "$ST_DIR" { grep -v "^$1 " "$ST_WFIX_STATE" 2>/dev/null; [ "$2" = - ] || echo "$1 $2 ${3:-0} ${4:-0}"; } > "$ST_WFIX_STATE.tmp" mv -f "$ST_WFIX_STATE.tmp" "$ST_WFIX_STATE" [ -s "$ST_WFIX_STATE" ] || rm -f "$ST_WFIX_STATE" } _st_wfix_hs() { awg show "$1" latest-handshakes 2>/dev/null | awk '{ print $2 + 0; exit }'; } _st_wfix_alive() { local hs [ -d "/sys/class/net/$1" ] || return 1 hs="$(_st_wfix_hs "$1")" [ "${hs:-0}" -gt 0 ] 2>/dev/null && [ $(( $(date +%s) - hs )) -lt 300 ] || return 1 ping -I "$1" -c 2 -W 3 1.1.1.1 >/dev/null 2>&1 || ping -I "$1" -c 2 -W 3 8.8.8.8 >/dev/null 2>&1 } _st_wfix_net_ok() { ping -c 2 -W 3 1.1.1.1 >/dev/null 2>&1 || ping -c 2 -W 3 8.8.8.8 >/dev/null 2>&1 || curl -s -o /dev/null --connect-timeout 5 --max-time 8 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null } _st_wfix_min() { local m; m="$(cat "$ST_WFIX" 2>/dev/null)"; case "$m" in 30|60|180) echo "$m" ;; *) echo "" ;; esac; } _st_wfix_set() { case "$1" in off) rm -f "$ST_WFIX" "$ST_WFIX_STATE" "$ST_DIR/wfix.last" ;; 30|60|180) mkdir -p "$ST_DIR"; echo "$1" > "$ST_WFIX" ;; *) echo '{"error":"допустимо: выкл, 30, 60 или 180 минут"}'; return 1 ;; esac _st_wfix_cron [ "$1" = off ] || ( _st_wfix_tick >/dev/null 2>&1 & ) printf '{"ok":true}\n' } _st_wfix_cron() { local want="" [ -n "$(_st_wfix_min)" ] && want="*/10 * * * * /opt/zapret-manager-luci/backend.sh steer_action wfix_tick x >/dev/null 2>&1 $ST_WFIX_TAG" if [ -n "$want" ]; then grep -qxF "$want" "$CRON_FILE" 2>/dev/null && return 0 else grep -qF "$ST_WFIX_TAG" "$CRON_FILE" 2>/dev/null || return 0 fi mkdir -p "$(dirname "$CRON_FILE")" touch "$CRON_FILE" sed -i "\\|$ST_WFIX_TAG|d" "$CRON_FILE" [ -n "$want" ] && echo "$want" >> "$CRON_FILE" /etc/init.d/cron enable >/dev/null 2>&1 /etc/init.d/cron restart >/dev/null 2>&1 return 0 } _st_wfix_tick() { local lim now n i since tries last dead="" all=1 any=0 pick="" pn="" keys="" lim="$(_st_wfix_min)" [ -n "$lim" ] || return 0 _st_installed || return 0 [ -f "$ST_OFF" ] && return 0 [ -n "$(_st_blocker)" ] && return 0 _st_warp_own && return 0 [ "$(_st_exit)" = warp ] || return 0 _st_running && return 0 [ -n "$(_st_sel)" ] || return 0 now="$(date +%s)" mkdir -p "$ST_DIR" echo "$now" > "$ST_DIR/wfix.last" n=1 while [ "$n" -le "$ST_WARP_MAX" ]; do i="$(_st_wif "$n")" n=$((n + 1)) _st_owns "net $i" || continue [ -s "$(_st_wconf $((n - 1)))" ] || continue any=1 if _st_wfix_alive "$i"; then all=0 since="$(_st_wfix_get "$i" 2)" if [ -n "$since" ]; then _st_wfix_put "$i" - _st_wfix_say "WARP $((n - 1)) снова работает" fi continue fi since="$(_st_wfix_get "$i" 2)"; tries="$(_st_wfix_get "$i" 3)"; last="$(_st_wfix_get "$i" 4)" if [ -z "$since" ]; then since="$(_st_wfix_hs "$i")" [ "${since:-0}" -gt 0 ] 2>/dev/null && [ "$since" -le "$now" ] && [ $((now - since)) -ge 300 ] || since="$now" tries=0; last=0 _st_wfix_put "$i" "$since" 0 0 fi dead="$dead $((n - 1))" [ "${tries:-0}" -ge "$ST_WFIX_TRIES" ] && continue [ $((now - since)) -ge $((lim * 60)) ] || continue [ $((now - ${last:-0})) -ge $((lim * 60)) ] || continue [ -z "$pick" ] && { pick="$i"; pn=$((n - 1)); } done [ "$any" = 1 ] && [ -n "$pick" ] || return 0 if [ "$all" = 1 ] && ! _st_wfix_net_ok; then [ -f "$ST_RUN/wfix.nonet" ] || { mkdir -p "$ST_RUN"; touch "$ST_RUN/wfix.nonet"; _st_wfix_say "нет интернета — подбор отложен"; } return 0 fi rm -f "$ST_RUN/wfix.nonet" tries="$(_st_wfix_get "$pick" 3)"; since="$(_st_wfix_get "$pick" 2)" [ "${tries:-0}" -ge 2 ] && keys=keys _st_wfix_put "$pick" "$since" $(( ${tries:-0} + 1 )) "$now" job_start steer do_steer_wfix "$pn" "$keys" "$since" >/dev/null } do_steer_wfix() { local n="$1" keys="$2" since="$3" i mins tries i="$(_st_wif "$n")" mins=$(( ($(date +%s) - ${since:-$(date +%s)}) / 60 )) tries="$(_st_wfix_get "$i" 3)" _st_phase warp rm -f "$ST_STOP_FLAG" mkdir -p "$ST_RUN"; echo "$n" > "$ST_RUN/wfix.busy" _rb_say "Автоподбор: WARP $n не работает $mins мин — подбираем новую точку входа${keys:+ с новыми ключами} (попытка ${tries:-1} из $ST_WFIX_TRIES)" if _st_warp_fix "$n" "$keys"; then rm -f "$ST_RUN/wfix.busy" _st_wfix_put "$i" - _st_kick set -- $(awk -v i="$i" '$1 == i { print $2; exit }' "$ST_WARP_UP" 2>/dev/null) _st_wfix_say "WARP $n не работал $mins мин — подобрана точка $(uci -q get "network.${i}_peer.endpoint_host"):$(uci -q get "network.${i}_peer.endpoint_port")${1:+, колония $1}${keys:+, новые ключи}" _rb_say "Готово" return 0 fi rm -f "$ST_RUN/wfix.busy" _st_stopped && { _st_wfix_say "WARP $n: подбор остановлен вручную"; return 1; } if [ "${tries:-0}" -ge "$ST_WFIX_TRIES" ]; then _st_wfix_say "WARP $n: $ST_WFIX_TRIES попытки подбора не помогли — автоподбор для него остановлен до ручной починки" else _st_wfix_say "WARP $n: новая точка не нашлась (попытка $tries из $ST_WFIX_TRIES), повторим через $(_st_wfix_min) мин" fi return 1 } _st_wfix_json() { local m out="" sep="" i since tries last t txt n fixing="" m="$(_st_wfix_min)" if [ -s "$ST_WFIX_STATE" ]; then while read -r i since tries last; do [ -n "$i" ] || continue case "$i" in zmwarp) n=1 ;; *) n="${i#zmwarp}" ;; esac out="$out$sep{\"n\":${n:-0},\"since\":${since:-0},\"tries\":${tries:-0},\"last\":${last:-0}}"; sep="," done < "$ST_WFIX_STATE" fi _st_running && [ -s "$ST_RUN/wfix.busy" ] && fixing="$(cat "$ST_RUN/wfix.busy")" printf '{"mode":"%s","max":%s,"now":%s,"last_check":%s,"fixing":"%s","dead":[%s],"log":[' "${m:-off}" "$ST_WFIX_TRIES" "$(date +%s)" \ "$(cat "$ST_DIR/wfix.last" 2>/dev/null || echo 0)" "$fixing" "$out" sep="" [ -s "$ST_WFIX_LOG" ] && tail -n 5 "$ST_WFIX_LOG" | while IFS='|' read -r t txt; do printf '%s{"t":%s,"text":"%s"}' "$sep" "${t:-0}" "$(esc "$txt")"; sep="," done printf ']}' } do_steer_apply() { _st_phase rules rm -f "$ST_STOP_FLAG" _st_installed || { echo "ОШИБКА: Steer ещё не установлен"; return 1; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: настройку ядра steer сейчас ведёт не Zapret Manager"; return 1; } rm -f "$ST_OFF" _st_apply || return 1 _st_phase check sleep 2 _st_selfcheck _rb_say "Готово" } do_steer_stop() { _st_phase rules _st_installed || { echo "ОШИБКА: Steer ещё не установлен"; return 1; } touch "$ST_OFF" _st_down rm -f "$ST_TGWS_WARP" if [ -f /etc/config/https-dns-proxy ]; then _doh_force_apply fi _rb_say "Готово, Steer и туннель выключены — всё идёт напрямую" } _st_need_warp() { [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; return 1; } _st_warp_on && return 0 echo "ОШИБКА: WARP ещё не подключён — подключите его на вкладке WARP" return 1 } do_steer_warp_restart() { _st_phase warp _st_need_warp || return 1 rm -f "$ST_STOP_FLAG" _rb_say "Перезапускаем туннель WARP" _st_warp_up || return 1 _st_kick _rb_say "Готово" } do_steer_warp_endpoint() { _st_phase warp _st_need_warp || return 1 rm -f "$ST_STOP_FLAG" _st_warp_up repick || return 1 _st_kick _rb_say "Готово, точка входа подобрана" } do_steer_warp_recreate() { _st_phase warp _st_need_warp || return 1 rm -f "$ST_STOP_FLAG" local n c host port _rb_say "Пересоздаём WARP с новыми ключами" n=1 while [ "$n" -le "$ST_WARP_N" ]; do c="$(_st_wconf "$n")" [ -f "$c" ] && mv "$c" "$c.old" if ! _st_with "$n" _st_warp_register; then [ -f "$c.old" ] && mv "$c.old" "$c" [ "$n" = 1 ] && { echo "ОШИБКА: не удалось получить новые ключи — оставляем прежние"; return 1; } _rb_warn "WARP $n: новых ключей нет — оставляем прежние" else rm -f "$c.old" host="$(uci -q get "network.$(_st_wif "$n")_peer.endpoint_host")" port="$(uci -q get "network.$(_st_wif "$n")_peer.endpoint_port")" _st_with "$n" _st_warp_iface_write "${host:-162.159.192.1}" "${port:-2408}" fi n=$((n + 1)) done ubus call network reload >/dev/null 2>&1 _st_warp_up repick || return 1 _st_kick _rb_say "Готово, WARP пересоздан" } do_steer_remove() { _st_phase remove _rb_say "Удаляем Steer и туннель WARP" local foreign="" p pkgs="" [ "$(_st_blocker)" = splify2 ] && foreign=splify2 [ -z "$foreign" ] && foreign="$(stl_busy_by)" if [ -n "$foreign" ]; then sed -i '/^steer-spec$/d; /^pkg steer/d' "$ST_OWNED" 2>/dev/null _rb_warn "Ядро steer нужно $foreign — оставляем его и его правила" else _st_spec_clear stl_stop fi local wi netrl=0 for wi in $(_st_wifs_every); do _st_owns "net $wi" || continue ifdown "$wi" >/dev/null 2>&1 uci -q delete "network.$wi" uci -q delete "network.${wi}_peer" netrl=1 done [ "$netrl" = 1 ] && { uci commit network; /etc/init.d/network reload >/dev/null 2>&1; } if _st_owns "fw $ST_WARP_ZONE"; then uci -q delete "firewall.$ST_WARP_ZONE" uci -q delete "firewall.${ST_WARP_ZONE}_fwd" uci commit firewall /etc/init.d/firewall reload >/dev/null 2>&1 fi if grep -qF "$ST_CRON_TAG" "$CRON_FILE" 2>/dev/null || grep -qF "# zm-subupd" "$CRON_FILE" 2>/dev/null || grep -qF "$ST_WFIX_TAG" "$CRON_FILE" 2>/dev/null; then sed -i "\\|$ST_CRON_TAG|d; \\|# zm-subupd|d; \\|$ST_WFIX_TAG|d" "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 fi _st_vpn_zone off for p in $(sed -n 's/^pkg \(steer[a-z0-9-]*\)$/\1/p' "$ST_OWNED" 2>/dev/null); do pkgs="$pkgs $p"; done [ -n "$pkgs" ] && stl_engine_remove $pkgs _st_owns "pkg conntrack" && $DELETE conntrack >&2 if ! uci show network 2>/dev/null | grep -q "\.proto='amneziawg'"; then local p for p in luci-i18n-amneziawg-ru luci-proto-amneziawg luci-app-amneziawg amneziawg-tools kmod-amneziawg; do _st_owns "pkg $p" && $DELETE "$p" >&2 done fi _rb_rpcd_ensure rm -rf "$ST_DIR" "$ST_RUN" rm -f "$ST_TGWS_WARP" [ -n "$foreign" ] || stl_leftovers _rb_say "Steer удалён" _zm_after_remove "Steer" } steer_status() { local running=false phase="" blk colo="" warp_up=false installed=false ver="" run=false chans=0 dns=false local host="" port="" hs="" age="" rx=0 tx=0 off=false svc="" sep="" id sel=" " skip w k r _st_legacy_check _st_running && running=true [ -f "$ST_PHASE_FILE" ] && phase=$(cat "$ST_PHASE_FILE") blk="$(_st_blocker)" _st_installed && installed=true [ -z "$blk" ] && [ "$installed" = false ] && _fk_present && blk=forkozz [ -f "$ST_OFF" ] && off=true if [ -s "$ST_WARP_UP" ]; then colo=$(while read -r w k; do [ -d "/sys/class/net/$w" ] && printf '%s\n' "$k"; done < "$ST_WARP_UP" | tr '\n' ',' | sed 's/,$//; s/,/, /g') [ -n "$colo" ] && warp_up=true elif [ -f "$ST_DIR/warp.colo" ]; then colo=$(cat "$ST_DIR/warp.colo") [ -d "/sys/class/net/$ST_WARP_IF" ] && warp_up=true fi if _st_owns "net $ST_WARP_IF"; then host="$(uci -q get "network.${ST_WARP_IF}_peer.endpoint_host")" port="$(uci -q get "network.${ST_WARP_IF}_peer.endpoint_port")" fi if [ "$warp_up" = true ] && command -v awg >/dev/null 2>&1; then for w in $(_st_wifs_all); do [ -d "/sys/class/net/$w" ] || continue k=$(awg show "$w" latest-handshakes 2>/dev/null | awk '{print $2; exit}') [ "${k:-0}" -gt "${hs:-0}" ] 2>/dev/null && hs="$k" set -- $(awg show "$w" transfer 2>/dev/null | head -n1) rx=$((rx + ${2:-0})); tx=$((tx + ${3:-0})) done [ -n "$hs" ] && [ "$hs" != 0 ] && age=$(( $(date +%s) - hs )) fi if stl_present; then ver="$(stl_version)" stl_running && run=true _st_owns "steer-spec" && chans="$(stl_rules)" fi _st_dns_conflict && dns=true local doh="" dohn=0 u if [ -x /etc/init.d/https-dns-proxy ] && [ ! -f "$DOH_OFF_FLAG" ] && /etc/init.d/https-dns-proxy running >/dev/null 2>&1; then while u="$(uci -q get "https-dns-proxy.@https-dns-proxy[$dohn].resolver_url")"; do dohn=$((dohn + 1)); [ "$dohn" = 1 ] && doh="$(_doh_provider_of "$u")"; [ "$dohn" -gt 16 ] && break done [ "$dohn" -gt 1 ] && doh=multi [ "$dohn" -ge 1 ] && [ -z "$doh" ] && doh=other fi _st_cat_bg [ -n "$(_st_wfix_min)" ] && _st_wfix_cron sel=" $(_st_sel | tr '\n' ' ') " svc="$(_rb_svc_src | awk -F'|' -v sel="$sel" -v skipf="$ST_SKIP" ' function j(v, o, i, c) { o = ""; for (i = 1; i <= length(v); i++) { c = substr(v, i, 1); if (c == "\\") o = o "\\\\"; else if (c == "\"") o = o "\\\""; else if (c == "\t" || c == "\r" || c == "\n") o = o " "; else o = o c } return o } BEGIN { while ((getline l < skipf) > 0) sk[l] = 1 } $1 == "" || ($1 in seen) { next } { seen[$1] = 1 } $1 != "custom" && $3 $4 $8 == "" { next } { printf "%s{\"id\":\"%s\",\"name\":\"%s\",\"group\":\"%s\",\"on\":%s,\"skip\":%s}", (n++ ? "," : ""), $1, j($2), j($9), (index(sel, " " $1 " ") ? "true" : "false"), (($1 in sk) ? "true" : "false") }')" local vexit vup=false vsub=false latest="" ext=false won=false failed=false active="" vexit="$(_st_exit)" _st_warp_on && won=true stl_v2 && { [ ! -s "$ST_SUB" ] || stl_has vpn; } && ext=true if [ -s "$STL_CACHE" ]; then latest="$(cat "$STL_CACHE")" elif stl_present; then ( stl_latest >/dev/null 2>&1 & ); fi if [ "$run" = true ] && _st_owns "steer-spec" && stl_state "$vexit"; then failed="$STL_FAILED" [ "$vexit" = vpn ] || [ "$failed" = true ] || active="$STL_DEV" fi [ -d "/sys/class/net/$ST_VPN_OUT" ] && vup=true [ -s "$ST_SUB" ] && vsub=true local vlive=null if [ "$vup" = true ] && [ "$vexit" = vpn ] && [ "$off" = false ]; then _st_vpn_live; case $? in 0) vlive=true ;; 1) vlive=false ;; esac fi printf '{"running":%s,"phase":"%s","blocker":"%s","installed":%s,"stopped":%s,"version":"%s","steer_running":%s,"channels":%s,"warp_up":%s,"warp_colo":"%s","warp_host":"%s","warp_port":"%s","warp_hs_age":"%s","warp_rx":%s,"warp_tx":%s,"autorestart":"%s","dns_conflict":%s,"doh":"%s","doh_mode":"%s","exit":"%s","vpn_up":%s,"vpn_live":%s,"has_sub":%s,"sub_label":"%s","latest":"%s","ext":%s,"warp_on":%s,"warp_mode":"%s","warp_own_saved":%s,"tunnels":%s,"warp_active":"%s","catalog":%s,"wfix":%s,"lists_via":%s,"failopen":%s,"hosts_extra":%s,"services":[%s]}\n' \ "$running" "$(esc "$phase")" "$(esc "$blk")" "$installed" "$off" "$(esc "$ver")" "$run" "${chans:-0}" "$warp_up" "$(esc "$colo")" \ "$(esc "$host")" "$(esc "$port")" "$age" "${rx:-0}" "${tx:-0}" "$(_st_cron_get)" "$dns" "$doh" "$(_doh_force_mode)" \ "$vexit" "$vup" "$vlive" "$vsub" "$(esc "$(_st_sub_label)")" "$(esc "$latest")" "$ext" "$won" "$(_st_warp_own && echo own || echo auto)" "$(_st_own_saved && echo true || echo false)" "$(_st_tunnels_json)" "$active" "$(_st_cat_json)" "$(_st_wfix_json)" "$([ -f "$ST_DIR/lists.via" ] && echo true || echo false)" "$failed" "$(_hosts_extra)" "$svc" } _st_active_if() { [ "$(_st_exit)" = vpn ] && return 0 _st_owns "steer-spec" && stl_running && stl_state warp || return 0 [ "$STL_FAILED" = true ] || printf '%s' "$STL_DEV" } _st_tunnels_json() { local n=1 i up hs age rx tx host port colo out="" sep="" seen city cc while [ "$n" -le "$ST_WARP_N" ]; do i="$(_st_wif "$n")" if _st_owns "net $i"; then up=false; age=""; rx=0; tx=0 [ -d "/sys/class/net/$i" ] && up=true host="$(uci -q get "network.${i}_peer.endpoint_host")" port="$(uci -q get "network.${i}_peer.endpoint_port")" colo="$(awk -v i="$i" '$1 == i {print $2; exit}' "$ST_WARP_UP" 2>/dev/null)" if [ "$up" = true ] && command -v awg >/dev/null 2>&1; then hs=$(awg show "$i" latest-handshakes 2>/dev/null | awk '{print $2; exit}') [ "${hs:-0}" -gt 0 ] 2>/dev/null && age=$(( $(date +%s) - hs )) set -- $(awg show "$i" transfer 2>/dev/null | head -n1) rx="${2:-0}"; tx="${3:-0}" fi seen=""; city=""; cc="" set -- $(_st_geo_get "$i") if [ $# -ge 4 ] && { [ "$1" = "$colo" ] || [ -z "$colo" ] || [ "$colo" = "?" ]; }; then [ -n "$colo" ] && [ "$colo" != "?" ] || colo="$1" seen="$2"; cc="$3"; shift 3; city="$*" [ "$seen" = - ] && seen=""; [ "$cc" = - ] && cc=""; [ "$city" = - ] && city="" fi out="$out$sep{\"n\":$n,\"if\":\"$i\",\"up\":$up,\"colo\":\"$(esc "$colo")\",\"city\":\"$(esc "$city")\",\"cc\":\"$(esc "$cc")\",\"seen\":\"$(esc "$seen")\",\"host\":\"$(esc "$host")\",\"port\":\"$(esc "$port")\",\"hs_age\":\"$age\",\"rx\":$rx,\"tx\":$tx}" sep="," fi n=$((n + 1)) done printf '[%s]' "$out" } ST_USER_DIR="$ST_DIR/user" _st_list_effective() { local id="$1" set f any=0 if [ -s "$ST_USER_DIR/$id.lst" ] || [ -s "$ST_USER_DIR/$id.pfx" ]; then ST_LIST_SRC=user; cat "$ST_USER_DIR/$id.lst" "$ST_USER_DIR/$id.pfx" 2>/dev/null; return 0; fi for set in $(_rb_svc_field "$id" 8 | tr ',' ' '); do f="$ST_DIR/lists/$set.dom" [ -s "$f" ] && { cat "$f"; any=1; } done if [ "$any" = 1 ]; then ST_LIST_SRC=sets; return 0; fi ST_LIST_SRC=package for f in $(_rb_svc_field "$id" 3 | tr ',' ' '); do _rb_list_get "$f" 2>/dev/null && [ -s "$ZM_LISTS_DIR/$f" ] && cat "$ZM_LISTS_DIR/$f"; done } steer_list_get() { local id="$1" body n tmp="$JOBS_DIR/steer-list.$$" [ "$id" = custom ] && _rb_routable "$id" || { echo '{"error":"редактируется только свой список"}'; return 1; } ST_LIST_SRC="" mkdir -p "$JOBS_DIR" _st_list_effective "$id" > "$tmp" body="$(tr -d '\r' < "$tmp" | grep -v '^[[:space:]]*$' | awk '!s[$0]++')" rm -f "$tmp" n=$(printf '%s\n' "$body" | grep -c .) printf '{"id":"%s","name":"%s","source":"%s","count":%s,"domains":%s,"subnets":%s,"content":"%s"}\n' "$id" "$(esc "$(_rb_svc_field "$id" 2)")" "$ST_LIST_SRC" "$n" \ "$(cat "$ST_USER_DIR/$id.lst" 2>/dev/null | grep -c .)" "$(cat "$ST_USER_DIR/$id.pfx" 2>/dev/null | grep -c .)" "$(esc_ml "$body")" } steer_list_set() { local id="${1%%|*}" body="${1#*|}" f tmp n case "$1" in *'|'*) ;; *) echo '{"error":"нет списка"}'; return 1 ;; esac [ "$id" = custom ] && _rb_routable "$id" || { echo '{"error":"редактируется только свой список"}'; return 1; } mkdir -p "$ST_USER_DIR" f="$ST_USER_DIR/$id.lst"; tmp="$f.tmp" printf '%s\n' "$body" | tr -d '\r' | tr 'A-Z' 'a-z' | tr ',;' '\n\n' | sed 's/[[:space:]]*#.*$//; s/^[[:space:]]*//; s/[[:space:]]*$//; s/^domain://; s/^full://; s/^suffix://; s/^\*\.//; s/^\.//' > "$tmp.all" grep -E '^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$' "$tmp.all" | grep -vE '^[0-9.]+$' | awk '!s[$0]++' > "$tmp" grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$' "$tmp.all" | awk -F'[./]' '$1 < 256 && $2 < 256 && $3 < 256 && $4 < 256 && ($5 == "" || $5 <= 32) { print ($5 == "" ? $0 "/32" : $0) }' | awk '!s[$0]++' > "$tmp.pfx" rm -f "$tmp.all" n=$(( $(grep -c . "$tmp") + $(grep -c . "$tmp.pfx") )) [ "$n" -gt 0 ] || { rm -f "$tmp" "$tmp.pfx"; echo '{"error":"в списке нет ни одного домена или IP-адреса"}'; return 1; } if [ -s "$tmp" ]; then mv "$tmp" "$f"; else rm -f "$tmp" "$f"; fi if [ -s "$tmp.pfx" ]; then mv "$tmp.pfx" "$ST_USER_DIR/$id.pfx"; else rm -f "$tmp.pfx" "$ST_USER_DIR/$id.pfx"; fi if [ "$id" = custom ]; then mkdir -p "$ST_DIR" [ -f "$ST_SKIP" ] && sed -i '/^custom$/d' "$ST_SKIP" _rb_in custom "$ST_SEL" || echo custom >> "$ST_SEL" fi _st_list_changed "$id" "$n" } steer_list_reset() { [ "$1" = custom ] && _rb_routable "$1" || { echo '{"error":"редактируется только свой список"}'; return 1; } rm -f "$ST_USER_DIR/$1.lst" "$ST_USER_DIR/$1.pfx" if [ "$1" = custom ]; then if _rb_in custom "$ST_SEL"; then sed -i '/^custom$/d' "$ST_SEL" if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ]; then job_start steer do_steer_apply return fi fi printf '{"ok":true,"saved":true,"count":0}\n' return fi _st_list_changed "$1" 0 } _st_list_changed() { if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && _rb_in "$1" "$ST_SEL"; then job_start steer do_steer_apply else printf '{"ok":true,"saved":true,"count":%s}\n' "$2" fi } _st_sel_set() { local want id want=" $(echo "$1" | tr ',' ' ') " for id in $want; do case "$id" in *[!a-z0-9_-]*) echo '{"error":"неизвестный сервис"}'; return 1 ;; esac [ -n "$(_rb_svc_field "$id" 1)" ] && _rb_routable "$id" || { echo '{"error":"неизвестный сервис"}'; return 1; } [ "$id" = custom ] && [ ! -s "$ST_USER_DIR/custom.lst" ] && [ ! -s "$ST_USER_DIR/custom.pfx" ] && { echo '{"error":"свой список пуст — сначала добавьте в него домены или IP"}'; return 1; } done mkdir -p "$ST_DIR" touch "$ST_SKIP" for id in $(_st_sel); do case "$want" in *" $id "*) ;; *) _rb_in "$id" "$ST_SKIP" || echo "$id" >> "$ST_SKIP" ;; esac; done for id in $want; do sed -i "/^$id\$/d" "$ST_SKIP"; done printf '%s\n' $want | grep . > "$ST_SEL" [ -s "$ST_SKIP" ] || rm -f "$ST_SKIP" return 0 } ST_VPN_OUT="$STL_VPN_DEV" ST_VPN_ZONE="zmvpn" ST_SUB="$ST_DIR/sub.txt" ST_SUB_INFO="$ST_DIR/sub.userinfo" ST_SUB_URL="$ST_DIR/sub.url" ST_SUB_TITLE="$ST_DIR/sub.title" ST_SUB_NODE="$ST_DIR/sub.node" ST_SUB_EXCL="$ST_DIR/sub.exclude" ST_SUB_HIDE="$ST_DIR/sub.hidden" ST_EXIT="$ST_DIR/exit" _st_exit() { local e e="$(cat "$ST_EXIT" 2>/dev/null)" if [ "$e" = vpn ] && [ -s "$ST_SUB" ] && _st_vpn_ok; then echo vpn; return; fi if _st_warp_on; then echo warp; return; fi if [ -s "$ST_SUB" ] && _st_vpn_ok; then echo vpn; return; fi echo none } _st_use_vpn() { [ "$(_st_exit)" = vpn ]; } ST_VPN_PROBE="$JOBS_DIR/steer.vpnprobe" _st_vpn_probe() { local trace ip="" loc="" r=fail if [ -d "/sys/class/net/$ST_VPN_OUT" ]; then trace="$(curl -s --interface "$ST_VPN_OUT" --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" ip="$(echo "$trace" | sed -n 's/^ip=//p')"; loc="$(echo "$trace" | sed -n 's/^loc=//p')" [ -n "$ip" ] && r=ok fi mkdir -p "$ZM_STATE_DIR" echo "$(date +%s) $r $ip $loc" > "$ST_VPN_PROBE" [ "$r" = ok ] } _st_vpn_live() { local t r age read -r t r _ 2>/dev/null < "$ST_VPN_PROBE" age=$(( $(date +%s) - ${t:-0} )) if [ "$age" -gt 120 ] || [ "$age" -lt 0 ]; then if mkdir "$ST_VPN_PROBE.lock" 2>/dev/null; then ( _st_vpn_probe >/dev/null 2>&1; rmdir "$ST_VPN_PROBE.lock" ) >/dev/null 2>&1 & elif [ -n "$(find "$ST_VPN_PROBE.lock" -mmin +1 2>/dev/null)" ]; then rmdir "$ST_VPN_PROBE.lock" 2>/dev/null fi fi [ -z "$r" ] && return 2 [ "$age" -gt 600 ] && return 2 [ "$r" = ok ] } _st_sub_label() { [ -s "$ST_SUB" ] || return 0 if [ -s "$ST_SUB_TITLE" ]; then case "$(head -n1 "$ST_SUB_TITLE")" in *🌨*VPN*) echo "StressKVN" ;; *) head -n1 "$ST_SUB_TITLE" ;; esac elif [ -s "$ST_SUB_URL" ]; then sed -n 's#^[a-z]*://\([^/:?]*\).*#\1#p' "$ST_SUB_URL" | head -n1 else echo "свои ссылки"; fi } ZM_FOLD_AWK='function fold(x, k) { x = tolower(x); for (k = 1; k <= nU; k++) gsub(U[k], L[k], x); return x } BEGIN { nU = split("А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я", U, " "); split("а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я", L, " ") }' _zm_excl_hits() { [ -s "$1" ] || [ -s "${2:-/nonexistent}" ] || return 0 ZM_EXCL="$(head -n1 "$1" 2>/dev/null)" ZM_HIDE="${2:-}" awk -F'\t' "$ZM_FOLD_AWK"' BEGIN { c = split(ENVIRON["ZM_EXCL"], a, /[|,]/); n = 0 for (i = 1; i <= c; i++) { t = a[i]; gsub(/^[ \t]+|[ \t]+$/, "", t); if (t != "") m[++n] = fold(t) } f = ENVIRON["ZM_HIDE"] if (f != "") while ((getline l < f) > 0) if (l != "") h[l] = 1 } { if ($2 in h) { print $1; next } nm = fold($2); for (i = 1; i <= n; i++) if (index(nm, m[i])) { print $1; break } }' } _zm_json_lines() { local l sep="" [ -s "$1" ] || return 0 while IFS= read -r l || [ -n "$l" ]; do [ -n "$l" ] || continue printf '%s"%s"' "$sep" "$(esc "$l")"; sep="," done < "$1" } _zm_names_save() { local src="$1" dst="$2" awk 'length($0) > 0 && length($0) <= 400 && !s[$0]++' "$src" 2>/dev/null | head -n 300 > "$dst.tmp" if [ -s "$dst.tmp" ]; then mv -f "$dst.tmp" "$dst"; else rm -f "$dst.tmp" "$dst"; fi } _zm_excl_save() { local m="$2" f="$1" case "$m" in *" "*|*"$(printf '\r')"*) echo "маркеры — одной строкой через |"; return 1 ;; esac [ "${#m}" -le 1600 ] || { echo "слишком длинный список маркеров"; return 1; } mkdir -p "$(dirname "$f")" rm -f "$f.v" if [ -z "$(printf '%s' "$m" | tr -d ' |,')" ]; then rm -f "$f"; return 0; fi printf '%s\n' "$m" > "$f" } _st_sub_filter() { local want tsv hid n all mk mk="$(head -n1 "$ST_SUB_EXCL" 2>/dev/null | tr '\t' ' ')" [ -n "$(printf '%s' "$mk" | tr -d ' |,')" ] || mk=- tsv="$(stl_nodes "$ST_SUB" | stl_tsv)" hid=" $(printf '%s\n' "$tsv" | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | tr '\n' ' ')" all="$(printf '%s\n' "$tsv" | grep -c .)" if [ "$all" -gt 0 ] && [ "$(echo $hid | wc -w)" -ge "$all" ]; then echo "!! Фильтр узлов скрыл все узлы подписки — пока используем все, поправьте маркеры" >&2 printf -- '-\t-\n' return 0 fi want="$(cat "$ST_SUB_NODE" 2>/dev/null)" if [ -n "$want" ]; then n="$(printf '%s\n' "$tsv" | ZM_W="$want" awk -F'\t' '$2 == ENVIRON["ZM_W"] { print $1; exit }')" [ -n "$n" ] && case "$hid" in *" $n "*) ;; *) printf '%s\t%s\n' "$n" "$mk"; return 0 ;; esac fi n="" [ -s "$ST_SUB_HIDE" ] && n="$(printf '%s\n' "$tsv" | awk -F'\t' -v h="$hid " '$1 != "" && index(h, " " $1 " ") == 0 { print $1 }' | tr '\n' ',' | sed 's/,$//')" printf '%s\t%s\n' "${n:--}" "$mk" } _st_vpn_zone() { if [ "$1" = on ]; then [ "$(uci -q get "firewall.$ST_VPN_ZONE")" = zone ] && { _zm_fwd_fix "${ST_VPN_ZONE}_fwd"; return 0; } uci set "firewall.$ST_VPN_ZONE=zone" uci set "firewall.$ST_VPN_ZONE.name=$ST_VPN_ZONE" uci add_list "firewall.$ST_VPN_ZONE.device=$ST_VPN_OUT" uci set "firewall.$ST_VPN_ZONE.input=REJECT" uci set "firewall.$ST_VPN_ZONE.output=ACCEPT" uci set "firewall.$ST_VPN_ZONE.forward=REJECT" uci set "firewall.$ST_VPN_ZONE.masq=0" uci set "firewall.$ST_VPN_ZONE.mtu_fix=1" uci set "firewall.${ST_VPN_ZONE}_fwd=forwarding" uci set "firewall.${ST_VPN_ZONE}_fwd.src=$(_zm_lan_zone)" uci set "firewall.${ST_VPN_ZONE}_fwd.dest=$ST_VPN_ZONE" uci commit firewall _st_own "fw $ST_VPN_ZONE" /etc/init.d/firewall reload >/dev/null 2>&1 else _st_owns "fw $ST_VPN_ZONE" || return 0 uci -q delete "firewall.$ST_VPN_ZONE" uci -q delete "firewall.${ST_VPN_ZONE}_fwd" uci commit firewall /etc/init.d/firewall reload >/dev/null 2>&1 sed -i "/^fw $ST_VPN_ZONE\$/d" "$ST_OWNED" 2>/dev/null fi } _st_vpn_check() { local w=0 tr ip loc while [ "$w" -lt 45 ] && [ ! -d "/sys/class/net/$ST_VPN_OUT" ]; do sleep 1; w=$((w + 1)); done if [ ! -d "/sys/class/net/$ST_VPN_OUT" ]; then echo "[FAIL] VPN: ни один узел подписки не поднялся — проверьте задержку узлов на вкладке «Подписка»" return 1 fi tr="$(curl -s --interface "$ST_VPN_OUT" --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" ip="$(echo "$tr" | sed -n 's/^ip=//p')"; loc="$(echo "$tr" | sed -n 's/^loc=//p')" if [ -n "$ip" ]; then echo "[ OK ] VPN: трафик идёт через узел подписки (выход $ip${loc:+, $loc})" return 0 fi echo "[FAIL] VPN: туннель поднят, но трафик через него не идёт" return 1 } _st_need_ext() { _st_installed || { echo "ОШИБКА: сначала установите Steer"; return 1; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; return 1; } if ! stl_has vpn; then _ensure_deps _st_phase pkgs _st_install_steer vpn || return 1 stl_has vpn || { echo "ОШИБКА: модуль VLESS ядра steer не установился"; return 1; } fi _st_phase sub } _st_sub_check() { if stl_sub_check "$1"; then _rb_say "Узлов, с которыми умеет работать Steer: $STL_SUB_USABLE" return 0 fi echo "ОШИБКА: в подписке нет узлов VLESS, с которыми умеет работать Steer" [ -n "$STL_SUB_WHY" ] && printf '%s\n' "$STL_SUB_WHY" | sed 's/^/ причина: /' return 1 } _st_sub_fetch() { rm -f "$ST_SUB.new" if ! stl_sub_fetch "$1" "$ST_SUB.new" "$ST_SUB_INFO"; then echo "ОШИБКА: подписка не скачалась${STL_SUB_ERR:+ — $STL_SUB_ERR}" return 1 fi [ -n "$STL_SUB_WARN" ] && _rb_warn "$STL_SUB_WARN" _st_sub_check "$ST_SUB.new" || { rm -f "$ST_SUB.new"; return 1; } mv -f "$ST_SUB.new" "$ST_SUB" chmod 600 "$ST_SUB" printf '%s\n' "${STL_SUB_URL:-$1}" > "$ST_SUB_URL" chmod 600 "$ST_SUB_URL" if [ -n "$STL_SUB_TITLE" ]; then printf '%s\n' "$STL_SUB_TITLE" > "$ST_SUB_TITLE"; else rm -f "$ST_SUB_TITLE"; fi _rb_say "Подписка «$(_st_sub_label)» скачана" } _st_sub_apply() { if [ -f "$ST_OFF" ]; then _rb_say "Steer выключен — настройки сохранены и применятся при включении" return 0 fi _st_phase rules _st_apply || return 1 _st_phase check sleep 2 _st_selfcheck _rb_say "Готово" } do_steer_sub_set() { local f="$ST_DIR/sub.pending" in rm -f "$ST_STOP_FLAG" _st_phase sub [ -s "$f" ] || { echo "ОШИБКА: нет ссылки"; return 1; } in="$(cat "$f")" rm -f "$f" _st_need_ext || return 1 case "$in" in http://*|https://*) _rb_say "Скачиваем подписку" _st_sub_fetch "$(printf '%s' "$in" | tr -d ' \r\n\t')" || return 1 ;; *) printf '%s\n' "$in" | tr ' \t\r' '\n\n\n' | grep '^vless://' > "$ST_SUB.new" [ -s "$ST_SUB.new" ] || { rm -f "$ST_SUB.new"; echo "ОШИБКА: в тексте нет ссылок vless://"; return 1; } _st_sub_check "$ST_SUB.new" || { rm -f "$ST_SUB.new"; return 1; } mv -f "$ST_SUB.new" "$ST_SUB" chmod 600 "$ST_SUB" rm -f "$ST_SUB_URL" "$ST_SUB_TITLE" "$ST_SUB_INFO" _rb_say "Ссылки сохранены" ;; esac rm -f "$ST_SUB_NODE" echo vpn > "$ST_EXIT" _rb_say "Выбранные сервисы теперь идут через подписку" _rb_say "Перезапускаем VPN-туннель с новым списком серверов" _st_sub_apply } do_steer_sub_links() { local f="$ST_DIR/sub.pending" n node rm -f "$ST_STOP_FLAG" _st_phase sub [ -s "$f" ] || { echo "ОШИБКА: нет ссылок"; return 1; } _st_need_ext || { rm -f "$f"; return 1; } tr ' \t\r' '\n\n\n' < "$f" | grep '^vless://' | awk '!s[$0]++' > "$ST_SUB.new" rm -f "$f" [ -s "$ST_SUB.new" ] || { rm -f "$ST_SUB.new"; echo "ОШИБКА: в тексте нет ссылок vless://"; return 1; } n="$(grep -c . "$ST_SUB.new")" _rb_say "Проверяем ссылки: $n" _st_sub_check "$ST_SUB.new" || { rm -f "$ST_SUB.new"; echo "Прежний список серверов не тронут"; return 1; } [ -s "$ST_SUB" ] && cp -f "$ST_SUB" "$ST_SUB.bak" mv -f "$ST_SUB.new" "$ST_SUB" chmod 600 "$ST_SUB" rm -f "$ST_SUB_URL" "$ST_SUB_TITLE" "$ST_SUB_INFO" node="$(cat "$ST_SUB_NODE" 2>/dev/null)" if [ -n "$node" ] && ! stl_nodes "$ST_SUB" | stl_tsv | ZM_W="$node" awk -F'\t' '$2 == ENVIRON["ZM_W"] { f = 1 } END { exit !f }'; then rm -f "$ST_SUB_NODE" echo " · выбранного узла «$node» больше нет в списке — Steer сам выберет рабочий" fi _rb_say "Список серверов сохранён" if _st_use_vpn && _st_installed; then _rb_say "Перезапускаем VPN-туннель с новым списком серверов"; _st_sub_apply else _rb_say "Готово — серверы применятся, когда выход будет «VPN»"; fi } do_steer_sub_update() { local was now rm -f "$ST_STOP_FLAG" _st_phase sub [ -s "$ST_SUB_URL" ] || { echo "ОШИБКА: это не подписка, а свои ссылки — обновлять нечего"; return 1; } _st_need_ext || return 1 _rb_say "Обновляем подписку" was="$(md5sum < "$ST_SUB" 2>/dev/null)" if ! _st_sub_fetch "$(head -n1 "$ST_SUB_URL")"; then [ "$1" = auto ] && echo " · прежний список серверов остался в работе" return 1 fi now="$(md5sum < "$ST_SUB" 2>/dev/null)" _st_use_vpn || { _rb_say "Готово"; return 0; } if [ "$1" = auto ] && [ "$was" = "$now" ] && [ ! -f "$ST_OFF" ] && stl_running && _st_vpn_probe; then _rb_say "Список серверов не изменился, VPN работает — туннель не трогаем" return 0 fi _rb_say "Перезапускаем VPN-туннель с обновлённым списком серверов" _st_sub_apply || { [ "$1" = auto ] && [ ! -f "$ST_OFF" ] && _st_vpn_heal; return 1; } [ "$1" = auto ] && [ ! -f "$ST_OFF" ] && [ "$(_st_sel)" ] && ! _st_vpn_probe && _st_vpn_heal return 0 } ST_SUB_FELL="$ST_DIR/sub.node.fell" ST_VPN_WATCH="$JOBS_DIR/steer.vpnwatch" _st_vpn_heal() { local pinned _rb_say "VPN не отвечает — перезапускаем Steer" logger -t zapret-manager "Steer: VPN не отвечает — перезапускаем службу" _zm_run 120 stl_restart >/dev/null 2>&1 sleep 3 if _st_vpn_check >/dev/null 2>&1; then _st_vpn_probe >/dev/null 2>&1 echo "[ OK ] VPN снова работает" return 0 fi pinned="$(cat "$ST_SUB_NODE" 2>/dev/null)" if [ -n "$pinned" ]; then _rb_warn "Сервер «$pinned» не отвечает — включаем автовыбор рабочего сервера" logger -t zapret-manager "Steer: сервер «$pinned» не отвечает — включён автовыбор" mkdir -p "$ST_DIR" printf '%s\n' "$pinned" > "$ST_SUB_FELL" rm -f "$ST_SUB_NODE" _st_apply >/dev/null 2>&1 || _zm_run 120 stl_restart >/dev/null 2>&1 sleep 3 if _st_vpn_check >/dev/null 2>&1; then _st_vpn_probe >/dev/null 2>&1 echo "[ OK ] VPN работает через другой сервер" return 0 fi fi echo "[FAIL] VPN не поднялся — ни один сервер подписки не отвечает" return 1 } do_steer_vpn_heal() { rm -f "$ST_STOP_FLAG" _st_phase check _st_vpn_heal } _st_vpn_watch() { local f="$ST_VPN_WATCH" fails=0 heals=0 last=0 now wait _st_installed && _st_use_vpn && [ ! -f "$ST_OFF" ] && [ -s "$ST_SUB" ] && _st_owns "steer-spec" || { rm -f "$f"; return 0; } stl_enabled || { rm -f "$f"; return 0; } [ "$(_st_sel)" ] || { rm -f "$f"; return 0; } [ -z "$(_st_blocker)" ] || return 0 _zm_busy_job >/dev/null 2>&1 && return 0 if _st_vpn_probe; then rm -f "$f"; return 0; fi _zm_inet_ok || return 0 { read -r fails heals last < "$f"; } 2>/dev/null fails="${fails:-0}"; heals="${heals:-0}"; last="${last:-0}" fails=$((fails + 1)) now="$(date +%s)" if [ "$fails" -lt 2 ]; then echo "$fails $heals $last" > "$f"; return 0; fi case "$heals" in 0) wait=0 ;; 1) wait=600 ;; 2) wait=1800 ;; *) wait=10800 ;; esac [ $((now - last)) -lt "$wait" ] && { echo "$fails $heals $last" > "$f"; return 0; } echo "$fails $((heals + 1)) $now" > "$f" job_start steer do_steer_vpn_heal >/dev/null 2>&1 return 0 } do_steer_sub_remove() { local was=0 _st_phase sub _st_use_vpn && was=1 rm -f "$ST_SUB" "$ST_SUB_INFO" "$ST_SUB_URL" "$ST_SUB_TITLE" "$ST_SUB_NODE" "$ST_SUB_FELL" echo warp > "$ST_EXIT" _st_vpn_zone off _st_sub_auto_set off >/dev/null if _st_warp_on; then _rb_say "Подписка удалена — выбранные сервисы идут через WARP" else _rb_say "Подписка удалена — туннеля больше нет, сервисы идут напрямую"; fi [ "$was" = 1 ] && _st_installed && _st_sub_apply return 0 } steer_sub_action() { rm -f "$ST_VPN_PROBE" local action="$1" mode="$2" case "$action" in sub_set) _st_installed || { echo '{"error":"сначала установите Steer"}'; return 1; } case "$mode" in http://*|https://*|*vless://*) ;; *) echo '{"error":"нужна ссылка на подписку (https://…) или ссылки vless://"}'; return 1 ;; esac mkdir -p "$ST_DIR" printf '%s\n' "$mode" > "$ST_DIR/sub.pending" chmod 600 "$ST_DIR/sub.pending" job_start steer do_steer_sub_set ;; sub_links) _st_installed || { echo '{"error":"сначала установите Steer"}'; return 1; } [ -s "$ST_SUB_URL" ] && { echo '{"error":"сейчас подключена подписка по ссылке — свои серверы можно задать, удалив её"}'; return 1; } printf '%s\n' "$mode" | tr ' \t\r' '\n\n\n' | grep -q '^vless://' || { echo '{"error":"нет ни одной ссылки vless://"}'; return 1; } mkdir -p "$ST_DIR" printf '%s\n' "$mode" > "$ST_DIR/sub.pending" chmod 600 "$ST_DIR/sub.pending" job_start steer do_steer_sub_links ;; sub_update) [ -s "$ST_SUB_URL" ] || { echo '{"error":"обновлять нечего"}'; return 1; } job_start steer do_steer_sub_update ;; sub_remove) [ -s "$ST_SUB" ] || { echo '{"error":"подписки нет"}'; return 1; } job_start steer do_steer_sub_remove ;; sub_exit) case "$mode" in warp|vpn) ;; *) echo '{"error":"неизвестный выход"}'; return 1 ;; esac [ "$mode" = vpn ] && [ ! -s "$ST_SUB" ] && { echo '{"error":"сначала добавьте подписку"}'; return 1; } [ "$mode" = warp ] && ! _st_warp_on && { echo '{"error":"сначала подключите WARP"}'; return 1; } mkdir -p "$ST_DIR" echo "$mode" > "$ST_EXIT" [ "$mode" = warp ] && _st_vpn_zone off if _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && [ -n "$(_st_sel)" ]; then if [ "$mode" = vpn ] && ! stl_has vpn; then job_start steer do_steer_sub_exit_vpn else job_start steer do_steer_apply; fi else printf '{"ok":true,"saved":true}\n' fi ;; sub_filter) local m err tsv total hid case "$mode" in '{'*) m="$(printf '%s' "$mode" | jsonfilter -e '@.m' 2>/dev/null)" ;; *) m="$mode" ;; esac if [ -s "$ST_SUB" ] && _st_vpn_ok && [ -n "$(printf '%s' "$m" | tr -d ' |,')" ]; then _zm_excl_save "$ST_DIR/sub.exclude.try" "$m" >/dev/null tsv="$(stl_nodes "$ST_SUB" | stl_tsv)" total="$(printf '%s\n' "$tsv" | grep -c .)" hid="$(printf '%s\n' "$tsv" | _zm_excl_hits "$ST_DIR/sub.exclude.try" "$ST_SUB_HIDE" | grep -c .)" rm -f "$ST_DIR/sub.exclude.try" [ "$total" -gt 0 ] && [ "$hid" -ge "$total" ] && { echo '{"error":"под эти маркеры попадают все узлы подписки — тогда VPN работать не сможет. Уберите лишние маркеры"}'; return 1; } fi err="$(_zm_excl_save "$ST_SUB_EXCL" "$m")" || { printf '{"error":"%s"}\n' "$(esc "$err")"; return 1; } if [ -s "$ST_SUB_NODE" ] && stl_nodes "$ST_SUB" | stl_tsv | ZM_W="$(cat "$ST_SUB_NODE")" awk -F'\t' '$2 == ENVIRON["ZM_W"]' | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | grep -q .; then rm -f "$ST_SUB_NODE" fi if [ -s "$ST_SUB" ] && _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && [ -n "$(_st_sel)" ]; then job_start steer do_steer_apply else printf '{"ok":true,"saved":true}\n' fi ;; sub_hide) local nf="$JOBS_DIR/steer.hide.in" tsv total hid mk xf="$ST_SUB_EXCL" err [ -s "$ST_SUB" ] || { echo '{"error":"подписки нет"}'; return 1; } mkdir -p "$ST_DIR" if [ "$(printf '%s' "$mode" | jsonfilter -e '@.reset' 2>/dev/null)" = true ]; then rm -f "$ST_SUB_HIDE" "$ST_SUB_EXCL" else case "$mode" in *'"markers":'*|*'"markers" :'*) mk="$(printf '%s' "$mode" | jsonfilter -e '@.markers' 2>/dev/null)" xf="$ST_DIR/sub.exclude.try.$$" err="$(_zm_excl_save "$xf" "$mk")" || { printf '{"error":"%s"}\n' "$(esc "$err")"; return 1; } ;; esac printf '%s' "$mode" | jsonfilter -e '@.names[*]' > "$nf" 2>/dev/null tsv="$(stl_nodes "$ST_SUB" | stl_tsv)" total="$(printf '%s\n' "$tsv" | grep -c .)" hid="$(printf '%s\n' "$tsv" | _zm_excl_hits "$xf" "$nf" | grep -c .)" if [ "$total" -gt 0 ] && [ "$hid" -ge "$total" ]; then rm -f "$nf" "$ST_DIR/sub.exclude.try.$$"; echo '{"error":"нельзя скрыть все узлы — тогда VPN работать не сможет"}'; return 1; fi if [ "$xf" != "$ST_SUB_EXCL" ]; then if [ -s "$xf" ]; then mv -f "$xf" "$ST_SUB_EXCL"; else rm -f "$xf" "$ST_SUB_EXCL"; fi fi _zm_names_save "$nf" "$ST_SUB_HIDE" rm -f "$nf" if [ -s "$ST_SUB_NODE" ] && printf '%s\n' "$tsv" | ZM_W="$(cat "$ST_SUB_NODE")" awk -F'\t' '$2 == ENVIRON["ZM_W"]' | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | grep -q .; then rm -f "$ST_SUB_NODE" fi fi if _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && [ -n "$(_st_sel)" ]; then job_start steer do_steer_apply else printf '{"ok":true,"saved":true}\n' fi ;; sub_node) [ -s "$ST_SUB" ] || { echo '{"error":"подписки нет"}'; return 1; } if [ -n "$mode" ] && stl_nodes "$ST_SUB" | stl_tsv | ZM_W="$mode" awk -F'\t' '$2 == ENVIRON["ZM_W"]' | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | grep -q .; then echo '{"error":"этот узел скрыт фильтром — уберите маркер или выберите другой узел"}'; return 1 fi mkdir -p "$ST_DIR" if [ -n "$mode" ]; then printf '%s\n' "$mode" > "$ST_SUB_NODE"; else rm -f "$ST_SUB_NODE"; fi rm -f "$ST_SUB_FELL" "$ST_VPN_WATCH" if _st_use_vpn && _st_installed && [ ! -f "$ST_OFF" ] && [ -z "$(_st_blocker)" ] && [ -n "$(_st_sel)" ]; then job_start steer do_steer_apply else printf '{"ok":true,"saved":true}\n' fi ;; esac } ST_SUB_TAG="# zm-subupd" _st_sub_auto_get() { local l h l="$(grep -F "$ST_SUB_TAG" "$CRON_FILE" 2>/dev/null | head -n1)" [ -n "$l" ] || { echo off; return; } h="$(echo "$l" | awk '{print $2}')" case "$h" in */*) echo "${h#*/}" ;; *) echo 24 ;; esac } _st_sub_auto_set() { local spec="" case "$1" in off) ;; 3|6|12) spec="$(( $(date +%M | sed 's/^0//') % 60 )) */$1 * * *" ;; 24) spec="$(( $(date +%M | sed 's/^0//') % 60 )) 5 * * *" ;; *) echo '{"error":"допустимо: выкл, 3, 6, 12 или 24 часа"}'; return 1 ;; esac mkdir -p "$(dirname "$CRON_FILE")" touch "$CRON_FILE" sed -i "\\|$ST_SUB_TAG|d" "$CRON_FILE" [ -n "$spec" ] && echo "$spec /opt/zapret-manager-luci/backend.sh steer_action sub_refresh x >/dev/null 2>&1 $ST_SUB_TAG" >> "$CRON_FILE" /etc/init.d/cron enable >/dev/null 2>&1 /etc/init.d/cron restart >/dev/null 2>&1 printf '{"ok":true}\n' } do_steer_engine() { _st_phase pkgs rm -f "$ST_STOP_FLAG" _st_installed || { echo "ОШИБКА: Steer ещё не установлен"; return 1; } [ -n "$(_st_blocker)" ] && { echo "ОШИБКА: ядро steer сейчас настраивает не Zapret Manager"; return 1; } rm -f "$STL_CACHE" _ensure_deps _st_install_steer || return 1 if [ ! -f "$ST_OFF" ] && [ -n "$(_st_sel)" ]; then _st_phase rules _st_apply || return 1 fi _rb_say "Готово, ядро steer $(stl_version)" } do_steer_sub_exit_vpn() { rm -f "$ST_STOP_FLAG" _st_need_ext || return 1 _st_sub_apply } steer_sub_status() { local has=false kind="" url="" title="" ext=false vexit=warp node="" up="" down="" total="" expire="" list=null vpn=null mt=0 hidden="" _st_vpn_ok && ext=true vexit="$(_st_exit)" if [ -s "$ST_SUB" ]; then has=true if [ -s "$ST_SUB_URL" ]; then kind=url; url="$(head -n1 "$ST_SUB_URL")"; else kind=links; fi title="$(_st_sub_label)" node="$(cat "$ST_SUB_NODE" 2>/dev/null)" if [ -s "$ST_SUB_INFO" ]; then up="$(sed -n 's/^upload=//p' "$ST_SUB_INFO" | head -n1)" down="$(sed -n 's/^download=//p' "$ST_SUB_INFO" | head -n1)" total="$(sed -n 's/^total=//p' "$ST_SUB_INFO" | head -n1)" expire="$(sed -n 's/^expire=//p' "$ST_SUB_INFO" | head -n1)" fi mt="$(date -r "$ST_SUB" +%s 2>/dev/null)" if [ "$ext" = true ]; then list="$(stl_nodes "$ST_SUB")" case "$list" in '{'*) hidden="$(printf '%s\n' "$list" | stl_tsv | _zm_excl_hits "$ST_SUB_EXCL" "$ST_SUB_HIDE" | tr '\n' ',' | sed 's/,$//')" ;; *) list=null ;; esac fi fi _st_use_vpn && _st_owns "steer-spec" && vpn="$(stl_vpn_json)" printf '{"ext":%s,"has":%s,"kind":"%s","url":"%s","title":"%s","exit":"%s","auto":"%s","node":"%s","quota":{"up":"%s","down":"%s","total":"%s","expire":"%s"},"updated":%s,"list":%s,"vpn":%s,"hidden":[%s],"exclude":"%s","hide_names":[%s],"fell":"%s"}\n' \ "$ext" "$has" "$kind" "$(esc "$url")" "$(esc "$title")" "$vexit" "$(_st_sub_auto_get)" "$(esc "$node")" \ "$(esc "$up")" "$(esc "$down")" "$(esc "$total")" "$(esc "$expire")" "${mt:-0}" "$list" "$vpn" "$hidden" "$(esc "$(cat "$ST_SUB_EXCL" 2>/dev/null)")" "$(_zm_json_lines "$ST_SUB_HIDE")" "$(esc "$(head -n1 "$ST_SUB_FELL" 2>/dev/null)")" } steer_sub_probe() { local out case "$1" in ''|*[!0-9]*) echo '{"ok":false,"error":"неверный номер узла"}'; return 1 ;; esac [ -s "$ST_SUB" ] && _st_vpn_ok || { echo '{"ok":false,"error":"подписки нет"}'; return 1; } out="$(stl_probe "$ST_SUB" "$1")" case "$out" in '{'*) printf '%s\n' "$out" ;; *) echo '{"ok":false,"error":"проверка не удалась"}' ;; esac } steer_action() { local action="$1" mode="$2" case "$action" in diag|wfix_tick|explain) ;; *) rm -f "$ST_VPN_PROBE" ;; esac case "$action" in install|apply|start|stop|remove|warp_restart|warp_endpoint|warp_recreate|lists|engine|warp_setup|warp_fix|warp_fixkeys|warp_own|warp_mode) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } case "$action" in warp_endpoint|warp_recreate|warp_fix|warp_fixkeys) _st_warp_own && { echo '{"error":"в режиме «Свой конфиг» ключи и точку входа задаёт ваш конфиг — замените его или вернитесь к автоматическому WARP"}'; return 1; } ;; esac case "$action" in warp_own) if [ -n "$mode" ]; then printf '%s\n' "$mode" | grep -qi '^[[:space:]]*\[interface\]' || { echo '{"error":"в конфиге нет секции [Interface]"}'; return 1; } printf '%s\n' "$mode" | grep -qi '^[[:space:]]*\[peer\]' || { echo '{"error":"в конфиге нет секции [Peer]"}'; return 1; } printf '%s\n' "$mode" | grep -qi '^[[:space:]]*endpoint[[:space:]]*=' || { echo '{"error":"в конфиге нет Endpoint — адреса сервера"}'; return 1; } mkdir -p "$ST_DIR" printf '%s\n' "$mode" | tr -d '\r' > "$ST_DIR/warp.pending" chmod 600 "$ST_DIR/warp.pending" else _st_own_saved || { echo '{"error":"вставьте конфиг WARP"}'; return 1; } fi job_start steer do_steer_warp_own ;; warp_mode) [ "$mode" = auto ] || { echo '{"error":"неизвестный режим"}'; return 1; } job_start steer do_steer_warp_auto ;; install) job_start steer do_steer_install ;; apply|start) job_start steer do_steer_apply ;; stop) job_start steer do_steer_stop ;; remove) job_start steer do_steer_remove ;; warp_restart) job_start steer do_steer_warp_restart ;; engine) job_start steer do_steer_engine ;; warp_setup) job_start steer do_steer_warp_setup ;; warp_fix|warp_fixkeys) case "$mode" in [1-9]) ;; *) echo '{"error":"неверный номер туннеля"}'; return 1 ;; esac [ "$mode" -le "$ST_WARP_N" ] || { echo '{"error":"неверный номер туннеля"}'; return 1; } if [ "$action" = warp_fixkeys ]; then job_start steer do_steer_warp_fix "$mode" keys else job_start steer do_steer_warp_fix "$mode"; fi ;; warp_endpoint) job_start steer do_steer_warp_endpoint ;; warp_recreate) job_start steer do_steer_warp_recreate ;; lists) _st_sel_set "$mode" || return 1 if _st_installed && [ ! -f "$ST_OFF" ]; then job_start steer do_steer_apply else printf '{"ok":true,"saved":true}\n'; fi ;; esac ;; sub_status) steer_sub_status ;; explain) steer_explain "$mode" ;; sub_auto) _st_sub_auto_set "$mode" ;; sub_refresh) [ -s "$ST_SUB_URL" ] || return 0 _st_running && return 0 job_start steer do_steer_sub_update auto >/dev/null ;; sub_probe) steer_sub_probe "$mode" ;; sub_links_get) [ -s "$ST_SUB" ] || { echo '{"error":"подписки нет"}'; return 1; } [ -s "$ST_SUB_URL" ] && { echo '{"error":"это подписка по ссылке — её серверы задаёт сервис, а не вы"}'; return 1; } printf '{"links":"%s"}\n' "$(esc_ml "$(grep '^vless://' "$ST_SUB")")" ;; sub_set|sub_links|sub_update|sub_remove|sub_exit|sub_node|sub_filter|sub_hide) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } steer_sub_action "$action" "$mode" ;; list_get) steer_list_get "$mode" ;; catalog_refresh) [ -f "$ST_CAT_OFF" ] && { echo '{"error":"каталог списков выключен"}'; return 1; } rm -f "$ST_DIR/catalog.err" ( _st_cat_refresh force >/dev/null 2>&1 & ) printf '{"ok":true}\n' ;; catalog_src) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } mkdir -p "$ST_DIR" case "$mode" in off) touch "$ST_CAT_OFF"; printf '{"ok":true}\n'; return 0 ;; on) rm -f "$ST_CAT_OFF" ;; '') rm -f "$ST_CAT_URL" ;; https://*) case "$mode" in *[[:space:]\"\'\\\`\$\;\|\<\>]*) echo '{"error":"недопустимые символы в ссылке"}'; return 1 ;; esac printf '%s\n' "$mode" > "$ST_CAT_URL" ;; *) echo '{"error":"нужна ссылка https://…/lists.json"}'; return 1 ;; esac rm -f "$ST_DIR/catalog.err" _zm_run 17 _st_cat_refresh force >/dev/null 2>&1 case $? in 0) printf '{"ok":true}\n' ;; 137|143) rmdir "$ST_RUN/cat.lock" 2>/dev/null; rm -f "$ST_RUN/lists.json.tmp"; echo '{"error":"каталог не успел скачаться за 17 секунд — прежний список сервисов оставлен, попробуйте позже"}'; return 1 ;; *) echo '{"error":"каталог по этой ссылке не скачался или в нём нет списков — прежний список сервисов оставлен"}'; return 1 ;; esac ;; list_set|list_reset) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } if [ "$action" = list_set ]; then steer_list_set "$mode"; else steer_list_reset "$mode"; fi ;; halt) _st_running || { echo '{"error":"ничего не выполняется"}'; return 1; } touch "$ST_STOP_FLAG" printf '{"ok":true}\n' ;; restart) _st_running && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } _st_owns "steer-spec" || { echo '{"error":"правил для Steer нет — выберите сервисы"}'; return 1; } stl_restart || { echo '{"error":"Steer не запустился — загляните в системный журнал"}'; return 1; } printf '{"ok":true}\n' ;; autorestart) _st_cron_set "$mode" ;; lists_via) mkdir -p "$ST_DIR" if [ "$mode" = on ]; then touch "$ST_DIR/lists.via"; else rm -f "$ST_DIR/lists.via"; fi printf '{"ok":true}\n' ;; wfix) _st_wfix_set "$mode" ;; wfix_tick) _st_wfix_tick; printf '{"ok":true}\n' ;; wfix_reset) rm -f "$ST_WFIX_STATE"; printf '{"ok":true}\n' ;; warp_own_get) local of="$ST_WARP_OWN" [ -s "$of" ] || of="$ST_WARP_OWN_KEEP" if [ -s "$of" ]; then printf '{"content":"%s"}\n' "$(esc_ml "$(tr -d '\r' < "$of" | tr '\t' ' ')")" else printf '{"content":""}\n'; fi ;; diag) local trace warp="none" colo="" tun="" tsep="" wi wn wv wc vpn="none" vip="" vloc="" gs gt if _st_installed && [ -n "$(_st_sel)" ] && [ ! -f "$ST_OFF" ] && _st_use_vpn; then vpn=off _st_vpn_probe && vpn=on read -r _ _ vip vloc 2>/dev/null < "$ST_VPN_PROBE" elif _st_installed && _st_warp_on && [ -n "$(_st_sel)" ] && [ ! -f "$ST_OFF" ]; then warp=off wn=1 while [ "$wn" -le "$ST_WARP_N" ]; do wi="$(_st_wif "$wn")" if _st_owns "net $wi" && [ -d "/sys/class/net/$wi" ]; then trace="$(curl -s --interface "$wi" --connect-timeout 4 --max-time 8 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" wc="$(echo "$trace" | sed -n 's/^colo=//p')" wv="" case "$trace" in *warp=on*|*warp=plus*) wv=on ;; *ip=*) _st_warp_own && wv=on ;; esac gs=""; gt="" if [ "$wv" = on ]; then warp=on; [ -n "$colo" ] || colo="$wc" _st_warp_geo "$wi" set -- $(_st_geo_get "$wi") if [ $# -ge 4 ]; then [ -n "$wc" ] || wc="$1" gs="$2"; shift 3; gt="$*" [ "$gs" = - ] && gs=""; [ "$gt" = - ] && gt="" fi else wc="$(_st_colo_of "$wi")"; [ -n "$wc" ] && wv=notls || wv=off; fi tun="$tun$tsep{\"n\":$wn,\"warp\":\"$wv\",\"colo\":\"$(esc "$wc")\",\"city\":\"$(esc "$gt")\",\"seen\":\"$(esc "$gs")\"}" tsep="," fi wn=$((wn + 1)) done fi local d="" stl_present && _st_owns "steer-spec" && d="$(stl_diag)" printf '{"warp":"%s","colo":"%s","vpn":"%s","vpn_ip":"%s","vpn_loc":"%s","tunnels":[%s],"diag":%s}\n' "$warp" "$(esc "$colo")" "$vpn" "$(esc "$vip")" "$(esc "$vloc")" "$tun" "${d:-null}" ;; dns_fix) do_steer_dns_fix printf '{"ok":true}\n' ;; cron_restart) _st_cron_restart ;; sync) _st_cron_refresh _st_owns "steer-spec" && [ ! -f "$ST_OFF" ] && stl_reload printf '{"ok":true}\n' ;; *) echo '{"error":"неизвестное действие"}' ;; esac } AWG_DIR="/etc/zm-awg" AWG_RUN="$JOBS_DIR/awg" AWG_WARP_PEER="bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=" AWG_ENDPOINTS="engage.cloudflareclient.com:4500 engage.cloudflareclient.com:2408 162.159.192.1:2408 162.159.193.1:2408 162.159.195.1:2408 188.114.97.1:2408 188.114.98.1:500 188.114.99.1:4500" mkdir -p "$AWG_RUN" 2>/dev/null _awg_say() { echo "==> $*"; } _awg_pkg_ver() { if [ "$PKG" = "apk" ]; then apk info -v 2>/dev/null | sed -n "s/^$1-\([0-9][^ ]*\)\$/\1/p" | head -n1 else opkg list-installed 2>/dev/null | awk -v p="$1" '$1 == p { print $3; exit }' fi } _awg_installed() { command -v awg >/dev/null 2>&1 && { _pkg_is_installed kmod-amneziawg || _st_awg_loaded; }; } _awg_proto_ok() { ubus call network get_proto_handlers 2>/dev/null | grep -q '"amneziawg"'; } _awg_ifaces() { uci -q show network | sed -n "s/^network\.\([A-Za-z0-9_]*\)\.proto='amneziawg'\$/\1/p"; } _awg_is_steer() { case "$1" in zmwarp|zmwarp[0-9]) return 0 ;; esac; return 1; } _awg_peer_sec() { uci -q -X show network | sed -n "s/^network\.\([^.=]*\)=amneziawg_$1\$/\1/p" | head -n1; } _awg_zone_of() { local z for z in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=zone\$/\1/p"); do case " $(uci -q get "firewall.$z.network") " in *" $1 "*) uci -q get "firewall.$z.name"; return 0 ;; esac done return 1 } _awg_hs_age() { local hs hs=$(awg show "$1" latest-handshakes 2>/dev/null | awk '$2 > 0 { print $2; exit }') [ -n "$hs" ] && echo $(( $(date +%s) - hs )) } _awg_health() { local i n=0 up=0 a st=0 if _awg_installed; then for i in $(_awg_ifaces); do [ "$(uci -q get "network.$i.auto")" = 0 ] && [ ! -d "/sys/class/net/$i" ] && continue n=$((n + 1)) a="$(_awg_hs_age "$i")" [ -n "$a" ] && [ "$a" -lt 180 ] && up=$((up + 1)) done if [ "$n" = 0 ]; then st=5 elif [ "$up" = "$n" ]; then st=1 elif [ "$up" = 0 ]; then st=2 else st=3; fi fi printf '%s,"awg_up":%s,"awg_total":%s' "$st" "$up" "$n" } _awg_iface_json() { local i="$1" p up=false addr ep host port age rx=0 tx=0 zone route auto desc warp=false owner=user mtu p="$(_awg_peer_sec "$i")" ifstatus "$i" 2>/dev/null | grep -q '"up": true' && up=true addr="$(uci -q get "network.$i.addresses" | tr ' ' ',')" mtu="$(uci -q get "network.$i.mtu")" host="$(uci -q get "network.$p.endpoint_host")"; port="$(uci -q get "network.$p.endpoint_port")" ep="$(awg show "$i" endpoints 2>/dev/null | awk '{ print $2; exit }')" [ -n "$ep" ] && [ "$ep" != "(none)" ] || ep="$host${port:+:$port}" age="$(_awg_hs_age "$i")" set -- $(awg show "$i" transfer 2>/dev/null | awk '{ r += $2; t += $3 } END { print r + 0, t + 0 }') rx="${1:-0}"; tx="${2:-0}" zone="$(_awg_zone_of "$i")" route="$(uci -q get "network.$p.route_allowed_ips")" auto="$(uci -q get "network.$i.auto")" desc="$(uci -q get "network.$p.description")" [ "$(uci -q get "network.$p.public_key")" = "$AWG_WARP_PEER" ] && warp=true _awg_is_steer "$i" && owner=steer printf '{"name":"%s","owner":"%s","up":%s,"enabled":%s,"address":"%s","mtu":"%s","endpoint":"%s","hs_age":"%s","rx":%s,"tx":%s,"zone":"%s","route_all":%s,"warp":%s,"desc":"%s"}' \ "$i" "$owner" "$up" "$([ "$auto" = 0 ] && echo false || echo true)" "$(esc "$addr")" "$(esc "$mtu")" "$(esc "$ep")" "$age" \ "$rx" "$tx" "$(esc "$zone")" "$([ "$route" = 1 ] && echo true || echo false)" "$warp" "$(esc "$desc")" } awg_status() { local i list="" sep="" running=false conf=false ep="" mih=false lp=luci-proto-amneziawg lv lv="$(_awg_pkg_ver luci-proto-amneziawg)" [ -n "$lv" ] || { lv="$(_awg_pkg_ver luci-app-amneziawg)"; [ -n "$lv" ] && lp=luci-app-amneziawg; } _job_alive awg && running=true for i in $(_awg_ifaces); do list="$list$sep$(_awg_iface_json "$i")"; sep=","; done if [ -s "$MIXOMO_WARP_CONF" ]; then conf=true ep="$(sed -n 's/^[[:space:]]*Endpoint[[:space:]]*=[[:space:]]*//p' "$MIXOMO_WARP_CONF" | head -n1)" fi [ -x /etc/init.d/mihomo ] && mih=true printf '{"running":%s,"phase":"%s","installed":%s,"kmod":"%s","tools":"%s","luci":"%s","luci_pkg":"%s","module":%s,"proto":%s,"steer":%s,"warp_conf":%s,"warp_path":"%s","warp_endpoint":"%s","mihomo":%s,"endpoints":"%s","steer_own":%s,"steer_active":"%s","ifaces":[%s]}\n' \ "$running" "$(cat "$AWG_RUN/phase" 2>/dev/null)" "$(_awg_installed && echo true || echo false)" \ "$(esc "$(_awg_pkg_ver kmod-amneziawg)")" "$(esc "$(_awg_pkg_ver amneziawg-tools)")" "$(esc "$lv")" "$lp" \ "$(_st_awg_loaded && echo true || echo false)" "$(_awg_proto_ok && echo true || echo false)" \ "$(_st_warp_on && echo true || echo false)" "$conf" "$MIXOMO_WARP_CONF" "$(esc "$ep")" "$mih" "$AWG_ENDPOINTS" "$(_st_warp_own && echo true || echo false)" "$(_st_warp_on && _st_active_if)" "$list" } do_awg_install() { echo "${1:-install}" > "$AWG_RUN/phase" _ensure_deps mkdir -p "$ST_RUN" "$AWG_DIR" [ "$1" = update ] && _awg_say "Переустанавливаем AmneziaWG" ST_AWG_OWN=0 _st_install_awg "$1" || return 1 _awg_say "Готово: AmneziaWG $(_awg_pkg_ver amneziawg-tools) установлен" } do_awg_remove() { local p i echo remove > "$AWG_RUN/phase" for i in $(_awg_ifaces); do _awg_is_steer "$i" && [ -s "$ST_OWNED" ] && continue _awg_is_steer "$i" || grep -qxF "$i" "$AWG_DIR/owned" 2>/dev/null || continue _awg_say "Удаляем интерфейс $i с его пиром и зоной файрвола" _awg_delete "$i" done _awg_say "Удаляем пакеты AmneziaWG" for p in luci-i18n-amneziawg-ru luci-proto-amneziawg luci-app-amneziawg amneziawg-tools kmod-amneziawg; do _pkg_is_installed "$p" || continue $DELETE "$p" done _awg_say "Выгружаем модуль ядра и чистим файлы" rmmod amneziawg >/dev/null 2>&1 [ -d /sys/module/amneziawg ] && _zm_reboot_hint "AmneziaWG удалён, но его модуль ядра ещё загружен. Перезагрузите роутер, чтобы он выгрузился." sed -i -E '/^pkg (kmod-amneziawg|amneziawg-tools|luci-proto-amneziawg|luci-app-amneziawg|luci-i18n-amneziawg-ru)$/d' "$ST_OWNED" 2>/dev/null rm -rf "$AWG_DIR" 2>/dev/null rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* 2>/dev/null _rb_rpcd_ensure _awg_say "Готово: AmneziaWG удалён полностью" } _awg_valid_ep() { printf '%s' "$1" | grep -Eq '^(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9.-]+):[0-9]{1,5}$'; } AWG_API1="https://api.cloudflareclient.com/v0a4005/reg" AWG_API2="https://api.cloudflareclient.com/v0i1909051800/reg" AWG_RELAY="https://edge-client-api.vercel.app/v0a4005/reg" AWG_CF_VER="a-6.11-2223" AWG_BOOT_PRIV="4OnO86dDLpqJ2U10ODwX3tarx6xlRGLfkmbSBtMgaHg=" AWG_BOOT_IP="172.16.0.3" AWG_TEST_IF="zmawgtest" AWG_TEST_HOSTS="162.159.192.1 188.114.97.1 188.114.96.3 162.159.193.2 162.159.195.2 188.114.98.2 188.114.99.2 8.6.112.2 8.34.70.2 8.34.146.2 8.39.125.2" AWG_TEST_PORTS="2408 500 4500 1701" AWG_I1_ICLOUD="" AWG_I1_QUIC1="" AWG_I1_SET="quic2 dns stun icloud sip quic" AWG_I1_HOSTS="www.apple.com www.google.com www.microsoft.com cdn.jsdelivr.net" _i1_hex() { if command -v hexdump >/dev/null 2>&1; then hexdump -ve '1/1 "%02x"'; else od -An -v -tx1 | tr -d ' \n'; fi; } _i1_num() { awk -v a="$1" -v b="$2" -v s="$(head -c 4 /dev/urandom | _i1_hex)" 'BEGIN { x = 0; for (i = 1; i <= length(s); i++) x = x * 16 + index("0123456789abcdef", substr(s, i, 1)) - 1; srand(x); print a + int(rand() * (b - a + 1)) }' } _i1_str() { tr -dc 'a-z0-9' < /dev/urandom 2>/dev/null | head -c "$1"; } _i1_host() { set -- $AWG_I1_HOSTS; eval "echo \"\${$(_i1_num 1 $#)}\""; } _i1_dns() { local h="${1:-$(_i1_host)}" q="" l p for l in $(echo "$h" | tr '.' ' '); do q="$q$(printf '%02x' "${#l}")$(printf '%s' "$l" | _i1_hex)"; done p="$(_i1_num 60 180)" printf '' "$q" $((p + 4)) "$p" "$p" } _i1_stun() { local s s=$(( $(_i1_num 4 8) * 4 )) printf '' $((s + 4)) "$s" "$(_i1_str "$s" | _i1_hex)" } _i1_sip() { local h="${1:-$(_i1_host)}" printf '' "$(printf '%s\r\n' \ "OPTIONS sip:$h SIP/2.0" \ "Via: SIP/2.0/UDP 192.168.$(_i1_num 0 255).$(_i1_num 2 254):5060;branch=z9hG4bK$(_i1_str 10)" \ "From: ;tag=$(_i1_num 100000 999999)" \ "To: " \ "Call-ID: $(_i1_str 16)@$h" \ "CSeq: $(_i1_num 1000 9999) OPTIONS" \ "Max-Forwards: 70" \ "User-Agent: PJSIP/2.13" \ "Content-Length: 0" \ "" | _i1_hex)" } _awg_i1() { case "$1" in quic) echo "$MIXOMO_AWG_I1" ;; quic2) echo "$AWG_I1_QUIC1" ;; icloud) echo "$AWG_I1_ICLOUD" ;; dns) _i1_dns ;; stun) _i1_stun ;; sip) _i1_sip ;; esac } _awg_write_conf() { mkdir -p "$(dirname "$MIXOMO_WARP_CONF")" printf '%s\n' \ "[Interface]" "PrivateKey = $1" "Address = ${3}${4:+, $4}" "DNS = 1.1.1.1, 1.0.0.1" "MTU = 1280" \ "S1 = $MIXOMO_AWG_S1" "S2 = $MIXOMO_AWG_S2" "Jc = $MIXOMO_AWG_JC" "Jmin = $MIXOMO_AWG_JMIN" "Jmax = $MIXOMO_AWG_JMAX" \ "H1 = $MIXOMO_AWG_H1" "H2 = $MIXOMO_AWG_H2" "H3 = $MIXOMO_AWG_H3" "H4 = $MIXOMO_AWG_H4" "I1 = ${6:-$MIXOMO_AWG_I1}" "" \ "[Peer]" "PublicKey = $2" "AllowedIPs = 0.0.0.0/0, ::/0" "Endpoint = $5" "PersistentKeepalive = 25" \ > "$MIXOMO_WARP_CONF" chmod 600 "$MIXOMO_WARP_CONF" } _awg_keys_main() { local reg="$AWG_RUN/reg.$$.json" mkdir -p "$AWG_RUN" rm -f "$reg" curl -fsSL --connect-timeout 10 --max-time 30 "$MIXOMO_WARP_PRIMARY" -o "$reg" 2>/dev/null && grep -q '"public_key"' "$reg" || { rm -f "$reg"; return 1; } G_PRIV=$(grep -o '"key"[[:space:]]*:[[:space:]]*"[^"]*"' "$reg" | head -n1 | sed 's/.*:[[:space:]]*"//;s/"$//') G_PEER=$(grep -o '"public_key"[[:space:]]*:[[:space:]]*"[^"]*"' "$reg" | head -n1 | sed 's/.*:[[:space:]]*"//;s/"$//') G_V4=$(grep -o '"v4"[[:space:]]*:[[:space:]]*"[^"]*"' "$reg" | sed -n '2p' | sed 's/.*:[[:space:]]*"//;s/"$//') G_V6=$(grep -o '"v6"[[:space:]]*:[[:space:]]*"[^"]*"' "$reg" | sed -n '2p' | sed 's/.*:[[:space:]]*"//;s/"$//') rm -f "$reg" [ -n "$G_PRIV" ] && [ -n "$G_PEER" ] && [ -n "$G_V4" ] } _awg_keys_get() { G_PRIV=""; G_PEER=""; G_V4=""; G_V6="" mkdir -p "$AWG_RUN" if [ -z "$(_awg_keygen_tool)" ] && [ "$ZM_KEYS_INSTALL" = 1 ]; then _awg_say "Ставим wireguard-tools, чтобы создавать ключи на роутере" $UPDATE >/dev/null 2>&1 $INSTALL wireguard-tools >&2 || echo " wireguard-tools не установился" fi if [ -n "$(_awg_keygen_tool)" ]; then _awg_say "Получаем ключи WARP: Cloudflare, ключ создаётся на роутере" _awg_cf_register "" && return 0 echo " Cloudflare не выдал ключи" fi _awg_say "Получаем ключи WARP: внешний генератор" _awg_keys_main && return 0 echo " внешний генератор не ответил" if [ -n "$1" ] && [ -d "/sys/class/net/$1" ] && [ -n "$(_awg_keygen_tool)" ]; then _awg_say "Пробуем Cloudflare через туннель $1" _awg_cf_register "--interface $1" && return 0 fi return 1 } _awg_keygen_tool() { if command -v awg >/dev/null 2>&1; then echo awg elif command -v wg >/dev/null 2>&1; then echo wg; fi } _awg_genkey() { local gen gen="$(_awg_keygen_tool)" [ -n "$gen" ] || return 1 G_PRIV="$($gen genkey 2>/dev/null)"; G_PUB="$(printf '%s' "$G_PRIV" | $gen pubkey 2>/dev/null)" [ -n "$G_PRIV" ] && [ -n "$G_PUB" ] } _awg_cf_register() { local reg="$AWG_RUN/reg.$$.json" api code pre id tok host lbl dead="" ua='okhttp/3.12.1' cv="$AWG_CF_VER" _awg_genkey || { echo " нет утилиты awg или wg"; return 1; } mkdir -p "$AWG_RUN" for api in "$AWG_API1" "$AWG_API2" $ST_WARP_API "$AWG_RELAY"; do host="${api#https://}"; host="${host%%/*}" case " $dead " in *" $host "*) continue ;; esac lbl="$host"; [ "$api" = "$AWG_RELAY" ] && lbl="реле $host" rm -f "$reg" code=$(curl -s --connect-timeout 6 --max-time 25 $1 -X POST \ -H 'Content-Type: application/json' -H "User-Agent: $ua" -H "CF-Client-Version: $cv" -H 'Accept-Encoding: identity' \ -d "{\"key\":\"$G_PUB\",\"install_id\":\"\",\"fcm_token\":\"\",\"tos\":\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\",\"type\":\"Android\",\"model\":\"\",\"locale\":\"en_US\"}" \ -o "$reg" -w '%{http_code}' "$api" 2>/dev/null) case "$code" in 200|201) ;; ''|000) echo " $lbl: нет связи"; dead="$dead $host"; continue ;; *) echo " $lbl: ответ $code"; continue ;; esac G_PEER=""; G_V4=""; G_V6=""; id=""; tok="" for pre in '@.result' '@'; do G_PEER="$(jsonfilter -i "$reg" -e "$pre.config.peers[0].public_key" 2>/dev/null)" [ -n "$G_PEER" ] || continue G_V4="$(jsonfilter -i "$reg" -e "$pre.config.interface.addresses.v4" 2>/dev/null)" G_V6="$(jsonfilter -i "$reg" -e "$pre.config.interface.addresses.v6" 2>/dev/null)" id="$(jsonfilter -i "$reg" -e "$pre.id" 2>/dev/null)"; tok="$(jsonfilter -i "$reg" -e "$pre.token" 2>/dev/null)" break done if [ -n "$G_PEER" ] && [ -n "$G_V4" ]; then if [ -n "$id" ] && [ -n "$tok" ]; then code=$(curl -s --connect-timeout 6 --max-time 15 $1 -X PATCH \ -H 'Content-Type: application/json' -H "User-Agent: $ua" -H "CF-Client-Version: $cv" -H 'Accept-Encoding: identity' -H "Authorization: Bearer $tok" \ -d '{"warp_enabled":true}' -o /dev/null -w '%{http_code}' "${api}/$id" 2>/dev/null) case "$code" in 2??) ;; *) echo " $lbl: ключ выдан, но WARP не включился (ответ ${code:-нет связи})"; G_PEER=""; G_V4=""; G_V6=""; dead="$dead $host"; continue ;; esac fi rm -f "$reg" echo " ключи выданы ($lbl)" return 0 fi echo " $lbl: в ответе нет ключа сервера" done rm -f "$reg" return 1 } _awg_api_ok() { local code code=$(curl -s --connect-timeout 5 --max-time 8 $1 -o /dev/null -w '%{http_code}' "$AWG_API1/" 2>/dev/null) [ -n "$code" ] && [ "$code" != 000 ] } AWG_NO_I1=0 AWG_TRY_WAIT=8 _awg_try() { local dev="$1" conf="$AWG_RUN/try.conf" hs i=0 i1="$5" [ "$AWG_NO_I1" = 1 ] && i1="" ip link del "$dev" >/dev/null 2>&1 ip link add dev "$dev" type amneziawg >/dev/null 2>&1 || return 2 ip link set "$dev" mtu 1280 >/dev/null 2>&1 ip addr add "$3/32" dev "$dev" >/dev/null 2>&1 ip link set "$dev" up >/dev/null 2>&1 printf '%s\n' "[Interface]" "PrivateKey = $2" \ "Jc = $MIXOMO_AWG_JC" "Jmin = $MIXOMO_AWG_JMIN" "Jmax = $MIXOMO_AWG_JMAX" "S1 = $MIXOMO_AWG_S1" "S2 = $MIXOMO_AWG_S2" \ "H1 = $MIXOMO_AWG_H1" "H2 = $MIXOMO_AWG_H2" "H3 = $MIXOMO_AWG_H3" "H4 = $MIXOMO_AWG_H4" ${i1:+"I1 = $i1"} "" \ "[Peer]" "PublicKey = ${6:-$AWG_WARP_PEER}" "AllowedIPs = 0.0.0.0/0" "Endpoint = $4" "PersistentKeepalive = 5" > "$conf" if ! awg setconf "$dev" "$conf" >/dev/null 2>&1; then if [ -n "$i1" ] && sed -i '/^I1 = /d' "$conf" && awg setconf "$dev" "$conf" >/dev/null 2>&1; then [ "$AWG_NO_I1" = 1 ] || echo " модуль AmneziaWG не знает маску I1 (версия 1.0) — пробуем без неё" AWG_NO_I1=1 else rm -f "$conf"; return 1 fi fi rm -f "$conf" while [ "$i" -lt "$AWG_TRY_WAIT" ]; do hs=$(awg show "$dev" latest-handshakes 2>/dev/null | awk '{ print $2; exit }') [ "${hs:-0}" -gt 0 ] 2>/dev/null && return 0 sleep 0.5 2>/dev/null || sleep 1 i=$((i + 1)) done return 1 } _awg_try_down() { ip link del "$1" >/dev/null 2>&1; } _awg_ml() { [ "$AWG_NO_I1" = 1 ] || echo ", маска $(_awg_mask_name "$1")"; } _awg_trace() { curl -s --interface "$1" --connect-timeout 4 --max-time 6 http://1.1.1.1/cdn-cgi/trace 2>/dev/null | grep -Eq '^warp=(on|plus)' && return 0 curl -s -o /dev/null --interface "$1" --connect-timeout 4 --max-time 7 https://www.google.com/generate_204 2>/dev/null } _awg_mask_name() { case "$1" in "$AWG_I1_ICLOUD") echo "iCloud-DNS" ;; "$AWG_I1_QUIC1") echo "QUIC-2" ;; "/dev/null 2>&1; then v="$(head -c 4 /dev/urandom 2>/dev/null | hexdump -e '1/4 "%u"' 2>/dev/null)" elif command -v od >/dev/null 2>&1; then v="$(head -c 4 /dev/urandom 2>/dev/null | od -An -tu4 2>/dev/null | tr -d ' \n')"; fi case "$v" in ''|*[!0-9]*) v="$(( $(date +%s) % 100000 * 1000 + $$ % 1000 ))" ;; esac echo "$v" } _awg_rand_hosts() { awk -v n="$1" -v pools="$ST_WARP_POOLS" -v seed="$(_zm_rand32)" 'BEGIN { srand(seed + 0); c = split(pools, p, " ") for (i = 1; i <= c; i++) for (k = 0; k < n; k++) printf "%s%d\n", p[i], 2 + int(rand() * 253) }' | awk 'BEGIN { srand() } { print rand() "\t" $0 }' | sort -n | cut -f2 } do_awg_gen() { local mode="$1" ep="$2" path="" i hs a host port m n=0 best="" fall="" ru="" rucnt=0 t0 colo="" bmask="$MIXOMO_AWG_I1" bep="" masks tried="" mkdir -p "$AWG_RUN" [ "$ZM_GEN_NOPHASE" = 1 ] || echo gen > "$AWG_RUN/phase" G_PRIV=""; G_PEER=""; G_V4=""; G_V6="" if [ "$mode" = std ]; then _awg_say "Генерируем WARP" _awg_keys_get || { echo "ОШИБКА: ключи не получены — ни один источник не ответил. Попробуйте «С проверкой связи»"; return 1; } _awg_say "Ключи получены, адрес $G_V4" if [ "$ep" = auto ]; then _awg_say "Подбираем точку входа" ep="$(_zm_wait "проверяем точки входа" _mixomo_warp_best_endpoint)" cat "$JOBS_DIR/mixomo_warp/pick.info" 2>/dev/null || echo " зарубежный узел не нашёлся — беру стандартную точку" rm -f "$JOBS_DIR/mixomo_warp/pick.info" fi _awg_valid_ep "$ep" || ep="engage.cloudflareclient.com:4500" _awg_say "Точка входа: $ep" _awg_write_conf "$G_PRIV" "$G_PEER" "$G_V4" "$G_V6" "$ep" _awg_say "Готово: WARP.conf сохранён" return 0 fi _awg_installed || { echo "ОШИБКА: для проверки связи нужен AmneziaWG — установите его выше"; return 1; } _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 trap '_awg_try_down "$AWG_TEST_IF"' EXIT INT TERM AWG_NO_I1=0 _awg_try "$AWG_TEST_IF" "$AWG_BOOT_PRIV" "$AWG_BOOT_IP" "127.0.0.1:9" "$MIXOMO_AWG_I1" if [ $? = 2 ]; then echo "ОШИБКА: модуль ядра AmneziaWG не загружен — переустановите AmneziaWG или перезагрузите роутер"; return 1; fi _awg_try_down "$AWG_TEST_IF" _awg_say "Шаг 1 из 2: получаем ключи WARP (ключ создаётся на роутере)" if _awg_cf_register ""; then : elif _awg_keys_main; then echo " ключи выдал внешний генератор" else echo " внешний генератор тоже не ответил" echo " ищем обходной путь через туннель" path="" for i in $(_awg_ifaces); do a="$(_awg_hs_age "$i")" [ -n "$a" ] && [ "$a" -lt 180 ] || continue if _awg_api_ok "--interface $i"; then path="--interface $i"; echo " через туннель $i"; break; fi done if [ -z "$path" ]; then echo " поднимаем временный туннель на общих ключах" for host in 188.114.96.3 162.159.192.1 188.114.97.1; do for port in $AWG_TEST_PORTS; do for m in "$MIXOMO_AWG_I1" "$AWG_I1_ICLOUD" "$AWG_I1_QUIC1"; do [ "$AWG_NO_I1" = 1 ] && [ "$m" != "$MIXOMO_AWG_I1" ] && continue if _awg_try "$AWG_TEST_IF" "$AWG_BOOT_PRIV" "$AWG_BOOT_IP" "$host:$port" "$m" && _awg_api_ok "--interface $AWG_TEST_IF"; then path="--interface $AWG_TEST_IF"; bep="$host:$port"; bmask="$m" echo " временный туннель: $host:$port$(_awg_ml "$m")" break 3 fi done done done fi [ -n "$path" ] || { _awg_try_down "$AWG_TEST_IF"; echo "ОШИБКА: источник ключей недоступен ни напрямую, ни через туннели — похоже, провайдер закрыл WARP"; return 1; } if ! _awg_cf_register "$path"; then _awg_try_down "$AWG_TEST_IF" echo "ОШИБКА: ключи не выданы"; return 1 fi _awg_try_down "$AWG_TEST_IF" fi _awg_say "Ключи получены, адрес $G_V4" _awg_say "Шаг 2 из 2: ищем рабочую точку входа и маску" masks="$bmask" for m in "$MIXOMO_AWG_I1" "$AWG_I1_ICLOUD" "$AWG_I1_QUIC1"; do [ "$m" = "$bmask" ] || masks="$masks $m"; done local ports="" oifs="$IFS" nl=' ' h1="${bep%%:*}" allm meta torn seen allm="$masks $(_i1_dns) $(_i1_stun) $(_i1_sip)" [ -n "$h1" ] || h1="${AWG_TEST_HOSTS%% *}" for port in $AWG_TEST_PORTS; do IFS="$nl" for m in $masks; do IFS="$oifs" [ "$AWG_NO_I1" = 1 ] && [ "$m" != "$bmask" ] && continue n=$((n + 1)) if _awg_try "$AWG_TEST_IF" "$G_PRIV" "$G_V4" "$h1:$port" "$m" "$G_PEER"; then ports="$ports $port"; break; fi done IFS="$oifs" echo " порт $port: $(case " $ports " in *" $port "*) echo открыт ;; *) echo закрыт ;; esac)" done if [ -z "$ports" ]; then echo " основные порты молчат — пробуем 50 запасных, это до двух минут" AWG_TRY_WAIT=4 for port in $ST_WARP_PORTS_EXT; do _awg_try "$AWG_TEST_IF" "$G_PRIV" "$G_V4" "$h1:$port" "$bmask" "$G_PEER" && ports="$ports $port" [ "$(echo $ports | wc -w)" -ge 2 ] && break done AWG_TRY_WAIT=8 [ -n "$ports" ] && echo " открыты запасные порты:$ports" fi [ -n "$ports" ] || _rb_warn "Ни один порт не ответил — похоже, провайдер режет UDP для WARP" t0="$(date +%s)" for host in $h1 $AWG_TEST_HOSTS $(_awg_rand_hosts 2); do if [ "$rucnt" -ge 12 ]; then echo " все проверенные точки ведут на российские узлы — похоже, провайдер направляет туда весь WARP"; break; fi for port in $ports; do a="$host:$port" case " $tried " in *" $a "*) continue ;; esac tried="$tried $a" IFS="$nl" for m in $allm; do IFS="$oifs" [ "$AWG_NO_I1" = 1 ] && [ "$m" != "$bmask" ] && continue [ $(( $(date +%s) - t0 )) -gt 420 ] && { echo " поиск идёт больше 7 минут — останавливаемся"; break 3; } n=$((n + 1)) [ "$n" -gt 80 ] && break 3 if _awg_try "$AWG_TEST_IF" "$G_PRIV" "$G_V4" "$a" "$m" "$G_PEER"; then if _awg_trace "$AWG_TEST_IF"; then set -- $(_st_warp_probe "$AWG_TEST_IF"); torn="$3" if [ "$torn" = 1 ]; then echo " $a$(_awg_ml "$m") — трафик пошёл и оборвался: так DPI рвёт туннель" [ -n "$fall" ] || fall="$a|$m" continue fi meta="потери $1%, $2 мс" colo="" set -- $(_cf_meta "$AWG_TEST_IF") if [ $# -ge 4 ]; then colo="$1" meta="$meta, узел $1" [ "$2" != - ] && seen="$2" || seen="" shift 3 [ "$*" != - ] && meta="$meta ($*)" [ -n "$seen" ] && meta="$meta, сайты видят $seen" fi if [ -n "$colo" ] && _st_warp_is_ru "$colo"; then echo " $a$(_awg_ml "$m") — трафик идёт, но через российский узел $colo: там свой DPI, ищем зарубежный" [ -n "$ru" ] || ru="$a|$m|$colo" rucnt=$((rucnt + 1)) break 2 fi best="$a"; bmask="$m" echo " $a$(_awg_ml "$m") — трафик идёт: $meta" break 3 fi echo " $a$(_awg_ml "$m") — рукопожатие есть, трафика нет" [ -n "$fall" ] || fall="$a|$m" else echo " $a$(_awg_ml "$m") — нет ответа" fi done IFS="$oifs" done done IFS="$oifs" _awg_try_down "$AWG_TEST_IF" if [ -z "$best" ] && [ -n "$ru" ]; then best="${ru%%|*}"; bmask="${ru#*|}"; bmask="${bmask%|*}" _rb_warn "Зарубежный узел не нашёлся — записываю точку через российский узел ${ru##*|} ($best): WARP работает, но часть сайтов через него может не открыться" fi if [ -z "$best" ]; then if [ -n "$fall" ]; then best="${fall%%|*}"; bmask="${fall#*|}" _rb_warn "Трафик нигде не пошёл — записываю точку с рукопожатием ($best), туннель может не заработать" else best="engage.cloudflareclient.com:4500"; bmask="$MIXOMO_AWG_I1" _rb_warn "Ни одна точка не ответила — записываю стандартную ($best)" fi fi _awg_write_conf "$G_PRIV" "$G_PEER" "$G_V4" "$G_V6" "$best" "$bmask" if [ "$AWG_NO_I1" = 1 ]; then sed -i '/^I1 = /d' "$MIXOMO_WARP_CONF" _awg_say "Готово: WARP.conf сохранён (точка $best, без маски I1)" else _awg_say "Готово: WARP.conf сохранён (точка $best, маска $(_awg_mask_name "$bmask"))" fi } _awg_iface_opts() { awk 'function trim(s) { gsub(/^[ \t\r]+|[ \t\r]+$/, "", s); return s } { l = $0; sub(/[#;].*$/, "", l); l = trim(l); if (l == "") next if (l ~ /^\[.*\]$/) { sec = tolower(trim(substr(l, 2, length(l) - 2))); next } if (sec != "interface") next p = index(l, "="); if (!p) next k = trim(substr(l, 1, p - 1)); v = trim(substr(l, p + 1)) if (v == "" || tolower(k) ~ /^(privatekey|address|dns|mtu|listenport|table|saveconfig|preup|postup|predown|postdown|fwmark)$/) next n = "" for (i = 1; i <= length(k); i++) { c = substr(k, i, 1); if (c ~ /[A-Z]/ && i > 1 && substr(k, i - 1, 1) ~ /[a-z0-9]/) n = n "_"; n = n tolower(c) } gsub(/[^a-z0-9_]/, "", n); if (n == "") next print "awg_" n, v }' "$1" } _awg_iface_set() { local o v _awg_iface_opts "$1" | while read -r o v; do uci set "network.$2.$o=$v"; done } _awg_uci_conf() { local o v for o in $(uci -q show "network.$1" | sed -n "s/^network\.$1\.\(awg_[a-z0-9_]*\)=.*/\1/p"); do v="$(uci -q get "network.$1.$o")" [ -n "$v" ] || continue echo "$(echo "${o#awg_}" | awk -F_ '{ for (i = 1; i <= NF; i++) printf "%s%s", toupper(substr($i, 1, 1)), substr($i, 2) }') = $v" done } _awg_conf_kv() { awk 'function trim(s) { gsub(/^[ \t\r]+|[ \t\r]+$/, "", s); return s } { l = $0; sub(/[#;].*$/, "", l); l = trim(l); if (l == "") next if (l ~ /^\[.*\]$/) { sec = tolower(trim(substr(l, 2, length(l) - 2))); next } p = index(l, "="); if (!p) next print sec, tolower(trim(substr(l, 1, p - 1))), trim(substr(l, p + 1)) }' "$1" } _awg_cv() { awk -v s="$2" -v k="$3" '$1 == s && $2 == k { $1 = ""; $2 = ""; sub(/^ /, ""); print; exit }' "$1"; } do_awg_create() { local name="$1" route="$2" fw="$3" f="$AWG_DIR/pending.conf" kv="$AWG_RUN/kv" priv pub psk addr a allowed ep host port keep mtu k v p zone w=0 age echo create > "$AWG_RUN/phase" [ -s "$f" ] || { echo "ОШИБКА: нет конфигурации"; return 1; } _awg_installed || { echo "ОШИБКА: сначала установите AmneziaWG"; return 1; } _awg_conf_kv "$f" > "$kv" priv="$(_awg_cv "$kv" interface privatekey)"; pub="$(_awg_cv "$kv" peer publickey)" addr="$(_awg_cv "$kv" interface address)"; ep="$(_awg_cv "$kv" peer endpoint)" [ -n "$priv" ] && [ -n "$pub" ] && [ -n "$addr" ] && [ -n "$ep" ] || { echo "ОШИБКА: в файле нет PrivateKey, Address, PublicKey или Endpoint"; return 1; } case "$ep" in \[*\]:*) host="${ep%]:*}"; host="${host#[}"; port="${ep##*]:}" ;; *) host="${ep%:*}"; port="${ep##*:}" ;; esac psk="$(_awg_cv "$kv" peer presharedkey)"; allowed="$(_awg_cv "$kv" peer allowedips)" keep="$(_awg_cv "$kv" peer persistentkeepalive)"; mtu="$(_awg_cv "$kv" interface mtu)" _awg_say "Создаём интерфейс $name" uci -q delete "network.$name" for p in $(uci -q -X show network | sed -n "s/^network\.\([^.=]*\)=amneziawg_$name\$/\1/p"); do uci -q delete "network.$p"; done uci set "network.$name=interface" uci set "network.$name.proto=amneziawg" uci set "network.$name.private_key=$priv" for a in $(echo "$addr" | tr ',' ' '); do case "$a" in */*) ;; *:*) a="$a/128" ;; *) a="$a/32" ;; esac uci add_list "network.$name.addresses=$a" done uci set "network.$name.mtu=${mtu:-1280}" _awg_iface_set "$f" "$name" p="$(uci add network "amneziawg_$name")" uci set "network.$p.description=$name" uci set "network.$p.public_key=$pub" [ -n "$psk" ] && uci set "network.$p.preshared_key=$psk" for a in $(echo "${allowed:-0.0.0.0/0, ::/0}" | tr ',' ' '); do uci add_list "network.$p.allowed_ips=$a"; done uci set "network.$p.endpoint_host=$host" uci set "network.$p.endpoint_port=${port:-51820}" uci set "network.$p.persistent_keepalive=${keep:-25}" uci set "network.$p.route_allowed_ips=$route" uci commit network grep -qxF "$name" "$AWG_DIR/owned" 2>/dev/null || echo "$name" >> "$AWG_DIR/owned" if [ "$fw" = 1 ]; then zone="$(echo "$name" | cut -c1-11)" _awg_say "Создаём зону firewall «$zone» — устройства сети смогут ходить в туннель" uci -q delete "firewall.zmawg_$name"; uci -q delete "firewall.zmawg_${name}_fwd" uci set "firewall.zmawg_$name=zone" uci set "firewall.zmawg_$name.name=$zone" uci add_list "firewall.zmawg_$name.network=$name" uci set "firewall.zmawg_$name.input=REJECT" uci set "firewall.zmawg_$name.output=ACCEPT" uci set "firewall.zmawg_$name.forward=REJECT" uci set "firewall.zmawg_$name.masq=1" uci set "firewall.zmawg_$name.mtu_fix=1" uci set "firewall.zmawg_${name}_fwd=forwarding" uci set "firewall.zmawg_${name}_fwd.src=$(_zm_lan_zone)" uci set "firewall.zmawg_${name}_fwd.dest=$zone" uci commit firewall /etc/init.d/firewall reload >/dev/null 2>&1 fi [ "$route" = 1 ] && echo "!! Весь трафик роутера пойдёт через $name. Упадёт туннель — пропадёт интернет" _awg_say "Поднимаем $name" ubus call network reload >/dev/null 2>&1 sleep 2 ubus call "network.interface.$name" up >/dev/null 2>&1 _awg_say "Ждём ответа сервера" printf 'Подождите ' while [ "$w" -lt 20 ]; do age="$(_awg_hs_age "$name")" [ -n "$age" ] && break [ -d "/sys/class/net/$name" ] || sleep 1 ping -I "$name" -c 1 -W 1 1.1.1.1 >/dev/null 2>&1 w=$((w + 1)) [ $((w % 5)) = 0 ] && printf '.' done echo rm -f "$f" "$kv" if [ -n "$age" ]; then _awg_say "Готово: $name работает" else echo "!! $name создан, но сервер не отвечает — проверьте точку входа" fi } do_awg_pick() { local i="$1" p got echo pick > "$AWG_RUN/phase" p="$(_awg_peer_sec "$i")" [ "$(uci -q get "network.$p.public_key")" = "$AWG_WARP_PEER" ] || { echo "ОШИБКА: подбор точки есть только для туннелей WARP"; return 1; } mkdir -p "$ST_DIR" "$ST_RUN" rm -f "$ST_STOP_FLAG" ubus call "network.interface.$i" up >/dev/null 2>&1 sleep 3 _awg_say "Подбираем точку входа для $i — это до пары минут" got="$(_st_warp_scan "$i" "$AWG_WARP_PEER" "" "")" || { echo "ОШИБКА: ни одна точка не ответила — провайдер может резать UDP к WARP"; return 1; } set -- $got _st_warp_link "$i" "$AWG_WARP_PEER" "$1" "$2" >/dev/null 2>&1 uci set "network.$p.endpoint_host=$1" uci set "network.$p.endpoint_port=$2" uci commit network _awg_say "Готово: $i → $1:$2 (сервер $3)" set -- $(_cf_meta "$i") [ $# -ge 4 ] && [ "$2" != - ] && _awg_say "Сайты видят страну: $2" return 0 } do_awg_regen() { local i="$1" p host port ep warp=0 route k v kk have=0 f="$AWG_DIR/pending.conf" rc echo gen > "$AWG_RUN/phase" mkdir -p "$AWG_RUN" "$AWG_DIR" p="$(_awg_peer_sec "$i")" [ "$(uci -q get "network.$p.public_key")" = "$AWG_WARP_PEER" ] && warp=1 host="$(uci -q get "network.$p.endpoint_host")"; port="$(uci -q get "network.$p.endpoint_port")" case "$host" in *:*) ep="[$host]:$port" ;; *) ep="$host:$port" ;; esac [ "$warp" = 1 ] && _awg_valid_ep "$ep" || ep="engage.cloudflareclient.com:4500" route="$(uci -q get "network.$p.route_allowed_ips")"; [ "$route" = 1 ] || route=0 G_PRIV=""; G_PEER=""; G_V4=""; G_V6="" _awg_say "Новые ключи WARP для $i" _awg_keys_get "$i" || { echo "ОШИБКА: ключи не получены — ни один источник не ответил"; return 1; } _awg_say "Ключи получены, адрес $G_V4" if [ "$warp" = 1 ]; then [ -n "$(_awg_uci_conf "$i")" ] && have=1 fi { echo "[Interface]" echo "PrivateKey = $G_PRIV" echo "Address = $G_V4${G_V6:+, $G_V6}" echo "MTU = $(uci -q get "network.$i.mtu" || echo 1280)" if [ "$have" = 1 ]; then _awg_uci_conf "$i" else AWG_NO_I1=0 if _awg_installed; then _st_awg_loaded || modprobe amneziawg >/dev/null 2>&1 _awg_try "$AWG_TEST_IF" "$G_PRIV" "$G_V4" "$ep" "$MIXOMO_AWG_I1" "$G_PEER" rc=$? _awg_try_down "$AWG_TEST_IF" [ "$rc" = 0 ] && _awg_say "Проверка: точка $ep отвечает" || _rb_warn "Точка $ep не ответила — после создания подберите точку входа" fi printf '%s\n' "Jc = $MIXOMO_AWG_JC" "Jmin = $MIXOMO_AWG_JMIN" "Jmax = $MIXOMO_AWG_JMAX" "S1 = $MIXOMO_AWG_S1" "S2 = $MIXOMO_AWG_S2" \ "H1 = $MIXOMO_AWG_H1" "H2 = $MIXOMO_AWG_H2" "H3 = $MIXOMO_AWG_H3" "H4 = $MIXOMO_AWG_H4" [ "$AWG_NO_I1" = 1 ] || echo "I1 = $MIXOMO_AWG_I1" fi echo "" echo "[Peer]" echo "PublicKey = $G_PEER" echo "AllowedIPs = 0.0.0.0/0, ::/0" echo "Endpoint = $ep" echo "PersistentKeepalive = 25" } > "$f" chmod 600 "$f" do_awg_create "$i" "$route" 0 } _awg_steer_n() { case "$1" in zmwarp) echo 1 ;; *) echo "${1#zmwarp}" ;; esac; } do_awg_steer_replace() { local i="$1" n c f="$AWG_DIR/pending.conf" kv="$AWG_RUN/kv" ep host port k v w=0 col echo replace > "$AWG_RUN/phase" if [ "$i" = "$ST_OWN_IF" ]; then mkdir -p "$ST_DIR" "$ST_RUN" if _st_warp_own; then mv -f "$f" "$ST_DIR/warp.pending" do_steer_warp_own return fi mv -f "$f" "$ST_WARP_OWN"; chmod 600 "$ST_WARP_OWN" _st_own_keep ( ST_WARP_IF="$ST_OWN_IF"; _st_warp_own_iface "$ST_WARP_OWN" ) || return 1 _st_warp_park "$ST_OWN_IF" _awg_say "Готово: конфиг сохранён. Он заработает, когда в Steer выберете «Свой конфиг»" return 0 fi if _st_warp_own; then echo "ОШИБКА: сейчас у Steer работает свой конфиг ($ST_OWN_IF) — автоматические туннели меняются, когда они включены" return 1 fi n="$(_awg_steer_n "$i")"; c="$(_st_wconf "$n")" _awg_conf_kv "$f" > "$kv" [ -n "$(_awg_cv "$kv" interface privatekey)" ] && [ -n "$(_awg_cv "$kv" peer publickey)" ] && [ -n "$(_awg_cv "$kv" interface address)" ] || { rm -f "$f" "$kv"; echo "ОШИБКА: в конфиге нет PrivateKey, Address или PublicKey"; return 1; } ep="$(_awg_cv "$kv" peer endpoint)" case "$ep" in \[*\]:*) host="${ep%]:*}"; host="${host#[}"; port="${ep##*]:}" ;; ?*:*) host="${ep%:*}"; port="${ep##*:}" ;; esac [ -n "$host" ] || host="$(uci -q get "network.${i}_peer.endpoint_host")" [ -n "$port" ] || port="$(uci -q get "network.${i}_peer.endpoint_port")" mkdir -p "$ST_DIR" cp "$f" "$c"; chmod 600 "$c" _awg_say "Записываем конфиг в туннель Steer $i" _st_with "$n" _st_warp_iface_write "${host:-162.159.192.1}" "${port:-2408}" _awg_iface_set "$f" "$i" uci -q delete "network.$i.auto" uci commit network rm -f "$f" "$kv" ubus call network reload >/dev/null 2>&1 sleep 2 ubus call "network.interface.$i" up >/dev/null 2>&1 while [ "$w" -lt 20 ]; do _st_warp_alive_if "$i" && break [ -d "/sys/class/net/$i" ] || sleep 1 ping -I "$i" -c 1 -W 1 1.1.1.1 >/dev/null 2>&1 w=$((w + 1)) done if _st_warp_alive_if "$i"; then col="$(_st_colo_of "$i")" if [ -s "$ST_WARP_UP" ]; then { while read -r x cc; do [ "$x" = "$i" ] || echo "$x $cc"; done < "$ST_WARP_UP"; echo "$i ${col:-?}"; } > "$ST_WARP_UP.tmp" mv "$ST_WARP_UP.tmp" "$ST_WARP_UP" fi _st_kick >/dev/null 2>&1 _awg_say "Готово: $i работает${col:+, сервер $col}" else echo "!! $i: конфиг записан, но сервер не отвечает — проверьте точку входа или нажмите «Новый WARP»" fi } do_awg_steer_regen() { echo regen > "$AWG_RUN/phase" _job_alive steer && { echo "ОШИБКА: на странице Steer идёт операция — дождитесь её окончания"; return 1; } _st_warp_fix "$(_awg_steer_n "$1")" keys || return 1 _st_kick >/dev/null 2>&1 _awg_say "Готово: у $1 новые ключи WARP" } _awg_export() { local i="$1" p a k v p="$(_awg_peer_sec "$i")" echo "[Interface]" echo "PrivateKey = $(uci -q get "network.$i.private_key")" echo "Address = $(uci -q get "network.$i.addresses" | sed 's/ /, /g')" v="$(uci -q get "network.$i.mtu")"; [ -n "$v" ] && echo "MTU = $v" _awg_uci_conf "$i" echo "" echo "[Peer]" echo "PublicKey = $(uci -q get "network.$p.public_key")" v="$(uci -q get "network.$p.preshared_key")"; [ -n "$v" ] && echo "PresharedKey = $v" echo "AllowedIPs = $(uci -q get "network.$p.allowed_ips" | sed 's/ /, /g')" a="$(uci -q get "network.$p.endpoint_host")" case "$a" in *:*) a="[$a]" ;; esac echo "Endpoint = $a:$(uci -q get "network.$p.endpoint_port")" v="$(uci -q get "network.$p.persistent_keepalive")"; [ -n "$v" ] && echo "PersistentKeepalive = $v" } _awg_delete() { local i="$1" p z ifdown "$i" >/dev/null 2>&1 for p in $(uci -q -X show network | sed -n "s/^network\.\([^.=]*\)=amneziawg_$i\$/\1/p"); do uci -q delete "network.$p"; done uci -q delete "network.$i" uci commit network if [ "$(uci -q get "firewall.zmawg_$i")" = zone ]; then uci -q delete "firewall.zmawg_$i"; uci -q delete "firewall.zmawg_${i}_fwd" else for z in $(uci -q -X show firewall | sed -n "s/^firewall\.\([^.=]*\)=zone\$/\1/p"); do case " $(uci -q get "firewall.$z.network") " in *" $i "*) uci -q del_list "firewall.$z.network=$i" ;; esac done fi uci commit firewall /etc/init.d/firewall reload >/dev/null 2>&1 ubus call network reload >/dev/null 2>&1 sed -i "/^$i\$/d" "$AWG_DIR/owned" 2>/dev/null } _awg_check_name() { printf '%s' "$1" | grep -Eq '^[a-z][a-z0-9_]{0,10}$' || { echo '{"error":"имя: латиница в нижнем регистре, цифры и _, начинается с буквы, до 11 символов"}'; return 1; } _awg_is_steer "$1" && { echo '{"error":"имена zmwarp заняты туннелями Steer"}'; return 1; } case "$1" in lan|wan|wan6|loopback) echo '{"error":"это имя занято системным интерфейсом"}'; return 1 ;; esac return 0 } awg_action() { local action="$1" mode="$2" i name route fw conf ep case "$action" in install|update|remove|gen|create|pick|replace|regen) _job_alive awg && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } _job_alive steer && { echo '{"error":"на странице Steer идёт операция — дождитесь её окончания"}'; return 1; } ;; esac case "$action" in install) job_start awg do_awg_install ;; update) job_start awg do_awg_install update ;; remove) _st_warp_on && { echo '{"error":"AmneziaWG нужен WARP в Steer — сначала удалите Steer на его странице"}'; return 1; } ep="" for i in $(_awg_ifaces); do if _awg_is_steer "$i"; then [ -s "$ST_OWNED" ] && { echo '{"error":"туннели WARP для Steer ещё настроены — сначала удалите Steer на его странице"}'; return 1; } else grep -qxF "$i" "$AWG_DIR/owned" 2>/dev/null || ep="$ep $i" fi done [ -n "$ep" ] && { printf '{"error":"%s"}\n' "$(esc "интерфейсы${ep} созданы не панелью — удалите их сами (Сеть → Интерфейсы), затем удалите AmneziaWG")"; return 1; } job_start awg do_awg_remove ;; gen) _job_running mixomo_warp && { echo '{"error":"на вкладке Mixomo → WARP идёт генерация — дождитесь её окончания"}'; return 1; } i="${mode%%|*}"; ep="${mode#*|}" case "$i" in std) [ "$ep" = default ] && ep="engage.cloudflareclient.com:4500" [ "$ep" = auto ] || _awg_valid_ep "$ep" || { echo '{"error":"endpoint: адрес:порт, например 162.159.192.1:2408"}'; return 1; } job_start awg do_awg_gen std "$ep" ;; check) _awg_installed || { echo '{"error":"для проверки связи нужен AmneziaWG — установите его выше"}'; return 1; } job_start awg do_awg_gen check ;; *) echo '{"error":"неизвестный способ"}'; return 1 ;; esac ;; conf_get) mixomo_warp_status ;; conf_set) mixomo_warp_config_set "$mode" ;; create) name="${mode%%|*}"; mode="${mode#*|}" route="${mode%%|*}"; mode="${mode#*|}" fw="${mode%%|*}"; conf="${mode#*|}" _awg_check_name "$name" || return 1 case "$route$fw" in [01][01]) ;; *) echo '{"error":"неверные параметры"}'; return 1 ;; esac if [ -n "$(uci -q get "network.$name")" ] && [ "$(uci -q get "network.$name.proto")" != amneziawg ]; then echo '{"error":"интерфейс с таким именем уже есть и это не AmneziaWG"}'; return 1 fi printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[interface\]' || { echo '{"error":"в конфигурации нет секции [Interface]"}'; return 1; } printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[peer\]' || { echo '{"error":"в конфигурации нет секции [Peer]"}'; return 1; } mkdir -p "$AWG_DIR" printf '%s\n' "$conf" | tr -d '\r' > "$AWG_DIR/pending.conf" chmod 600 "$AWG_DIR/pending.conf" job_start awg do_awg_create "$name" "$route" "$fw" ;; replace) i="${mode%%|*}"; conf="${mode#*|}" [ "$(uci -q get "network.$i.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } if _awg_is_steer "$i"; then printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[peer\]' || { echo '{"error":"в конфигурации нет секции [Peer]"}'; return 1; } mkdir -p "$AWG_DIR" printf '%s\n' "$conf" | tr -d '\r' > "$AWG_DIR/pending.conf" chmod 600 "$AWG_DIR/pending.conf" job_start awg do_awg_steer_replace "$i" return fi printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[interface\]' || { echo '{"error":"в конфигурации нет секции [Interface]"}'; return 1; } printf '%s\n' "$conf" | grep -qi '^[[:space:]]*\[peer\]' || { echo '{"error":"в конфигурации нет секции [Peer]"}'; return 1; } route="$(uci -q get "network.$(_awg_peer_sec "$i").route_allowed_ips")"; [ "$route" = 1 ] || route=0 mkdir -p "$AWG_DIR" printf '%s\n' "$conf" | tr -d '\r' > "$AWG_DIR/pending.conf" chmod 600 "$AWG_DIR/pending.conf" job_start awg do_awg_create "$i" "$route" 0 ;; regen) [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } if _awg_is_steer "$mode"; then job_start awg do_awg_steer_regen "$mode"; return; fi job_start awg do_awg_regen "$mode" ;; pick) [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } _awg_is_steer "$mode" && { echo '{"error":"точки туннелей Steer подбираются на странице Steer"}'; return 1; } job_start awg do_awg_pick "$mode" ;; endpoint) i="${mode%%|*}"; ep="${mode#*|}" [ "$(uci -q get "network.$i.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } _awg_is_steer "$i" && { echo '{"error":"точки туннелей Steer меняются на странице Steer"}'; return 1; } _awg_valid_ep "$ep" || { echo '{"error":"точка входа: адрес:порт, например 162.159.192.1:2408"}'; return 1; } name="$(_awg_peer_sec "$i")" case "$ep" in \[*\]:*) route="${ep%]:*}"; route="${route#[}" ;; *) route="${ep%:*}" ;; esac uci set "network.$name.endpoint_host=$route" uci set "network.$name.endpoint_port=${ep##*:}" uci commit network ifup "$i" >/dev/null 2>&1 printf '{"ok":true}\n' ;; restart|up|down) [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } case "$action" in restart) ifup "$mode" >/dev/null 2>&1 ;; up) uci -q delete "network.$mode.auto"; uci commit network; ifup "$mode" >/dev/null 2>&1 ;; down) _awg_is_steer "$mode" && { echo '{"error":"туннели Steer выключаются на странице Steer"}'; return 1; } uci set "network.$mode.auto=0"; uci commit network; ifdown "$mode" >/dev/null 2>&1 ;; esac printf '{"ok":true}\n' ;; test) [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } local tr colo ip warp age seen="" city="" loss="" rtt="" torn="" age="$(_awg_hs_age "$mode")" tr="$(curl -s --interface "$mode" --connect-timeout 5 --max-time 10 https://www.cloudflare.com/cdn-cgi/trace 2>/dev/null)" [ -n "$tr" ] || tr="$(curl -s --interface "$mode" --connect-timeout 5 --max-time 10 https://1.1.1.1/cdn-cgi/trace 2>/dev/null)" colo="$(printf '%s\n' "$tr" | sed -n 's/^colo=//p')"; ip="$(printf '%s\n' "$tr" | sed -n 's/^ip=//p')"; warp="$(printf '%s\n' "$tr" | sed -n 's/^warp=//p')" if [ -z "$ip" ]; then tr="$(curl -s --interface "$mode" --connect-timeout 5 --max-time 10 https://ipinfo.io/json 2>/dev/null)" ip="$(printf '%s' "$tr" | jsonfilter -e '@.ip' 2>/dev/null)" [ -n "$ip" ] && seen="$(printf '%s' "$tr" | jsonfilter -e '@.country' 2>/dev/null)" fi if [ -z "$ip" ]; then ip="$(curl -s --interface "$mode" --connect-timeout 5 --max-time 10 https://api.ipify.org 2>/dev/null | grep -Eo '^[0-9a-fA-F.:]{3,45}$' | head -n1)" fi if [ -n "$ip" ]; then set -- $(_cf_meta "$mode") if [ $# -ge 4 ]; then [ -n "$colo" ] || colo="$1" [ "$2" != - ] && [ -z "$seen" ] && seen="$2" shift 3; [ "$*" != - ] && city="$*" fi set -- $(_st_warp_probe "$mode"); loss="$1"; rtt="$2"; torn="$3" [ "$loss" -ge 100 ] 2>/dev/null && { loss=""; rtt=""; torn=""; } fi printf '{"ok":%s,"hs_age":"%s","colo":"%s","city":"%s","seen":"%s","ip":"%s","warp":"%s","loss":"%s","rtt":"%s","torn":%s}\n' "$([ -n "$ip" ] && echo true || echo false)" "$age" "$(esc "$colo")" "$(esc "$city")" "$(esc "$seen")" "$(esc "$ip")" "$(esc "$warp")" "$loss" "$rtt" "$([ "$torn" = 1 ] && echo true || echo false)" ;; export) [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } printf '{"content":"%s"}\n' "$(esc_ml "$(_awg_export "$mode")")" ;; delete) [ "$(uci -q get "network.$mode.proto")" = amneziawg ] || { echo '{"error":"нет такого интерфейса"}'; return 1; } _awg_is_steer "$mode" && { echo '{"error":"туннели Steer удаляются вместе со Steer на его странице"}'; return 1; } _awg_delete "$mode" printf '{"ok":true}\n' ;; mihomo) [ -s "$MIXOMO_WARP_CONF" ] || { echo '{"error":"сначала получите WARP.conf"}'; return 1; } mixomo_warp_integrate_action ;; *) echo '{"error":"неизвестное действие"}' ;; esac } FK_REPO="yandexru45/netshift" FK_PIN="0.9.9" FK_MARK="/usr/lib/netshift/zm-managed" FK_UC="/opt/zapret-manager-luci/netshift.uc" FK_SAVE="$ZM_STATE_DIR/forkop.config" FK_SB_OWN="$ZM_STATE_DIR/netshift.singbox" FK_DEPS="ca-bundle kmod-inet-diag kmod-tun curl jq ucode ucode-mod-fs ucode-mod-uci kmod-nft-tproxy coreutils-base64 bind-dig ip-full" FK_NFT="NetShiftTable" FK_MIRRORS='mirror\.(infotechtg|51343)\.ru|fold8\.ru' _fk_say() { echo "==> $*"; } _fk_installed() { [ -x /usr/bin/netshift ] && [ -f /usr/lib/netshift/constants.sh ]; } _fk_legacy() { [ -x /usr/bin/forkop ] || [ -d /usr/lib/forkop ] || [ -x /etc/init.d/forkop ]; } _fk_present() { _fk_installed || _fk_legacy; } _fk_version() { sed -n 's/^NETSHIFT_VERSION="\([^"]*\)".*/\1/p' /usr/lib/netshift/constants.sh 2>/dev/null | head -n1; } _fk_enabled() { ls /etc/rc.d/S*netshift >/dev/null 2>&1; } _fk_up() { nft list table inet "$FK_NFT" >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1; } _fk_sb_ver() { command -v sing-box >/dev/null 2>&1 && sing-box version 2>/dev/null | head -n1 | awk '{print $NF}'; } _fk_sb_pkg() { local p; for p in sing-box-tiny sing-box sing-box-extended; do _pkg_is_installed "$p" && { echo "$p"; return 0; }; done; return 1; } _fk_sb_var() { local v v="$(_fk_sb_ver)" [ -n "$v" ] || { echo ""; return 0; } case "$v" in *-lite*) echo lite; return 0 ;; *extended*) echo extended; return 0 ;; esac [ "$(head -c 2 /usr/bin/sing-box 2>/dev/null)" = "#!" ] && [ -f /usr/libexec/sing-box-core ] && { echo lite; return 0; } case "$(_fk_sb_pkg)" in sing-box-tiny) echo tiny ;; sing-box) echo stable ;; sing-box-extended) echo extended ;; *) echo manual ;; esac } _fk_sb_name() { case "$1" in tiny) echo облегчённый ;; stable) echo обычный ;; extended) echo расширенный ;; lite|compact) echo "расширенный облегчённый" ;; manual) echo "установлен вручную" ;; *) echo "$1" ;; esac; } _fk_sbx_latest() { curl -Ls --connect-timeout 5 --max-time 10 -o /dev/null -w '%{url_effective}' "https://github.com/shtorm-7/sing-box-extended/releases/latest" 2>/dev/null | sed -n 's#.*/tag/[vV]\{0,1\}##p' | head -n1 } _fk_foreign() { _pkg_is_installed netshift || _pkg_is_installed luci-app-netshift; } _fk_uc() { command -v ucode >/dev/null 2>&1 && [ -f "$FK_UC" ] || { echo '{"error":"Forkozz не установлен"}'; return 1; }; ucode "$FK_UC" "$@"; } _fk_ucj() { local out rc ef="$JOBS_DIR/fk-uc.$$.err" mkdir -p "$JOBS_DIR" out="$(_fk_uc "$@" 2>"$ef")"; rc=$? case "$out" in '{'*|'['*) rm -f "$ef"; printf '%s\n' "$out" ;; *) [ -n "$out" ] || out="$(cat "$ef" 2>/dev/null)"; rm -f "$ef" printf '{"error":"%s"}\n' "$(esc "Forkozz не ответил: $(printf '%s' "${out:-пустой ответ}" | head -n 3 | tr '\t' ' ')")"; return 1 ;; esac return $rc } _fk_latest_fetch() { local v v="$(_ver_gh_latest "$FK_REPO")" echo "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || v="$(curl -sI -o /dev/null -w '%{redirect_url}' --connect-timeout 5 --max-time 15 "https://github.com/$FK_REPO/releases/latest" 2>/dev/null | sed -n 's#.*/releases/tag/v\{0,1\}##p')" echo "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' && echo "$v" } _fk_latest() { _zm_cached netshift _fk_latest_fetch; } _fk_blocker() { _pkg_is_installed https-dns-proxy && { echo doh; return; } { _pkg_is_installed podkop || _pkg_is_installed podkop-plus || [ -x /etc/init.d/podkop ] || [ -x /etc/init.d/podkop-plus ]; } && { echo podkop; return; } { _pkg_is_installed luci-app-passwall || _pkg_is_installed luci-app-passwall2 || [ -x /etc/init.d/passwall ] || [ -x /etc/init.d/passwall2 ]; } && { echo passwall; return; } { _pkg_is_installed nextdns || [ -x /etc/init.d/nextdns ]; } && { echo nextdns; return; } [ -x "$MIHOMO_BIN" ] && { echo mixomo; return; } _st_installed 2>/dev/null && { echo steer; return; } echo "" } _fk_blocker_text() { case "$1" in doh) echo "Стоит DNS over HTTPS — Forkozz шифрует DNS сам. Удалите DoH на его вкладке." ;; podkop) echo "Стоит Podkop — он тоже перехватывает трафик. Удалите его." ;; passwall) echo "Стоит PassWall — он тоже перехватывает трафик. Удалите его." ;; nextdns) echo "Стоит NextDNS — он тоже перехватывает DNS. Удалите его." ;; mixomo) echo "Стоит Mixomo — он тоже перехватывает трафик и DNS. Удалите его на вкладке Mixomo." ;; steer) echo "Стоит Steer — он тоже направляет сервисы в туннель. Удалите его на вкладке Steer." ;; *) echo "$1" ;; esac } _fk_warn() { local w="" [ -x /usr/bin/bytetube ] && /etc/init.d/bytetube enabled 2>/dev/null && w="$w bytetube" echo $w } _fk_health() { if ! _fk_installed && _fk_legacy; then ls /etc/rc.d/S*forkop >/dev/null 2>&1 || { echo 5; return; } nft list table inet ForkopTable >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1 && echo 1 || echo 2 return fi _fk_installed || { echo 0; return; } _fk_enabled || { echo 5; return; } _fk_up && echo 1 || echo 2 } forkop_status() { local inst=false ver="" latest="" newer=false en=false up=false sb sbp="" blk busy=false warn="" x sbu="" legacy=false _fk_installed && { inst=true; ver="$(_fk_version)"; } [ "$inst" = false ] && _fk_legacy && legacy=true latest="$(_fk_latest)" [ -n "$ver" ] && [ -n "$latest" ] && _st_ver_lt "$ver" "$latest" && newer=true _fk_enabled && en=true _fk_up && up=true if [ "$legacy" = true ]; then ls /etc/rc.d/S*forkop >/dev/null 2>&1 && en=true nft list table inet ForkopTable >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1 && up=true fi sb="$(_fk_sb_ver)" sbp="$(_fk_sb_var)" [ "$inst" = false ] && [ -n "$sb" ] && sbu="$(_fk_sb_users)" blk="$(_fk_blocker)" _job_running forkop && busy=true for x in $(_fk_warn); do warn="$warn${warn:+,}\"$x\""; done printf '{"installed":%s,"legacy":%s,"version":"%s","latest":"%s","newer":%s,"enabled":%s,"running":%s,"managed":%s,"foreign":%s,"singbox":"%s","singbox_pkg":"%s","singbox_users":"%s","blocker":"%s","blocker_text":"%s","warn":[%s],"busy":%s,"lan_ip":"%s","saved":%s,"watch":%s,"hosts_extra":%s}\n' \ "$inst" "$legacy" "$(esc "$ver")" "$(esc "$latest")" "$newer" "$en" "$up" \ "$([ -f "$FK_MARK" ] && echo true || echo false)" "$(_fk_foreign && echo true || echo false)" \ "$(esc "$sb")" "$(esc "$sbp")" "$(esc "$sbu")" "$(esc "$blk")" "$(esc "$(_fk_blocker_text "$blk")")" "$warn" "$busy" \ "$(esc "$(_zm_lan_ip)")" "$([ -s "$FK_SAVE" ] && echo true || echo false)" \ "$(awk '{print $1 + 0; exit}' "$ZM_STATE_DIR/fk.watch" 2>/dev/null || echo 0)" "$(_hosts_extra)" } _fk_feeds_official() { local f b ch=0 for f in /etc/opkg/distfeeds.conf /etc/opkg/customfeeds.conf /etc/apk/repositories /etc/apk/repositories.d/*.list; do [ -f "$f" ] || continue case "$f" in */repositories.d/forkop.list) rm -f "$f"; ch=1; continue ;; esac grep -qE "$FK_MIRRORS" "$f" || continue b="$f.pre-forkop-mirror" if [ -f "$b" ] && ! grep -qE "$FK_MIRRORS" "$b"; then cp "$b" "$f" elif [ -f "/rom$f" ] && ! grep -qE "$FK_MIRRORS" "/rom$f"; then cp "/rom$f" "$f" else sed -i -E 's#https?://mirror\.(infotechtg|51343)\.ru/openwrt/releases/#https://downloads.openwrt.org/releases/#g' "$f"; fi sed -i -E "/$FK_MIRRORS/d" "$f" rm -f "$b" ch=1 _fk_say "Вернули официальный репозиторий пакетов: $f" done [ -f /etc/apk/keys/forkop-mirror.pem ] && { rm -f /etc/apk/keys/forkop-mirror.pem; ch=1; } [ "$ch" = 1 ] && return 0 return 1 } # Old Forkop configs may point lists at third-party mirrors: put the official sources back before importing. _fk_legacy_conf_fix() { sed -i -E \ -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/allow-domains/#https://raw.githubusercontent.com/itdoginfo/allow-domains/main/#g' \ -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/b4geoip-forkop/#https://raw.githubusercontent.com/Greeg0ry/b4geoip-forkop/main/#g' \ -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/rulesets/community/#https://github.com/itdoginfo/allow-domains/releases/latest/download/#g' \ -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/rulesets/adlist\.srs#https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs#g' \ -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/rulesets/supercell\.srs#https://raw.githubusercontent.com/ushan0v/sing-box-supercell-ruleset/main/supercell.srs#g' \ -e 's#https?://mirror\.(infotechtg|51343)\.ru/forkop/lists/rulesets/github\.srs#https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/github.srs#g' "$1" } _fk_restore_old() { [ -d /usr/lib/netshift.zm-old ] || return 0 rm -rf /usr/lib/netshift mv /usr/lib/netshift.zm-old/.bin /usr/bin/netshift 2>/dev/null mv /usr/lib/netshift.zm-old/.init /etc/init.d/netshift 2>/dev/null mv /usr/lib/netshift.zm-old /usr/lib/netshift _fk_say "Вернули прежнюю версию Forkozz" } # Swaps the OpenWrt sing-box package: tiny or stable. Keeps a copy of the old binary and returns it on failure. _fk_sb_pkg_switch() { local want="$1" pkg other tmp="$JOBS_DIR/fk-sb-bak" oldpkg case "$want" in tiny) pkg=sing-box-tiny; other=sing-box ;; *) pkg=sing-box; other=sing-box-tiny ;; esac oldpkg="$(_fk_sb_pkg)" rm -rf "$tmp"; mkdir -p "$tmp" [ -e /usr/bin/sing-box ] && cp -p /usr/bin/sing-box "$tmp/sing-box" 2>/dev/null _fk_say "Ставим пакет $pkg из репозитория OpenWrt" _pkg_is_installed "$other" && $DELETE "$other" >/dev/null 2>&1 if _pkg_is_installed "$pkg"; then if command -v apk >/dev/null 2>&1; then apk fix --reinstall "$pkg" >/dev/null 2>&1 || apk add --force-overwrite "$pkg" >/dev/null 2>&1 else opkg install --force-reinstall "$pkg" >/dev/null 2>&1; fi else $INSTALL "$pkg" >/dev/null 2>&1 fi if _pkg_is_installed "$pkg" && [ -x /usr/bin/sing-box ] && sing-box version >/dev/null 2>&1; then case "$(_fk_sb_ver)" in *extended*) ;; *) rm -f /usr/libexec/sing-box-core /etc/netshift/core-version.cache /etc/sing-box-version.cache /usr/lib/libcronet.so 2>/dev/null rm -rf "$tmp" return 0 ;; esac fi echo " ! пакет $pkg не встал — возвращаем прежний sing-box" [ -n "$oldpkg" ] && [ "$oldpkg" != "$pkg" ] && ! _pkg_is_installed "$oldpkg" && $INSTALL "$oldpkg" >/dev/null 2>&1 [ -s "$tmp/sing-box" ] && cp -p "$tmp/sing-box" /usr/bin/sing-box 2>/dev/null rm -rf "$tmp" return 1 } _fk_sb_install() { local v v="$(_fk_sb_ver)" if [ -n "$v" ] && ! _st_ver_lt "$v" 1.12.0 && [ -x /etc/init.d/sing-box ]; then _fk_say "sing-box $v уже установлен ($(_fk_sb_name "$(_fk_sb_var)")) — оставляем его" return 0 fi [ -n "$v" ] && _fk_say "sing-box $v слишком старый или без службы — заменяем" if [ "$1" != stable ] && _fk_sb_pkg_switch tiny; then : else [ "$1" != stable ] && _fk_say "Облегчённого sing-box для этого роутера нет — ставим обычный" _fk_sb_pkg_switch stable || { echo "ОШИБКА: sing-box не установился"; return 1; } fi _fk_sb_pkg > "$FK_SB_OWN" _fk_say "sing-box $(_fk_sb_ver) установлен ($(_fk_sb_name "$(_fk_sb_var)"))" } _fk_sb_users() { local p u="" for p in podkop podkop-plus luci-app-passwall luci-app-passwall2 luci-app-homeproxy homeproxy luci-app-ssr-plus luci-app-momo momo; do _pkg_is_installed "$p" && u="$u${u:+, }$p" done for p in podkop podkop-plus passwall passwall2 homeproxy momo; do [ -x "/etc/init.d/$p" ] && ! echo ", $u," | grep -q ", $p," && u="$u${u:+, }$p" done echo "$u" } _fk_sb_purge() { local p had="" v var u u="$(_fk_sb_users)" if [ -n "$u" ]; then _fk_say "sing-box оставляем — он нужен: $u" return 0 fi v="$(_fk_sb_ver)" var="$(_fk_sb_var)" for p in sing-box-extended sing-box sing-box-tiny; do _pkg_is_installed "$p" && had="$had $p"; done if [ -z "$v" ] && [ -z "$had" ] && [ ! -e /usr/bin/sing-box ]; then _fk_say "sing-box не установлен — пропускаем" return 0 fi _fk_say "Удаляем sing-box${v:+ $v}${var:+ ($(_fk_sb_name "$var"))}" if [ -x /etc/init.d/sing-box ]; then echo " → Останавливаем службу sing-box и убираем её из автозапуска" /etc/init.d/sing-box stop >/dev/null 2>&1 /etc/init.d/sing-box disable >/dev/null 2>&1 fi if pidof sing-box >/dev/null 2>&1; then echo " → Завершаем процессы sing-box" killall sing-box >/dev/null 2>&1 sleep 1 killall -9 sing-box >/dev/null 2>&1 fi for p in $had; do _zm_pkg_purge "$p"; done if [ -e /usr/bin/sing-box ]; then echo " → Удаляем оставшийся файл /usr/bin/sing-box" rm -f /usr/bin/sing-box fi echo " → Удаляем настройки и кэш sing-box" rm -rf /etc/sing-box /etc/config/sing-box /var/run/sing-box /tmp/sing-box /usr/share/sing-box rm -f /usr/lib/libcronet.so /usr/libexec/sing-box-core /etc/sing-box-version.cache /usr/lib/libcronet.so.forkop-backup.* /usr/bin/sing-box.forkop-backup.* 2>/dev/null [ -e /etc/init.d/sing-box ] && ! _fk_sb_pkg >/dev/null && rm -f /etc/init.d/sing-box rm -f /etc/rc.d/*sing-box "$FK_SB_OWN" if [ -z "$(_fk_sb_ver)" ] && [ ! -e /usr/bin/sing-box ]; then echo " ✓ sing-box удалён" return 0 fi echo "!! sing-box удалить до конца не получилось" return 1 } _fk_dnsmasq_clean() { local s v o x ch=0 left pre for s in $(uci -q show dhcp | sed -n "s/^dhcp\.\([^.=]*\)=dnsmasq\$/\1/p"); do for pre in netshift forkop; do for o in noresolv cachesize; do if uci -q get "dhcp.$s.${pre}_$o" >/dev/null; then v="$(uci -q get "dhcp.$s.${pre}_$o")" if [ -n "$v" ]; then uci -q set "dhcp.$s.$o=$v"; else uci -q delete "dhcp.$s.$o"; fi uci -q delete "dhcp.$s.${pre}_$o" ch=1 fi done if uci -q get "dhcp.$s.${pre}_server" >/dev/null; then v="$(uci -q get "dhcp.$s.${pre}_server")" uci -q delete "dhcp.$s.server" for x in $v; do uci -q add_list "dhcp.$s.server=$x"; done uci -q delete "dhcp.$s.${pre}_server" ch=1 fi done uci -q get "dhcp.$s.netshift_configured" >/dev/null && { uci -q delete "dhcp.$s.netshift_configured"; ch=1; } for v in $(uci -q get "dhcp.$s.server"); do case "$v" in 127.0.0.4[0-9]|127.0.0.4[0-9]#*) uci -q del_list "dhcp.$s.server=$v"; ch=1 ;; esac done left="$(uci -q get "dhcp.$s.server")" if [ -z "$left" ] && [ "$(uci -q get "dhcp.$s.noresolv")" = 1 ]; then uci -q delete "dhcp.$s.noresolv" ch=1 fi [ "$(uci -q get "dhcp.$s.cachesize")" = 0 ] && { uci -q delete "dhcp.$s.cachesize"; ch=1; } done if [ "$ch" = 1 ]; then uci -q commit dhcp echo " ✓ Настройки DNS роутера возвращены" fi return 0 } _fk_ip_clean() { local f l pr t for t in netshift forkop; do for f in "" -6; do ip $f rule show 2>/dev/null | grep -i "lookup $t" | while IFS= read -r l; do pr="${l%%:*}" ip $f rule del pref "$pr" >/dev/null 2>&1 done ip $f route flush table "$t" >/dev/null 2>&1 done grep -qi "[[:space:]]$t\$" /etc/iproute2/rt_tables 2>/dev/null && sed -i "/[[:space:]]$t\$/d" /etc/iproute2/rt_tables done ip rule show 2>/dev/null | grep -qiE 'netshift|forkop' || echo " ✓ Правила маршрутизации Forkozz убраны" return 0 } _fk_lists_report() { local c=/etc/sing-box/config.json n=0 i=0 t ty u pth sz lines tbl sets set cnt shown=0 if [ -s "$c" ]; then n="$(jsonfilter -i "$c" -e '@.route.rule_set[*].tag' 2>/dev/null | wc -l)" _fk_say "Наборов правил в sing-box: $n" while [ "$i" -lt "$n" ] && [ "$i" -lt 80 ]; do t="$(jsonfilter -i "$c" -e "@.route.rule_set[$i].tag" 2>/dev/null)" ty="$(jsonfilter -i "$c" -e "@.route.rule_set[$i].type" 2>/dev/null)" if [ "$ty" = remote ]; then u="$(jsonfilter -i "$c" -e "@.route.rule_set[$i].url" 2>/dev/null)" echo " · $t — с ${u#https://}" else pth="$(jsonfilter -i "$c" -e "@.route.rule_set[$i].path" 2>/dev/null)" if [ -s "$pth" ]; then sz="$(ls -l "$pth" 2>/dev/null | awk '{ s = $5; if (s >= 1048576) printf "%.1f МБ", s / 1048576; else printf "%d КБ", (s + 1023) / 1024 }')" case "$pth" in *.json) lines="$(grep -o '"[^"]*"' "$pth" 2>/dev/null | grep -c '\.')" ; echo " ✓ $t — $sz, записей ≈$lines" ;; *) echo " ✓ $t — $sz" ;; esac else echo " ✗ $t — файла ещё нет ($pth)" fi fi i=$((i + 1)) done [ "$n" -gt 80 ] && echo " … и ещё $((n - 80))" fi tbl="$(nft list table inet "$FK_NFT" 2>/dev/null)" if [ -n "$tbl" ]; then sets="$(printf '%s\n' "$tbl" | awk '$1 == "set" { print $2 }')" for set in $sets; do cnt="$(nft list set inet "$FK_NFT" "$set" 2>/dev/null | sed -n '/elements = {/,/}/p' | tr ',' '\n' | grep -c '[0-9a-f]')" [ "$cnt" -gt 0 ] 2>/dev/null || continue [ "$shown" = 0 ] && { _fk_say "Адреса в правилах файрвола (nftables):"; shown=1; } echo " · $set — $cnt" done fi return 0 } _fk_lists_errors() { local e e="$(logread 2>/dev/null | tail -n 300 | grep -iE 'netshift|sing-box' | grep -iE 'rule-set|rule_set|ruleset|list|download' | grep -iE 'error|fail|timeout|refused|not found|ошиб' | tail -n 6 | sed 's/^[^]]*\]: //')" [ -n "$e" ] || return 0 echo "!! В журнале есть ошибки загрузки списков:" printf '%s\n' "$e" | sed 's/^/ /' return 0 } _fk_check_live() { local j if pidof sing-box >/dev/null 2>&1; then echo " ✓ sing-box работает"; else echo " ✗ sing-box не запущен"; fi if nft list table inet "$FK_NFT" >/dev/null 2>&1; then echo " ✓ Правила перехвата трафика на месте"; else echo " ✗ Правил перехвата трафика нет"; fi j="$(_zm_run 20 /usr/bin/netshift check_fakeip 2>/dev/null)" case "$j" in *'"fakeip": true'*|*'"fakeip":true'*) echo " ✓ DNS отдаёт адреса Forkozz — выбранные сервисы идут через туннель" ;; *) echo " ! Проверочный домен не получил адрес Forkozz — DNS может ещё прогреваться" ;; esac } _fk_resume() { [ -x /etc/init.d/netshift ] || return 0 [ "$1" = 1 ] && /etc/init.d/netshift enable >/dev/null 2>&1 [ "$2" = 1 ] && { _fk_say "Запускаем Forkozz обратно"; /etc/init.d/netshift start >/dev/null 2>&1; } return 0 } _fk_legacy_resume() { [ -x /etc/init.d/forkop ] || return 0 [ "$1" = 1 ] && /etc/init.d/forkop enable >/dev/null 2>&1 [ "$2" = 1 ] && { _fk_say "Запускаем прежний движок Forkop обратно"; /etc/init.d/forkop start >/dev/null 2>&1; } return 0 } # Removes the old Forkop engine (files, firewall, routes, cron, dnsmasq changes). sing-box stays. _fk_legacy_remove() { local p _fk_say "Убираем прежний движок Forkop (настройки уже перенесены)" if [ -x /etc/init.d/forkop ]; then _zm_run 60 /etc/init.d/forkop stop >/dev/null 2>&1 /etc/init.d/forkop disable >/dev/null 2>&1 fi [ -x /usr/bin/forkop ] && _zm_run 60 /usr/bin/forkop restore_dnsmasq >/dev/null 2>&1 nft delete table inet ForkopTable >/dev/null 2>&1 nft delete table inet ForkopTableDpiGuard >/dev/null 2>&1 for p in luci-i18n-forkop-ru luci-app-forkop forkop; do _pkg_is_installed "$p" && $DELETE "$p" >/dev/null 2>&1; done rm -rf /usr/lib/forkop /usr/lib/forkop.zm-old /usr/share/forkop /etc/forkop /var/run/forkop /tmp/forkop* "$JOBS_DIR/forkop-src" \ /www/luci-static/resources/view/forkop /usr/share/luci/menu.d/luci-app-forkop.json /usr/share/rpcd/acl.d/luci-app-forkop.json rm -f /usr/bin/forkop /etc/init.d/forkop /etc/rc.d/*forkop /etc/config/forkop /etc/config/forkop-opkg /etc/uci-defaults/*forkop* \ /etc/hotplug.d/*/*forkop* "$ZM_STATE_DIR/latest.forkop" "$JOBS_DIR/forkop-sb.json" "$JOBS_DIR/forkop.conf.bak" /opt/zapret-manager-luci/forkop.uc sed -i '/forkop/d' /etc/sysupgrade.conf 2>/dev/null if grep -q 'forkop' "$CRON_FILE" 2>/dev/null; then sed -i '/forkop/d' "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 fi _fk_dnsmasq_clean _fk_ip_clean echo " ✓ Прежний движок убран" } do_fk_install() { local mode="$1" tag tmp="$JOBS_DIR/netshift-src" tgz src rel major b need="" p free was_run=0 was_en=0 fresh=1 sbfree=0 restored=0 local legacy=0 lconf="$JOBS_DIR/forkop-import" notes keep="$JOBS_DIR/netshift.conf.keep" _fk_say "Проверяем роутер: версию OpenWrt, свободную память и конфликты" rel="$(awk -F\' '/DISTRIB_RELEASE/ {print $2}' /etc/openwrt_release)" major="${rel%%.*}" case "$major" in ''|*[!0-9]*) ;; *) [ "$major" -ge 24 ] || { echo "ОШИБКА: Forkozz работает на OpenWrt 24.10 и новее, а здесь $rel"; return 1; } ;; esac b="$(_fk_blocker)" [ -n "$b" ] && { echo "ОШИБКА: $(_fk_blocker_text "$b")"; return 1; } _fk_installed && fresh=0 _fk_installed || ! _fk_legacy || legacy=1 [ -z "$(_fk_sb_ver)" ] && sbfree=12288 free="$(df -k /overlay 2>/dev/null | awk 'NR==2 {print $4}')" [ -n "$free" ] || free="$(df -k / 2>/dev/null | awk 'NR==2 {print $4}')" if [ -n "$free" ] && [ "$free" -lt $((4096 + sbfree)) ]; then echo "ОШИБКА: мало памяти: свободно $((free / 1024)) МБ, нужно около $(((4096 + sbfree) / 1024)) МБ" return 1 fi echo " ✓ OpenWrt $rel, свободно ${free:+$((free / 1024)) МБ}, конфликтов нет" [ "$legacy" = 1 ] && _fk_say "Найден прежний движок Forkop — переводим Forkozz на NetShift и переносим настройки, секции и исключения" _fk_feeds_official || true _zm_net_prepare downloads.openwrt.org || return 1 _zm_dns_ok codeload.github.com || _zm_net_heal _fk_say "Обновляем список пакетов" $UPDATE || { echo "ОШИБКА: список пакетов не обновился — проверьте интернет"; return 1; } _fk_say "Проверяем нужные пакеты" for p in $FK_DEPS; do _pkg_is_installed "$p" || need="$need $p"; done [ -n "$need" ] || echo " ✓ Все нужные пакеты уже стоят" if [ -n "$need" ]; then _fk_say "Ставим недостающие пакеты:$need" $INSTALL $need for p in $need; do _pkg_is_installed "$p" || { echo "ОШИБКА: не установился пакет $p"; return 1; }; done fi { [ "$mode" = update ] || [ "$fresh" = 1 ]; } && tag="$(ZM_VER_FORCE=1 _zm_cached netshift _fk_latest_fetch)" [ -n "$tag" ] || tag="$(_fk_version)" [ -n "$tag" ] || tag="$FK_PIN" _fk_say "Скачиваем NetShift $tag с GitHub (github.com/$FK_REPO) — на нём работает Forkozz" rm -rf "$tmp" mkdir -p "$tmp" tgz="$tmp/src.tar.gz" _zm_gh_get "https://codeload.github.com/$FK_REPO/tar.gz/refs/tags/$tag" "$tgz" || _zm_gh_get "https://github.com/$FK_REPO/archive/refs/tags/$tag.tar.gz" "$tgz" || { rm -rf "$tmp" echo "ОШИБКА: GitHub не отвечает — ни напрямую, ни через WARP" return 1 } if ! tar -xzf "$tgz" -C "$tmp" 2>/dev/null; then rm -rf "$tmp" echo "ОШИБКА: архив скачался битым — попробуйте ещё раз" return 1 fi echo " ✓ Архив скачан ($(ls -l "$tgz" 2>/dev/null | awk '{ printf "%d КБ", ($5 + 1023) / 1024 }')), распаковываем" rm -f "$tgz" src="$(ls -d "$tmp"/*/netshift/files 2>/dev/null | head -n1)" if [ ! -f "$src/usr/bin/netshift" ] || [ ! -f "$src/usr/lib/constants.sh" ] || [ ! -f "$src/etc/init.d/netshift" ] || [ ! -f "$src/etc/config/netshift" ]; then rm -rf "$tmp" echo "ОШИБКА: в архиве NetShift нет нужных файлов" return 1 fi sed -i "s#__COMPILED_VERSION_VARIABLE__#$tag#g" "$src/usr/lib/constants.sh" grep -q "^NETSHIFT_VERSION=\"$tag\"" "$src/usr/lib/constants.sh" || { rm -rf "$tmp"; echo "ОШИБКА: не удалось прописать версию NetShift"; return 1; } _fk_say "Источники: GitHub и репозиторий OpenWrt, без зеркал" if [ "$legacy" = 1 ]; then [ -x /etc/init.d/forkop ] && /etc/init.d/forkop enabled 2>/dev/null && was_en=1 ls /etc/rc.d/S*forkop >/dev/null 2>&1 && was_en=1 nft list table inet ForkopTable >/dev/null 2>&1 && pidof sing-box >/dev/null 2>&1 && was_run=1 rm -rf "$lconf"; mkdir -p "$lconf" if [ -s /etc/config/forkop ]; then cp /etc/config/forkop "$lconf/forkop" elif [ -s "$FK_SAVE" ]; then cp "$FK_SAVE" "$lconf/forkop"; fi [ -s "$lconf/forkop" ] && _fk_legacy_conf_fix "$lconf/forkop" [ "$was_run" = 1 ] && { _fk_say "Останавливаем прежний движок на время перехода"; _zm_run 60 /etc/init.d/forkop stop >/dev/null 2>&1; } elif _fk_foreign; then _fk_say "Убираем пакет NetShift с LuCI, настройки оставляем" _fk_up && was_run=1 _fk_enabled && was_en=1 [ -s /etc/config/netshift ] && cp /etc/config/netshift "$keep" [ "$was_run" = 1 ] && _zm_run 60 /etc/init.d/netshift stop >/dev/null 2>&1 for p in luci-i18n-netshift-ru luci-app-netshift netshift; do _pkg_is_installed "$p" && $DELETE "$p" >&2; done rm -rf /www/luci-static/resources/view/netshift /usr/share/luci/menu.d/luci-app-netshift.json /usr/share/rpcd/acl.d/luci-app-netshift.json [ -s "$keep" ] && cp "$keep" /etc/config/netshift rm -f "$keep" fresh=0 elif [ "$fresh" = 0 ]; then _fk_up && was_run=1 _fk_enabled && was_en=1 [ "$was_run" = 1 ] && { _fk_say "Останавливаем Forkozz на время обновления"; _zm_run 60 /etc/init.d/netshift stop >/dev/null 2>&1; } fi _fk_say "Копируем файлы NetShift в систему (/usr/lib/netshift, /usr/bin/netshift, /etc/init.d/netshift)" rm -rf /usr/lib/netshift.zm-old if [ -d /usr/lib/netshift ]; then mv /usr/lib/netshift /usr/lib/netshift.zm-old [ -f /usr/bin/netshift ] && mv /usr/bin/netshift /usr/lib/netshift.zm-old/.bin [ -f /etc/init.d/netshift ] && mv /etc/init.d/netshift /usr/lib/netshift.zm-old/.init fi mkdir -p /usr/lib/netshift /etc/netshift if ! cp -R "$src/usr/lib/." /usr/lib/netshift/ || ! cp "$src/usr/bin/netshift" /usr/bin/netshift || ! cp "$src/etc/init.d/netshift" /etc/init.d/netshift || ! cp "$src/etc/config/netshift" /usr/lib/netshift/default.config; then _fk_restore_old rm -rf "$tmp" if [ "$legacy" = 1 ]; then _fk_legacy_resume "$was_en" "$was_run"; else _fk_resume "$was_en" "$was_run"; fi echo "ОШИБКА: файлы не записались — похоже, кончилась память" return 1 fi chmod 0755 /usr/bin/netshift /etc/init.d/netshift chmod -R a+rX /usr/lib/netshift rm -rf "$tmp" if [ ! -s /etc/config/netshift ]; then cp /usr/lib/netshift/default.config /etc/config/netshift uci -q delete netshift.main uci -q set netshift.settings.dns_type='udp' uci -q set netshift.settings.dns_server='9.9.9.9' uci -q set netshift.settings.bootstrap_dns_server='9.9.9.9' uci -q delete netshift.settings.zm_dns_servers uci -q add_list netshift.settings.zm_dns_servers='9.9.9.9' uci -q delete netshift.settings.zm_bootstrap uci -q add_list netshift.settings.zm_bootstrap='9.9.9.9' uci -q add_list netshift.settings.zm_bootstrap='77.88.8.8' uci -q set netshift.settings.disable_quic='1' uci -q commit netshift [ "$legacy" = 1 ] || _fk_say "DNS по умолчанию: Quad9 9.9.9.9 (обычный UDP). Сменить можно в «Настройках»" fi chmod 0644 /etc/config/netshift if [ "$legacy" = 1 ]; then if [ -s "$lconf/forkop" ]; then _fk_say "Переносим настройки Forkop: подключения, секции, списки, свои домены, исключения, устройства, DNS" notes="$(_fk_uc import "$lconf" 2>&1)" case "$notes" in *'"ok": true'*|*'"ok":true'*) printf '%s' "$notes" | jsonfilter -e '@.notes[*]' 2>/dev/null | sed 's/^/ ! /' echo " ✓ Настройки перенесены" restored=1 ;; *) echo "ОШИБКА: настройки Forkop не перенеслись: $(printf '%s' "$notes" | jsonfilter -e '@.error' 2>/dev/null)" rm -rf /usr/lib/netshift /usr/bin/netshift /etc/init.d/netshift /etc/config/netshift _fk_restore_old _fk_legacy_resume "$was_en" "$was_run" return 1 ;; esac fi _fk_legacy_remove rm -rf "$lconf" fi rm -rf /usr/lib/netshift.zm-old echo "$tag" > "$FK_MARK" rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* /var/luci-indexcache* 2>/dev/null _fk_sb_install || { echo "!! Forkozz установлен, но без sing-box он не работает — нажмите «Переустановить»"; return 1; } [ "$was_en" = 1 ] && /etc/init.d/netshift enable >/dev/null 2>&1 if [ "$was_run" = 1 ]; then _fk_say "Запускаем Forkozz" _zm_quiet "" /etc/init.d/netshift start || true _fk_wait_up 90 || echo "!! Forkozz не запустился — нажмите «Проверить»" fi if [ "$legacy" = 1 ]; then _fk_say "Готово: Forkozz переведён на NetShift $tag, настройки перенесены" elif [ "$restored" = 1 ]; then _fk_say "Готово: Forkozz $tag установлен с прежними настройками. Нажмите «Включить»" elif [ "$fresh" = 1 ]; then _fk_say "Готово: Forkozz (NetShift $tag) установлен. Настройте подключение и сервисы" else _fk_say "Готово: Forkozz обновлён до NetShift $tag" fi } do_fk_remove() { local p _fk_say "Останавливаем Forkozz и убираем его из автозапуска" if [ -x /etc/init.d/netshift ]; then _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 /etc/init.d/netshift disable >/dev/null 2>&1 fi echo " ✓ Forkozz остановлен" _fk_say "Возвращаем DNS роутера (dnsmasq) к прежним настройкам" _fk_dnsmasq_clean _fk_say "Убираем маршруты и таблицу маршрутизации Forkozz" _fk_ip_clean _fk_say "Удаляем правила файрвола (nftables: $FK_NFT)" nft delete table inet "$FK_NFT" >/dev/null 2>&1 nft list table inet "$FK_NFT" >/dev/null 2>&1 && echo "!! Таблица $FK_NFT не удалилась" || echo " ✓ Правила файрвола убраны" _fk_legacy && _fk_legacy_remove for p in luci-i18n-netshift-ru luci-app-netshift netshift; do _pkg_is_installed "$p" && $DELETE "$p"; done _fk_say "Удаляем файлы, настройки и кэш Forkozz" rm -rf /usr/lib/netshift /usr/lib/netshift.zm-old /etc/netshift /tmp/netshift* /tmp/sing-box/rulesets /tmp/sing-box/subscription* "$JOBS_DIR/netshift-src" \ /www/luci-static/resources/view/netshift /usr/share/luci/menu.d/luci-app-netshift.json /usr/share/rpcd/acl.d/luci-app-netshift.json rm -f /usr/bin/netshift /etc/init.d/netshift /etc/rc.d/*netshift /etc/config/netshift /etc/config/netshift-070 /var/run/netshift* \ "$FK_SAVE" "$FK_MARK" "$ZM_STATE_DIR/latest.netshift" "$ZM_STATE_DIR"/fk.sub.* "$ZM_STATE_DIR"/fk.fell* "$ZM_STATE_DIR/fk.dnsfo" "$JOBS_DIR/netshift.conf.bak" sed -i '/netshift/d' /etc/sysupgrade.conf 2>/dev/null echo " ✓ Файлы и настройки удалены" if grep -q 'netshift' "$CRON_FILE" 2>/dev/null; then _fk_say "Убираем задания Forkozz из расписания (cron)" sed -i '/netshift/d' "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 echo " ✓ Расписание очищено" fi _fk_sb_purge || true _fk_say "Перезапускаем DNS роутера" /etc/init.d/dnsmasq restart >/dev/null 2>&1 echo " ✓ dnsmasq перезапущен" _fk_feeds_official || true rm -f /tmp/luci-indexcache* /tmp/luci-modulecache/* /var/luci-indexcache* 2>/dev/null if [ -e /usr/bin/sing-box ]; then _fk_say "Forkozz удалён полностью — вместе с настройками и правилами (sing-box оставлен)" else _fk_say "Forkozz удалён полностью — вместе с настройками, правилами и sing-box"; fi _zm_after_remove "Forkozz" } do_fk_sb_remove() { _fk_installed && { echo "ОШИБКА: Forkozz установлен — без sing-box он не работает. Сначала удалите Forkozz"; return 1; } local u u="$(_fk_sb_users)" [ -n "$u" ] && { echo "ОШИБКА: sing-box нужен другим программам ($u) — удалять его нельзя"; return 1; } _fk_sb_purge || return 1 _fk_say "Готово: sing-box удалён" } _fk_start_once() { _zm_run 60 /etc/init.d/netshift start >> "$JOBS_DIR/forkop-svc.out" 2>&1 } _fk_wait_up() { local i=0 while [ "$i" -lt "$1" ]; do _fk_up && return 0 sleep 1 i=$((i + 1)) [ "$i" = 20 ] && printf 'Подождите ' [ "$i" -gt 20 ] && [ $((i % 5)) = 0 ] && printf '.' done [ "$i" -ge 20 ] && echo _fk_up } _fk_why() { local l l="$(logread 2>/dev/null | grep -E 'netshift|sing-box' | grep -E 'fatal|Aborted|error' | tail -n 1)" case "$l" in *"Outbound section not found"*) echo "ни в одной секции нет настроенного подключения" ;; *"Invalid service in community lists"*) echo "в секции выбран список, которого NetShift не знает — сохраните сервисы заново" ;; *"configuration"*"is invalid"*) echo "sing-box не принял собранный конфиг — посмотрите журнал" ;; *"version"*"lower than the required"*) echo "sing-box слишком старый — замените его в «Настройках»" ;; *"Service 'sing-box' is missing"*) echo "нет службы sing-box — переустановите sing-box в «Настройках»" ;; *"Proxy string is not set"*|*"links is not set"*) echo "в секции нет ни одного сервера" ;; *"VPN interface is not set"*) echo "в секции не выбран туннель" ;; esac } _fk_reset_runtime() { _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 if pidof sing-box >/dev/null 2>&1 && [ -z "$(_fk_sb_users)" ]; then [ -x /etc/init.d/sing-box ] && /etc/init.d/sing-box stop >/dev/null 2>&1 killall sing-box >/dev/null 2>&1 sleep 2 pidof sing-box >/dev/null 2>&1 && killall -9 sing-box >/dev/null 2>&1 echo " ✓ Остановлен оставшийся процесс sing-box" fi nft delete table inet "$FK_NFT" >/dev/null 2>&1 uci -q set netshift.settings.shutdown_correctly='1' && uci -q commit netshift } _fk_svc_report() { local f="$JOBS_DIR/forkop-svc.out" lines [ -s "$f" ] || return 0 lines="$(sed 's/\x1b\[[0-9;]*m//g' "$f" | grep -v '^[[:space:]]*$' | grep -viE 'udhcpc|^pending$|^Start netshift$|service_triggers' | grep -iE 'error|fatal|fail|abort|ошиб|не удалось' | awk '!s[$0]++' | tail -n 8)" [ -n "$lines" ] && printf '%s\n' "$lines" | sed 's/^/ /' return 0 } do_fk_service() { local a="$1" b i=0 _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } case "$a" in stop) _fk_say "Выключаем Forkozz — трафик пойдёт напрямую" /etc/init.d/netshift disable >/dev/null 2>&1 echo " ✓ Убран из автозапуска" _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 _fk_up && { nft delete table inet "$FK_NFT" >/dev/null 2>&1; } echo " ✓ Служба остановлена, правила сняты" _fk_say "Готово: Forkozz выключен" return 0 ;; esac b="$(_fk_blocker)" [ -n "$b" ] && { echo "ОШИБКА: $(_fk_blocker_text "$b")"; return 1; } [ -n "$(_fk_sb_ver)" ] || _fk_sb_install || return 1 /etc/init.d/netshift enable >/dev/null 2>&1 : > "$JOBS_DIR/forkop-svc.out" if [ "$a" = apply ] && _fk_up; then _fk_say "Применяем настройки: Forkozz пересобирает конфиг sing-box и подхватывает списки" _zm_run 240 /usr/bin/netshift reload >> "$JOBS_DIR/forkop-svc.out" 2>&1 || echo "!! Перезагрузка настроек завершилась с ошибкой" else _fk_say "Запускаем Forkozz: собираем конфиг sing-box, скачиваем списки, ставим правила" if _fk_up || pidof sing-box >/dev/null 2>&1 || nft list table inet "$FK_NFT" >/dev/null 2>&1; then _zm_run 90 /etc/init.d/netshift stop >> "$JOBS_DIR/forkop-svc.out" 2>&1 fi _fk_start_once || echo "!! Запуск завершился с ошибкой" fi _fk_svc_report if ! _fk_up; then _fk_say "Ждём, пока поднимутся sing-box и правила (до 90 с)" _fk_wait_up 90 fi if ! _fk_up; then b="$(_fk_why)" _rb_warn "Forkozz не поднялся${b:+: $b}" _fk_say "Пробуем ещё раз: снимаем остатки прошлого запуска и запускаем заново" _fk_reset_runtime : > "$JOBS_DIR/forkop-svc.out" _fk_start_once || echo "!! Повторный запуск завершился с ошибкой" _fk_svc_report _fk_up || _fk_wait_up 90 fi rm -f "$JOBS_DIR/forkop-svc.out" if _fk_up; then _fk_say "Проверяем, что всё работает" _fk_check_live _fk_lists_report _fk_lists_errors _fk_say "Готово: Forkozz работает" grep -q '^Forkozz не запустился' "$ZM_REBOOT_HINT" 2>/dev/null && rm -f "$ZM_REBOOT_HINT" return 0 fi b="$(_fk_why)" echo "ОШИБКА: Forkozz не запустился${b:+: $b}. Если ошибка повторяется, перезагрузите роутер — это сбросит всё, что осталось от прошлых запусков." _zm_reboot_hint "Forkozz не запустился. Перезагрузите роутер — это сбросит всё, что осталось от прошлых запусков, — и включите Forkozz снова." echo "Последние записи журнала:" logread 2>/dev/null | grep -E 'netshift|sing-box' | grep -v 'DEBUG\|\[debug\]' | tail -n 10 | sed 's/^[^]]*\]: //; s/^/ /' return 1 } do_fk_lists() { local rc=0 out="$JOBS_DIR/forkop-lists.out" _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } _fk_up || { echo "ОШИБКА: Forkozz выключен — включите его, и списки скачаются сами"; return 1; } _fk_say "Скачиваем свежие списки сервисов и обновляем адреса в файрволе" _zm_run 300 /usr/bin/netshift list_update > "$out" 2>&1 || rc=1 sed 's/\x1b\[[0-9;]*m//g' "$out" 2>/dev/null | grep -v '^[[:space:]]*$' | tail -n 15 | sed 's/^/ /' rm -f "$out" if [ "$rc" != 0 ]; then echo "!! Скачивание списков завершилось с ошибкой — работают прежние" else echo " ✓ Списки скачаны — sing-box подхватит их сам" fi _fk_say "Проверяем, что списки на месте и работают" _fk_check_live _fk_lists_report _fk_lists_errors _fk_up || { echo "ОШИБКА: после обновления списков Forkozz не работает — нажмите «Перезапустить»"; return 1; } _fk_say "Готово: списки обновлены" } do_fk_subs() { _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } _fk_say "Обновляем подписки" _zm_quiet "" /usr/bin/netshift subscription_update all || { echo "ОШИБКА: подписка не обновилась — проверьте ссылку"; return 1; } _fk_say "Готово: подписки обновлены" } do_fk_singbox() { local want="$1" free tfree have need tneed out="$JOBS_DIR/forkop-sb.json" old oldvar msg i=0 _fk_installed || { echo "ОШИБКА: Forkozz не установлен"; return 1; } case "$want" in tiny|stable|extended) ;; *) echo "ОШИБКА: неизвестный вариант sing-box"; return 1 ;; esac if [ "$want" != extended ] && grep -Eqi "[?&]type=xhttp|^[[:space:]]*list zm_links 'vmess://" /etc/config/netshift 2>/dev/null; then echo "ОШИБКА: среди серверов есть XHTTP или VMess — они работают только на расширенном sing-box. Сначала уберите их в «Подключении»" return 1 fi old="$(_fk_sb_ver)"; oldvar="$(_fk_sb_var)" _fk_say "Проверяем место и память" free="$(df -k /overlay 2>/dev/null | awk 'NR==2 {print $4}')" [ -n "$free" ] || free="$(df -k / 2>/dev/null | awk 'NR==2 {print $4}')" have="$(ls -l /usr/bin/sing-box 2>/dev/null | awk '{print int($5 / 1024)}')" tfree="$(df -k /tmp 2>/dev/null | awk 'NR==2 {print $4}')" case "$want" in extended) need=40960; tneed=30720 ;; *) need=16384; tneed=12288 ;; esac if [ -n "$free" ] && [ $((free + ${have:-0})) -lt "$need" ]; then echo "ОШИБКА: мало места на флеше для этого sing-box: свободно $(((free + ${have:-0}) / 1024)) МБ, нужно около $((need / 1024)) МБ" return 1 fi if [ -n "$tfree" ] && [ "$tfree" -lt "$tneed" ]; then echo "ОШИБКА: мало оперативной памяти для загрузки: свободно $((tfree / 1024)) МБ, нужно около $((tneed / 1024)) МБ" return 1 fi echo " ✓ Флеш: свободно $(((free + ${have:-0}) / 1024)) МБ, /tmp: $((tfree / 1024)) МБ" [ -n "$old" ] && echo " · сейчас стоит $old ($(_fk_sb_name "$oldvar")) — сохраняем его копию, чтобы вернуть, если новый не заработает" case "$want" in extended) _zm_net_prepare api.github.com github.com objects.githubusercontent.com || return 1 _fk_say "Скачиваем расширенный sing-box с GitHub (github.com/shtorm-7/sing-box-extended) — около 40 МБ" _zm_wait "" /usr/bin/netshift component_action sing_box install_extended > "$out" 2>&1 if [ "$(jsonfilter -i "$out" -e '@.success' 2>/dev/null)" != true ]; then msg="$(jsonfilter -i "$out" -e '@.message' 2>/dev/null)" echo "ОШИБКА: sing-box не заменился${msg:+: $msg}" [ -n "$old" ] && [ "$(_fk_sb_ver)" = "$old" ] && echo " ✓ Прежний sing-box $old на месте — Forkozz работает как раньше" rm -f "$out" return 1 fi rm -f "$out" ;; *) _zm_net_prepare downloads.openwrt.org || return 1 $UPDATE >/dev/null 2>&1 _fk_sb_pkg_switch "$want" || { echo "ОШИБКА: $(_fk_sb_name "$want") sing-box не встал" [ -n "$old" ] && [ "$(_fk_sb_ver)" = "$old" ] && echo " ✓ Прежний sing-box $old на месте — Forkozz работает как раньше" return 1 } ;; esac _fk_sb_pkg > "$FK_SB_OWN" || echo "sing-box" > "$FK_SB_OWN" echo " ✓ Установлен sing-box $(_fk_sb_ver)" if _fk_enabled; then _fk_say "Перезапускаем Forkozz на новом sing-box" _zm_run 90 /etc/init.d/netshift stop >/dev/null 2>&1 _fk_start_once _fk_wait_up 90 _fk_check_live _fk_up || { echo "ОШИБКА: Forkozz не запустился на новом sing-box — верните прежний вариант в «Настройках»"; return 1; } fi _fk_say "Готово: sing-box $(_fk_sb_ver), $(_fk_sb_name "$(_fk_sb_var)")" } FK_DNS_Q="AAABAAABAAAAAAAAB2V4YW1wbGUDY29tAAABAAE" _fk_dns_ms() { awk -v t="$1" 'BEGIN { if (t == "" || t + 0 <= 0) print 0; else printf "%d\n", t * 1000 + 0.5 }'; } _fk_dns_probe() { local t="$1" v="$2" out="$3" f="$3.body" host port path url code tm res ms="" ok=0 how="" case "$t" in udp) host="${v%:*}"; port="${v##*:}"; [ "$host" = "$v" ] && port=53 res="$(dig @"$host" -p "$port" example.com A +time=3 +tries=1 2>&1)" echo "$res" | grep -q 'status: NOERROR' && { ok=1; ms="$(echo "$res" | sed -n 's/.*Query time: \([0-9]*\) msec.*/\1/p' | head -n1)"; } how=udp ;; dot) host="${v%:*}"; port="${v##*:}"; [ "$host" = "$v" ] && port=853 res="$(dig +tls @"$host" -p "$port" example.com A +time=4 +tries=1 2>&1)" if echo "$res" | grep -q 'status: NOERROR'; then ok=1; ms="$(echo "$res" | sed -n 's/.*Query time: \([0-9]*\) msec.*/\1/p' | head -n1)"; how=dot else res="$(curl -sk -o /dev/null -w '%{time_appconnect}' --connect-timeout 4 --max-time 5 "https://$host:$port/" 2>/dev/null)" tm="$(_fk_dns_ms "$res")" [ "$tm" -gt 0 ] 2>/dev/null && { ok=1; ms="$tm"; how=tls; } fi ;; doh) case "$v" in https://*) url="$v"; case "${url#https://}" in */*) ;; *) url="$url/dns-query" ;; esac ;; *) url="https://$v/dns-query" ;; esac case "$url" in *\?*) url="$url&dns=$FK_DNS_Q" ;; *) url="$url?dns=$FK_DNS_Q" ;; esac res="$(curl -s -o "$f" -w '%{http_code} %{time_total}' -H 'accept: application/dns-message' --connect-timeout 4 --max-time 6 "$url" 2>/dev/null)" code="${res%% *}"; tm="${res##* }" if [ "$code" = 200 ] && [ "$(wc -c < "$f" 2>/dev/null || echo 0)" -ge 29 ]; then ok=1; ms="$(_fk_dns_ms "$tm")"; fi rm -f "$f"; how=doh ;; esac [ "$ok" = 1 ] && [ "${ms:-0}" -lt 1 ] 2>/dev/null && ms=1 printf '%s|%s|%s|%s\n' "$v" "$ok" "${ms:-0}" "$how" > "$out" } fk_dns_test() { local t="${1%%|*}" list="${1#*|}" d="$JOBS_DIR/fkdns.$$" v i=0 sep="" line ok ms how case "$t" in udp|dot|doh) ;; *) echo '{"error":"неизвестный тип DNS"}'; return 1 ;; esac command -v dig >/dev/null 2>&1 || [ "$t" = doh ] || { echo '{"error":"нет программы dig — нажмите «Переустановить» Forkozz"}'; return 1; } mkdir -p "$d" for v in $list; do case "$v" in *[!A-Za-z0-9.:/_?=%-]*|'') continue ;; esac i=$((i + 1)) [ "$i" -gt 16 ] && break _fk_dns_probe "$t" "$v" "$d/$i" & done wait printf '{"type":"%s","results":[' "$t" for v in $(ls "$d" 2>/dev/null | sort -n); do IFS='|' read -r line ok ms how < "$d/$v" printf '%s{"server":"%s","ok":%s,"ms":%s,"how":"%s"}' "$sep" "$(esc "$line")" "$([ "$ok" = 1 ] && echo true || echo false)" "${ms:-0}" "$how" sep="," done printf ']}\n' rm -rf "$d" } forkop_config_get() { _fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; } if [ ! -f "$ST_CAT_OFF" ]; then if [ -s "$ST_CAT_IDX" ] && ! head -n1 "$ST_CAT_IDX" | grep -q '^[^|]*|[^|]*|[^|]*|[sd]$'; then [ -d "$ST_RUN/cat.lock" ] || ( _st_cat_refresh force >/dev/null 2>&1 & ) else _st_cat_bg fi fi ZM_FK_CAT="$(_st_cat_json)" _fk_ucj get "$1" } forkop_config_set() { local res _fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; } _job_running forkop && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } res="$(printf '%s' "$1" | _fk_ucj set)" || { echo "$res"; return 1; } rm -f "$ZM_STATE_DIR"/fk.fell "$ZM_STATE_DIR"/fk.fell.* "$JOBS_DIR"/fk.heal "$JOBS_DIR"/fk.heal.* echo "$res" } _zm_route_target() { local q q="$(printf '%s' "$1" | tr -d ' \t\r\n' | tr 'A-Z' 'a-z' | sed 's#^[a-z0-9+.-]*://##; s#^[^/@]*@##; s#[/?\#].*$##')" case "$q" in \[*\]*) q="${q#\[}"; q="${q%%\]*}" ;; *:*:*) ;; *:*) q="${q%%:*}" ;; esac printf '%s' "$q" | grep -Eq '^([a-z0-9]([a-z0-9_-]{0,62}\.)+[a-z0-9-]{1,63}|[0-9a-f:]{2,39})$' || return 1 printf '%s' "$q" } _zm_resolve4() { nslookup "$1" 127.0.0.1 2>/dev/null | awk '/^Name:/ { f = 1; next } f && /^Address/ { a = $0; sub(/^Address[ 0-9]*:[ \t]*/, "", a); if (a ~ /^[0-9.]+$/) { print a; exit }}' | head -n1 } _zm_resolve_any() { local a a="$(_zm_resolve4 "$1")" [ -n "$a" ] || a="$(nslookup "$1" 127.0.0.1 2>/dev/null | awk '/^Name:/ { f = 1; next } f && /^Address/ { a = $0; sub(/^Address[ 0-9]*:[ \t]*/, "", a); print a; exit }')" printf '%s' "$a" } steer_explain() { local q q="$(_zm_route_target "$1")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; } stl_present || { echo '{"error":"Steer не установлен"}'; return 1; } { _st_owns "steer-spec" && [ ! -f "$ST_OFF" ]; } || { printf '{"target":"%s","verdict":"off","text":""}\n' "$(esc "$q")"; return 0; } stl_explain "$q" printf '{"target":"%s","verdict":"%s","out":"%s","dev":"%s","channel":"%s","addr":"%s","fake":%s,"node":"%s","text":"%s"}\n' \ "$(esc "$q")" "$STL_X_VERDICT" "$(esc "$STL_X_OUT")" "$(esc "$STL_X_DEV")" "$(esc "$STL_X_SET")" "$(esc "$STL_X_ADDR")" "$STL_X_FAKE" \ "$(esc "$(cat "$ST_SUB_NODE" 2>/dev/null)")" "$(esc_ml "$STL_X_TEXT")" } fk_route_check() { local q addr="" how="" verdict=direct tr rule="" ob="" ac="" sec="" s isip=0 caught=0 q="$(_zm_route_target "$1")" || { echo '{"error":"введите домен (например, youtube.com) или IP-адрес"}'; return 1; } _fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; } _fk_up || { printf '{"target":"%s","verdict":"off"}\n' "$(esc "$q")"; return 0; } if printf '%s' "$q" | grep -Eq '^[0-9.]+$|:'; then addr="$q"; isip=1; else addr="$(_zm_resolve_any "$q")"; fi case "$addr" in '') ;; 198.18.*|198.19.*|fd00:ec3a:*) caught=1; how=fakeip ;; *:*) nft get element inet "$FK_NFT" netshift_subnets_v6 "{ $addr }" >/dev/null 2>&1 && { caught=1; how=subnet; } ;; *) for s in netshift_subnets netshift_discord_subnets; do nft get element inet "$FK_NFT" "$s" "{ $addr }" >/dev/null 2>&1 && { caught=1; how=subnet; break; } done ;; esac if command -v timeout >/dev/null 2>&1; then tr="$(timeout 40 ucode "$FK_UC" route "$q" "$addr" 2>/dev/null)" else tr="$(_fk_uc route "$q" "$addr" 2>/dev/null)"; fi case "$tr" in '{'*) rule="$(printf '%s' "$tr" | jsonfilter -e '@.rule' 2>/dev/null)" ob="$(printf '%s' "$tr" | jsonfilter -e '@.outbound' 2>/dev/null)" ac="$(printf '%s' "$tr" | jsonfilter -e '@.action' 2>/dev/null)" sec="$(printf '%s' "$tr" | jsonfilter -e '@.section' 2>/dev/null)" ;; esac case "$ac" in bypass) verdict=bypass ;; block) verdict=block ;; connection|outbound) [ "$caught" = 1 ] && verdict=proxy || verdict=direct ;; *) if [ -z "$addr" ]; then verdict=unknown; else verdict=direct; fi ;; esac [ -z "$addr" ] && [ "$isip" = 0 ] && { [ "$verdict" = proxy ] || [ "$ac" = connection ] || [ "$ac" = outbound ]; } && verdict=unknown printf '{"target":"%s","verdict":"%s","how":"%s","addr":"%s","ip":%s,"rule":"%s","outbound":"%s","action":"%s","section":"%s"}\n' \ "$(esc "$q")" "$verdict" "$how" "$(esc "$addr")" "$([ "$isip" = 1 ] && echo true || echo false)" "$(esc "$rule")" "$(esc "$ob")" "$(esc "$ac")" "$(esc "$sec")" } forkop_action() { local a="$1" arg="$2" b u case "$a" in install|update) b="$(_fk_blocker)" [ -n "$b" ] && { printf '{"error":"%s"}\n' "$(esc "$(_fk_blocker_text "$b")")"; return 1; } job_start forkop do_fk_install "$a" ;; remove) job_start forkop do_fk_remove ;; start|stop|restart|apply) job_start forkop do_fk_service "$a" ;; route) fk_route_check "$arg" ;; lists) job_start forkop do_fk_lists ;; subs) job_start forkop do_fk_subs ;; singbox) job_start forkop do_fk_singbox "$arg" ;; singbox_remove) _fk_installed && { echo '{"error":"сначала удалите Forkozz — без sing-box он не работает"}'; return 1; } u="$(_fk_sb_users)" [ -n "$u" ] && { printf '{"error":"%s"}\n' "$(esc "sing-box нужен другим программам ($u) — удалять его нельзя")"; return 1; } job_start forkop do_fk_sb_remove ;; diag) _fk_ucj diag "$arg" ;; secstate) _fk_ucj secstate "$([ "$arg" = probe ] && echo probe)" ;; servers) _fk_ucj servers "$arg" ;; latency) _fk_ucj latency "$arg" ;; cfg) forkop_config_get "$arg" ;; secdel) _fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; } _job_running forkop && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } b="$(_fk_ucj secdel "$arg")" || { printf '%s\n' "$b"; return 1; } case "$b" in *'"ok": true'*|*'"ok":true'*) ;; *) printf '%s\n' "$b"; return 1 ;; esac if _fk_enabled && _fk_up; then job_start forkop do_fk_service restart else printf '{"ok":true,"saved":true}\n'; fi ;; dns_test) fk_dns_test "$arg" ;; hide) _fk_installed || { echo '{"error":"Forkozz не установлен"}'; return 1; } _job_running forkop && { echo '{"error":"дождитесь окончания текущей операции"}'; return 1; } b="$(printf '%s' "$arg" | _fk_ucj hide)" case "$b" in *'"ok": true'*|*'"ok":true'*) ;; *) printf '%s\n' "$b"; return 1 ;; esac if _fk_enabled && _fk_up; then job_start forkop do_fk_service apply else printf '{"ok":true,"saved":true}\n'; fi ;; select) case "$arg" in *'|'*) _fk_ucj select "${arg%%|*}" "${arg#*|}" ;; *) _fk_ucj select "" "$arg" ;; esac ;; *) echo '{"error":"неизвестное действие"}' ;; esac } SI_OUT="$JOBS_DIR/sysinfo.json" _si_clean() { printf '%s' "$1" | tr '\t\r' ' ' | tr -d '\000-\010\013\014\016-\037'; } _si_row() { printf '%s["%s","%s","%s"]' "$SI_SEP" "$(esc "$(_si_clean "$1")")" "$(esc "$(_si_clean "$2")")" "$3" SI_SEP="," } _si_ping() { local t t="$(ping "$1" -c 1 -W 2 google.com 2>/dev/null | sed -n 's/.*time=\([0-9.]*\).*/\1/p' | head -n1)" [ -n "$t" ] && _si_row "$2" "работает, $t мс" ok || _si_row "$2" "нет связи" bad } _si_sites() { local list="$1" res="$2" i=0 n e name url : > "$res" while IFS= read -r e; do [ -n "$e" ] || continue i=$((i + 1)) name="${e%%|*}"; url="${e#*|}" ( if curl -sL --connect-timeout 3 --max-time 5 --speed-time 3 --speed-limit 1 --range 0-65535 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) curl/8.0" -o /dev/null "$url" >/dev/null 2>&1; then echo "$i|$name|1" >> "$res" else echo "$i|$name|0" >> "$res" fi ) & [ $((i % 10)) = 0 ] && wait done < "$list" wait } do_sysinfo() { local rel arch model kern up d h m mt mf ft fu fa tt tu sw hw fix t prov rate st zv zs zr tcp udp opt hosts pk sites ok=0 all=0 line i name v list="$JOBS_DIR/sysinfo.urls" res="$JOBS_DIR/sysinfo.res" out="$SI_OUT.tmp" echo "==> Собираем данные о роутере" rel="$(. /etc/openwrt_release 2>/dev/null; echo "$DISTRIB_RELEASE")" arch="$(. /etc/openwrt_release 2>/dev/null; echo "$DISTRIB_ARCH")" model="$(cat /tmp/sysinfo/model 2>/dev/null)" [ -n "$model" ] || model="$(jsonfilter -s "$(ubus call system board 2>/dev/null)" -e '@.model' 2>/dev/null)" kern="$(uname -r)" up="$(cut -d. -f1 /proc/uptime 2>/dev/null)"; up="${up:-0}" d=$((up / 86400)); h=$((up % 86400 / 3600)); m=$((up % 3600 / 60)) mt="$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo)" mf="$(awk '/^MemAvailable:/ {print int($2 / 1024)}' /proc/meminfo)" fa="$(df -k /overlay 2>/dev/null | awk 'NR==2 {print $2, $3, $4}')" [ -n "$fa" ] || fa="$(df -k / 2>/dev/null | awk 'NR==2 {print $2, $3, $4}')" set -- $fa; ft="$1"; fu="$2" set -- $(df -k /tmp 2>/dev/null | awk 'NR==2 {print $2, $3}'); tt="$1"; tu="$2" sw="$(uci -q get firewall.@defaults[0].flow_offloading)" hw="$(uci -q get firewall.@defaults[0].flow_offloading_hw)" fix="$(_flow_offloading_fix_applied)" { printf '{"ts":"%s","sections":[' "$(date '+%d.%m.%Y %H:%M')" SI_SEP="" printf '{"title":"Роутер","rows":[' _si_row "Модель" "${model:-—}" "" _si_row "OpenWrt" "${rel:-—}" "" _si_row "Архитектура" "${arch:-—}" "" _si_row "Ядро" "$kern" "" _si_row "Работает" "$([ "$d" -gt 0 ] && printf '%s д ' "$d")$(printf '%d ч %02d мин' "$h" "$m")" "" [ -n "$ft" ] && _si_row "Флеш" "занято $((fu / 1024)) МБ из $((ft / 1024)) МБ" "" [ -n "$tt" ] && _si_row "/tmp" "занято $((tu / 1024)) МБ из $((tt / 1024)) МБ" "" printf ']}' } > "$out" echo "==> Проверяем сеть" prov="$(curl -fsSL --connect-timeout 3 --max-time 5 https://ipinfo.io/org 2>/dev/null | sed -E 's/^AS[0-9]+ ?//; s/\b(OJSC|PJSC|JSC|LLC|Inc\.?|Ltd\.?)\b//g; s/[.,]//g; s/ +/ /g; s/^ +| +$//g')" rate="$(curl -fsS --connect-timeout 3 --max-time 5 https://api.github.com/rate_limit 2>/dev/null | grep -o '"remaining":[[:space:]]*[0-9]*' | head -n1 | grep -o '[0-9]*$')" { SI_SEP="" printf ',{"title":"Сеть","rows":[' [ -n "$prov" ] && _si_row "Провайдер" "$prov" "" _si_ping -4 "Google по IPv4" _si_ping -6 "Google по IPv6" if [ -n "$rate" ]; then [ "$rate" -gt 0 ] && _si_row "GitHub API" "работает, лимит $rate" ok || _si_row "GitHub API" "лимит исчерпан" bad else _si_row "GitHub API" "недоступен" bad fi if [ "$hw" = 1 ]; then _si_row "Flow Offloading" "аппаратный" warn elif [ "$sw" = 1 ]; then _si_row "Flow Offloading" "программный" warn else _si_row "Flow Offloading" "выключен" ok; fi [ "$fix" = true ] && _si_row "FIX для Flow Offloading" "применён" ok || _si_row "FIX для Flow Offloading" "не применён" "$([ "$sw" = 1 ] || [ "$hw" = 1 ] && echo bad)" if /etc/init.d/https-dns-proxy running >/dev/null 2>&1 && [ -f /etc/config/https-dns-proxy ]; then sed -n "s/^[[:space:]]*option resolver_url '\([^']*\)'.*/\1/p" /etc/config/https-dns-proxy | while IFS= read -r v; do _si_row "DNS over HTTPS" "$v" ""; SI_SEP=","; done fi _fk_installed && _si_row "Forkozz" "$(_fk_up && echo работает || echo выключен)" "$(_fk_up && echo ok)" printf ']}' } >> "$out" echo "==> Смотрим Zapret" st="$(status)" zv="$(jsonfilter -s "$st" -e '@.zapret_version' 2>/dev/null)" zr="$(jsonfilter -s "$st" -e '@.zapret_running' 2>/dev/null)" zs="$(jsonfilter -s "$st" -e '@.strategy' 2>/dev/null)" { SI_SEP="" printf ',{"title":"Zapret","rows":[' if [ -f /etc/init.d/zapret ]; then _si_row "Состояние" "$([ "$zr" = true ] && echo запущен || echo остановлен)" "$([ "$zr" = true ] && echo ok || echo bad)" _si_row "Версия" "${zv:-—}" "" _si_row "Стратегия" "${zs:-—}" "" tcp="$(sed -n "s/^[[:space:]]*option NFQWS_PORTS_TCP[[:space:]]*'\(.*\)'.*/\1/p" "$CONF" | head -n1)" udp="$(sed -n "s/^[[:space:]]*option NFQWS_PORTS_UDP[[:space:]]*'\(.*\)'.*/\1/p" "$CONF" | head -n1)" _si_row "Порты TCP" "${tcp:-—}" "" _si_row "Порты UDP" "${udp:-—}" "" _si_row "IPv6 в Zapret" "$([ "$(_ipv6_enabled_in_zapret)" = true ] && echo включён || echo выключен)" "" else _si_row "Состояние" "не установлен" "" fi printf ']}]' opt="" [ -f "$CONF" ] && opt="$(uci -q get zapret.config.NFQWS_OPT 2>/dev/null)" printf ',"strategy":"%s"' "$(esc_ml "$(_si_clean "$opt")")" hosts="" if [ -f "$HOSTS_FILE" ] && ! grep -qE 'geohide\.ru|mafioznik|dns\.malw\.link' "$HOSTS_FILE"; then hosts="$(grep '^#' "$HOSTS_FILE" | cut -c2- | sed 's/^[[:space:]]*//' | grep -v '^$' | while IFS= read -r v; do printf '"%s",' "$(esc "$(_si_clean "$v")")"; done)" fi printf ',"hosts":[%s]' "${hosts%,}" pk="$(_si_user_pkgs)" printf ',"packages":[%s]' "$(printf '%s\n' "$pk" | grep -v '^$' | while IFS= read -r v; do printf '"%s",' "$(esc "$v")"; done | sed 's/,$//')" } >> "$out" _test_prepare_urls "$list" all="$(grep -c '|' "$list")" echo "==> Проверяем доступность сайтов: $all" _zm_wait "" _si_sites "$list" "$res" ok="$(grep -c '|1$' "$res")" { printf ',"sites_ok":%s,"sites_total":%s,"sites":[' "${ok:-0}" "${all:-0}" sort -t'|' -k1,1n "$res" | awk -F'|' "$ZM_JESC_AWK"'{ printf "%s{\"name\":\"%s\",\"ok\":%s}", (n++ ? "," : ""), jesc($2), ($3 == 1 ? "true" : "false") }' printf ']}\n' } >> "$out" rm -f "$list" "$res" mv "$out" "$SI_OUT" echo "==> Готово: сайты открываются $ok из $all" } _si_user_pkgs() { local t0 if [ "$PKG" = apk ]; then if [ -s /rom/etc/apk/world ]; then sed 's/[<>=~].*//' /rom/etc/apk/world | sort -u > "$JOBS_DIR/si.rom" sed 's/[<>=~].*//' /etc/apk/world 2>/dev/null | sort -u | grep -vxF -f "$JOBS_DIR/si.rom" else sed 's/[<>=~].*//' /etc/apk/world 2>/dev/null | sort -u fi elif [ -s /rom/usr/lib/opkg/status ]; then awk '/^Package:/ {p = $2} /^Status: install user/ {print p}' /rom/usr/lib/opkg/status | sort -u > "$JOBS_DIR/si.rom" awk '/^Package:/ {p = $2} /^Status: install user/ {print p}' /usr/lib/opkg/status 2>/dev/null | sort -u | grep -vxF -f "$JOBS_DIR/si.rom" else t0="$(awk '/^Installed-Time:/ {print $2}' /usr/lib/opkg/status 2>/dev/null | sort -n | head -n1)" awk -v t0="${t0:-0}" '/^Package:/ {p = $2; u = 0} /^Status: install user/ {u = 1} /^Installed-Time:/ { if (u && $2 > t0 + 600) print p }' /usr/lib/opkg/status 2>/dev/null | sort -u fi rm -f "$JOBS_DIR/si.rom" } sysinfo_run() { job_start sysinfo do_sysinfo } sysinfo_get() { [ -s "$SI_OUT" ] && cat "$SI_OUT" || echo '{"sections":[]}' } redbtn_panel_gone() { local f r=0 line dir=/etc/zm-redbtn run="$JOBS_DIR/redbtn" _kill_match "redbtn_geo_watch" if [ -x /etc/init.d/zm-geodns ]; then /etc/init.d/zm-geodns stop >/dev/null 2>&1 /etc/init.d/zm-geodns disable >/dev/null 2>&1 fi rm -f /etc/init.d/zm-geodns /etc/rc.d/*zm-geodns for f in /tmp/dnsmasq.d/zm-geo.conf /tmp/dnsmasq.cfg*.d/zm-geo.conf; do [ -f "$f" ] && { rm -f "$f"; r=1; } done if grep -q redbtn_geo_watch "$CRON_FILE" 2>/dev/null; then sed -i '/redbtn_geo_watch/d' "$CRON_FILE" /etc/init.d/cron restart >/dev/null 2>&1 fi if [ -s "$dir/hosts.ai" ]; then while IFS= read -r line; do [ -n "$line" ] || continue _hosts_has_line "$line" || { echo "$line" >> "$HOSTS_FILE"; r=1; } done < "$dir/hosts.ai" fi if grep -qx 'pkg https-dns-proxy' "$dir/owned" 2>/dev/null; then if [ -f "$DOH_OFF_FLAG" ] || ! /etc/init.d/https-dns-proxy enabled 2>/dev/null; then /etc/init.d/https-dns-proxy stop >/dev/null 2>&1 $DELETE https-dns-proxy >&2 rm -f /etc/config/https-dns-proxy r=1 fi fi _doh_pkg || rm -f "$DOH_OFF_FLAG" [ -f "$DOH_OFF_FLAG" ] && { /etc/init.d/https-dns-proxy stop >/dev/null 2>&1; /etc/init.d/https-dns-proxy disable >/dev/null 2>&1; } [ "$r" = 1 ] && /etc/init.d/dnsmasq restart >/dev/null 2>&1 [ -d "$dir" ] && _st_migrate rm -rf "$dir" "$run" /opt/zapret-manager-luci/autobypass_video /usr/share/zm-redbtn/doh.conf return 0 } cmd="$1"; shift if [ "$2" = @stdin ]; then ZM_IN="$(cat; echo .)"; set -- "$1" "${ZM_IN%.}" elif [ "$1" = @stdin ]; then ZM_IN="$(cat; echo .)"; set -- "${ZM_IN%.}"; fi case "$cmd" in strategy_set_v|strategy_set_flowseal|strategy_set_youtube|youtube_quic_set|discord_set_dv|discord_set_fake|game_set|game_set_fake|game_toggle_xtreme) [ -f "$CONF" ] && { _nochange_guard || exit 1; } ;; test_action) [ "$1" = start ] && [ "$2" != current ] && [ -f "$CONF" ] && { _nochange_guard || exit 1; } ;; esac case "$cmd" in status) status ;; system_info) system_info ;; job_status) job_status "$1" ;; log_tail) log_tail "$1" ;; zapret_action) zapret_action "$1" ;; zapret_latest_version) zapret_latest_version ;; zapret2_action) zapret2_action "$1" ;; strategy_list_v) strategy_list_v ;; strategy_set_v) strategy_set_v "$1" ;; strategy_list_flowseal) strategy_list_flowseal "$1" ;; strategy_set_flowseal) strategy_set_flowseal "$1" ;; strategy_list_youtube) strategy_list_youtube "$1" ;; strategy_set_youtube) strategy_set_youtube "$1" ;; youtube_quic_set) youtube_quic_set "$1" ;; discord_status) discord_status ;; discord_set_dv) discord_set_dv "$1" ;; discord_set_fake) discord_set_fake "$1" ;; game_status) game_status ;; game_set) game_set "$1" ;; game_set_fake) game_set_fake "$1" ;; game_toggle_xtreme) game_toggle_xtreme ;; system_status) system_status ;; system_check_connectivity) system_check_connectivity ;; system_toggle_quic) system_toggle_quic ;; system_toggle_ipv6) system_toggle_ipv6 ;; system_toggle_flow_offloading_fix) system_toggle_flow_offloading_fix ;; system_uninstall_panel) system_uninstall_panel ;; sysinfo_run) sysinfo_run ;; sysinfo_get) sysinfo_get ;; mirror_status) mirror_status ;; mirror_set) mirror_set "$1" ;; exclusions_status) exclusions_status ;; exclusions_toggle) exclusions_toggle "$1" ;; exclusions_clear) exclusions_clear ;; nfqws_opt_get) nfqws_opt_get ;; zapret_lists_status) zapret_lists_status ;; zapret_list_get) zapret_list_get "$1" ;; zapret_list_set) zapret_list_set "$1" "$2" ;; zapret_list_restore) zapret_list_restore "$1" ;; nfqws_opt_set) nfqws_opt_set "$1" ;; tg_status) tg_status ;; tg_action) tg_action "$1" "$2" ;; tg_auto_tick) tg_auto_tick "$1" ;; zapret_excl_tick) zapret_excl_tick ;; tg_restart_all) tg_restart_all ;; tgws_status) tgws_status ;; tgws_action) tgws_action "$1" ;; hosts_status) hosts_status ;; hosts_toggle) hosts_toggle "$1" ;; hosts_replace_geohide) hosts_replace_geohide "$1" ;; hosts_reset) hosts_reset ;; hosts_file_get) hosts_file_get ;; hosts_file_set) hosts_file_set "$1" ;; doh_install) doh_install ;; doh_remove) doh_remove ;; doh_status) doh_status ;; doh_set) doh_set "$1" ;; doh_bootstrap_set) doh_bootstrap_set "$1" ;; doh_force_set) doh_force_set "$1" ;; test_status) test_status ;; test_action) test_action "$1" "$2" ;; test_results) test_results "$1" ;; zm_update_status) zm_update_status ;; zm_update_action) zm_update_action ;; mixomo_status) mixomo_status ;; mixomo_action) mixomo_action "$1" ;; mixomo_config_get) mixomo_config_get ;; mixomo_config_set) mixomo_config_set "$1" ;; mixomo_subscription_set) mixomo_subscription_set "$1" ;; mixomo_filter_set) mixomo_filter_set "$1" ;; mixomo_proxies) mixomo_proxies ;; mixomo_magitrickle_list_set) mixomo_magitrickle_list_set "$1" ;; mixomo_autorestart_set) mixomo_autorestart_set "$1" "$2" ;; mixomo_ui_action) mixomo_ui_action "$1" ;; mixomo_warp_status) mixomo_warp_status ;; 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 ;; health) health ;; versions) versions_status "$1" ;; redbtn_panel_gone) redbtn_panel_gone ;; awg_status) awg_status ;; awg_action) awg_action "$1" "$2" ;; steer_status) steer_status ;; steer_action) steer_action "$1" "$2" ;; bytetube_action) bytetube_action "$1" ;; forkop_status) forkop_status ;; forkop_config_get) forkop_config_get ;; forkop_config_set) forkop_config_set "$1" ;; forkop_action) forkop_action "$1" "$2" ;; lan_ip) _zm_lan_ip ;; jobs_cancel) jobs_cancel "$1" ;; system_reboot) system_reboot ;; rpcd_watch) rpcd_watch ;; zm_watch) zm_watch ;; ui_theme_get) ui_theme_get ;; ui_theme_set) ui_theme_set "$1" ;; *) echo '{"error":"неизвестная команда"}'; exit 1 ;; esac ZM_INSTALLER_EOF chmod 0755 '/opt/zapret-manager-luci/backend.sh.zm-new' mv -f '/opt/zapret-manager-luci/backend.sh.zm-new' '/opt/zapret-manager-luci/backend.sh' cat > '/opt/zapret-manager-luci/netshift.uc' << 'ZM_INSTALLER_EOF' let fs = require("fs"); let uci = require("uci"); const CFG = "netshift"; const BIN = getenv("ZM_FK_BIN") || "/usr/bin/netshift"; const NS_LIB = getenv("ZM_FK_LIB") || "/usr/lib/netshift"; const SB_CONF = getenv("ZM_FK_SBCONF") || "/etc/sing-box/config.json"; const NFT_TABLE = "NetShiftTable"; const LEASES = getenv("ZM_FK_LEASES") || "/tmp/dhcp.leases"; const NETDIR = getenv("ZM_FK_NETDIR") || "/sys/class/net"; const STATE = getenv("ZM_FK_STATE") || "/opt/zapret-manager-luci/state"; const RS_CACHE = getenv("ZM_FK_RSCACHE") || "/tmp/zm-fk-rs"; const SERVICES_BASE = [ "russia_inside", "russia_outside", "ukraine_inside", "geoblock", "block", "porn", "news", "anime", "youtube", "hdrezka", "tiktok", "google_ai", "google_play", "hodca", "discord", "meta", "twitter", "cloudflare", "cloudfront", "digitalocean", "hetzner", "ovh", "telegram", "roblox" ]; const EXTRA_SRS = { ads_hagezi_pro: "https://github.com/zxc-rv/ad-filter/releases/latest/download/adlist.srs", supercell: "https://raw.githubusercontent.com/ushan0v/sing-box-supercell-ruleset/main/supercell.srs", github: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/sing/geo/geosite/github.srs" }; const ST_DIR = getenv("ZM_FK_STDIR") || "/etc/zm-steer"; const RB_CONF = getenv("ZM_FK_RBCONF") || "/usr/share/zm-redbtn/services.conf"; const ITD_RAW = "https://raw.githubusercontent.com/itdoginfo/allow-domains/main"; const SRS_MAIN = "https://github.com/itdoginfo/allow-domains/releases/latest/download"; const ITD_SUBNETS = { cloudflare: true, cloudfront: true, digitalocean: true, discord: true, google_meet: true, hetzner: true, meta: true, ovh: true, roblox: true, telegram: true, twitter: true }; const CAT_SKIP = { mydyson: true }; const SCHEMES = /^(vless|vmess|trojan|ss|socks4|socks4a|socks5|hysteria2|hy2):\/\/[^ \t\r\n]+$/i; const BYPASS = "zm_bypass"; const SUB_IVS = [ "30m", "1h", "3h", "6h", "12h", "1d" ]; const LIST_IVS = [ "1h", "3h", "12h", "1d", "3d" ]; const LINK_KEYS = [ "proxy_string", "selector_proxy_links", "urltest_proxy_links", "selector_proxy_links_text", "urltest_proxy_links_text" ]; function s(v) { return v == null ? "" : "" + v; } function out(v) { print(sprintf("%J", v), "\n"); } function fail(msg) { out({ error: msg }); exit(1); } function arr(v) { if (v == null) return []; if (type(v) == "array") return filter(map(v, (x) => trim(s(x))), (x) => x != ""); let t = trim(s(v)); return t == "" ? [] : [ t ]; } function words(v) { let r = []; for (let x in arr(v)) for (let w in split(x, /[ \t\r\n]+/)) if (w != "") push(r, w); return r; } function lines(v) { let r = []; for (let x in (type(v) == "array" ? v : [ v ])) for (let l in split(s(x), /[\r\n]+/)) { l = trim(l); if (l != "" && !match(l, /^(#|\/\/)/)) push(r, l); } return r; } function tokens(v) { let r = []; for (let l in lines(v)) for (let w in split(replace(l, /[ \t]+(#|\/\/).*$/, ""), /[ \t,;]+/)) if (w != "") push(r, w); return r; } function uniq(list) { let seen = {}, r = []; for (let x in list) if (!seen[x]) { seen[x] = true; push(r, x); } return r; } function rawlist(v) { if (v == null) return []; if (type(v) != "array") v = [ v ]; return filter(map(v, (x) => s(x)), (x) => x != ""); } function sh(cmd) { let p = fs.popen(cmd + " 2>/dev/null", "r"); if (!p) return ""; let d = p.read("all"); p.close(); return s(d); } function jcmd(cmd) { let t = trim(sh(cmd)); if (t == "") return null; try { return json(t); } catch (e) {} let i = rindex(t, "\n{"); if (i >= 0) try { return json(substr(t, i + 1)); } catch (e) {} return null; } function q(v) { return "'" + replace(s(v), /'/g, "'\\''") + "'"; } function cursor() { let dir = getenv("ZM_FK_CONFDIR"); let c = dir ? uci.cursor(dir) : uci.cursor(); c.load(CFG); return c; } function sections(c, t) { let r = []; c.foreach(CFG, t, (x) => { push(r, x); }); return r; } function services_list() { let txt = s(fs.readfile(NS_LIB + "/constants.sh")); let m = match(txt, /\nCOMMUNITY_SERVICES="([^"]*)"/); let l = m ? filter(split(trim(m[1]), /[ \t]+/), (x) => match(x, /^[a-z0-9_]+$/)) : []; return length(l) ? l : SERVICES_BASE; } /* ---------- catalog of lists (shared with Steer) ---------- */ function latest_url(u) { return replace(s(u), /^(https:\/\/github\.com\/[^\/]+\/[^\/]+\/releases\/)download\/[^\/]+\//, "$1latest/download/"); } function kind_hint(u) { u = lc(s(u)); for (let w in [ "geosite", "domain", "adguard", "filter" ]) if (index(u, w) >= 0) return "domains"; for (let w in [ "geoip", "subnet", "cidr" ]) if (index(u, w) >= 0) return "subnets"; return "unknown"; } function itd_name(u) { let m = match(s(u), /allow-domains\/.*\/([A-Za-z0-9_]+)\.srs$/); return m ? lc(m[1]) : null; } function itd_plain(n) { let t = [ "l:" + ITD_RAW + "/Services/" + n + ".lst" ]; if (ITD_SUBNETS[n]) push(t, "l:" + ITD_RAW + "/Subnets/IPv4/" + n + ".lst"); return t; } function set_tokens(set, known) { if (known[set]) return [ "c:" + set ]; if (EXTRA_SRS[set]) return [ "s:" + EXTRA_SRS[set] ]; return itd_plain(set); } function idx_tokens(e, known) { let n = itd_name(e.url); if (n && known[n]) return [ "c:" + n ]; if (e.fmt == "lst") return [ "l:" + e.url ]; if (n) return itd_plain(n); let u = latest_url(e.url); if (e.kind == "d") return [ "s:" + u ]; if (kind_hint(u) == "domains") return []; return [ "r:" + u ]; } function catalog_items(known_list) { let known = {}, items = [], seen = {}, idx = {}; for (let k in known_list) known[k] = true; for (let l in split(s(fs.readfile(RB_CONF)), "\n")) { if (l == "" || substr(l, 0, 1) == "#") continue; let f = split(l, "|"), id = f[0], t = []; if (!id || id == "custom" || seen[id]) continue; if (s(f[2]) + s(f[3]) + s(f[7]) == "") continue; if (id == "github") t = set_tokens("github", known); else for (let set in split(s(f[7]), ",")) { if (set == "") continue; t = [ ...t, ...set_tokens(set, known) ]; } if (!length(t)) continue; seen[id] = true; push(items, { id, name: s(f[1]) || id, group: "", targets: uniq(t) }); } if (fs.access(ST_DIR + "/catalog.off")) return items; for (let l in split(s(fs.readfile(ST_DIR + "/catalog.idx")), "\n")) { let f = split(l, "|"); if (length(f) >= 3 && f[0] != "" && match(f[2], /^https:\/\//)) idx[f[0]] = { fmt: f[1], url: f[2], kind: s(f[3]) }; } for (let l in split(s(fs.readfile(ST_DIR + "/services.remote")), "\n")) { let f = split(l, "|"), id = f[0], grp = s(f[8]), t = []; if (!id || seen[id] || grp == "" || grp == "Свои списки каталога") continue; if (CAT_SKIP[replace(id, /^c_/, "")]) continue; for (let k in split(s(f[7]), ",")) if (idx[k]) t = [ ...t, ...idx_tokens(idx[k], known) ]; if (!length(t)) continue; seen[id] = true; push(items, { id, name: s(f[1]) || id, group: grp, targets: uniq(t) }); } return items; } function cat_meta() { try { let j = json(s(getenv("ZM_FK_CAT"))); return type(j) == "object" ? j : null; } catch (e) { return null; } } /* ---------- links ---------- */ function link_xhttp(x) { let h = split(x, "#"); if (match(x, /^vmess:\/\//i)) { let d = null; let b = replace(replace(trim(substr(h[0], 8)), /-/g, "+"), /_/g, "/"); while (length(b) % 4) b += "="; try { d = json(b64dec(b)); } catch (e) { d = null; } return (type(d) == "object" && lc(s(d.net)) == "xhttp") ? (s(d.ps) || s(d.add) || "vmess") : null; } if (!match(h[0], /[?&]type=xhttp(&|$)/i)) return null; return length(h) > 1 && h[1] != "" ? replace(h[1], /%20/g, " ") : split(split(h[0], "@")[1] || h[0], "?")[0]; } function sb_extended() { return index(sh("sing-box version"), "extended") >= 0; } function link_name(l) { if (match(l, /^vmess:\/\//i)) { let d = null, b = replace(replace(trim(substr(split(l, "#")[0], 8)), /-/g, "+"), /_/g, "/"); while (length(b) % 4) b += "="; try { d = json(b64dec(b)); } catch (e) { d = null; } if (type(d) == "object" && s(d.ps) != "") return s(d.ps); } let i = index(l, "#"); if (i < 0) return ""; let t = replace(substr(l, i + 1), /\+/g, " "); return replace(t, /%([0-9A-Fa-f]{2})/g, (all, h) => chr(hex(h))); } function link_host(l) { let m = match(split(l, "#")[0], /^[a-z0-9]+:\/\/(?:[^@\/?#]*@)?(\[[^\]]+\]|[^:\/?#]+)(?::([0-9]+))?/i); return m ? m[1] + (m[2] ? ":" + m[2] : "") : ""; } /* ---------- server filter (markers and hidden names) ---------- */ const CYR_UP = split("А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я", " "); const CYR_LO = split("а б в г д е ё ж з и й к л м н о п р с т у ф х ц ч ш щ ъ ы ь э ю я", " "); function fold(v) { v = lc(s(v)); for (let k = 0; k < length(CYR_UP); k++) v = replace(v, CYR_UP[k], CYR_LO[k]); return v; } function unfold_up(v) { v = uc(s(v)); for (let k = 0; k < length(CYR_LO); k++) v = replace(v, CYR_LO[k], CYR_UP[k]); return v; } function cap_first(v) { v = s(v); if (v == "") return v; let b = ord(v, 0), n = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1; return unfold_up(substr(v, 0, n)) + substr(v, n); } function excl_list(v) { let res = [], seen = {}; for (let x in split(s(v), /[|,]/)) { x = fold(trim(x)); if (x == "" || seen[x]) continue; seen[x] = true; push(res, x); } return res; } function excl_vars(m) { return { m: excl_list(m.zm_exclude), h: rawlist(m.zm_hide) }; } function excl_any(f) { return length(f.m) > 0 || length(f.h) > 0; } function excl_hit(name, f) { name = s(name); if (index(f.h, name) >= 0) return true; name = fold(name); for (let x in f.m) if (x != "" && index(name, x) >= 0) return true; return false; } function filter_keywords(f) { let r = []; for (let x in f.m) r = [ ...r, x, cap_first(x), unfold_up(x) ]; return uniq([ ...r, ...f.h ]); } /* ---------- reading sections ---------- */ function is_conn(x) { let t = s(x.connection_type); return x[".name"] != BYPASS && (t == "proxy" || t == "vpn"); } function pick(c) { let all = sections(c, "section"), sec = null; for (let x in all) if (x[".name"] == "main" && is_conn(x)) sec = "main"; if (!sec) for (let x in all) if (is_conn(x)) { sec = x[".name"]; break; } if (!sec) sec = "main"; let extra = [], conns = []; for (let x in all) { if (x[".name"] == BYPASS) continue; if (is_conn(x)) { if (x[".name"] != sec) push(conns, x[".name"]); continue; } push(extra, { name: x[".name"], label: s(x.zm_label || x[".name"]), action: s(x.connection_type), enabled: s(x.disabled) != "1" }); } return { sec, extra, conns }; } function sec_links(m) { let l = rawlist(m.zm_links); if (length(l)) return l; for (let k in LINK_KEYS) { let v = k == "proxy_string" ? arr(m[k]) : (index(k, "_text") >= 0 ? lines(m[k]) : words(m[k])); if (length(v)) return v; } return []; } function visible_links(m, f) { return filter(sec_links(m), (l) => !excl_hit(link_name(l), f)); } function sec_mode(m) { if (s(m.connection_type) == "vpn") return "iface"; let t = s(m.proxy_config_type || "url"); if (t == "subscription") return "sub"; if (t == "outbound") return "json"; return "links"; } function sub_urls(m) { return uniq(words(m.subscription_url)); } function domain_lines(m) { let t = s(m.user_domain_list_type || "disabled"); if (t == "text") return uniq(tokens(m.user_domains_text)); if (t == "dynamic") return uniq(words(m.user_domains)); return []; } function subnet_lines(m) { let t = s(m.user_subnet_list_type || "disabled"); if (t == "text") return uniq(tokens(m.user_subnets_text)); if (t == "dynamic") return uniq(words(m.user_subnets)); return []; } function url_ext(u) { let m = match(lc(replace(s(u), /[?#].*$/, "")), /\.([a-z0-9]+)$/); return m ? m[1] : ""; } function is_rs(u) { let e = url_ext(u); return e == "srs" || e == "json"; } function sec_refs(m) { let r = { c: uniq(words(m.community_lists)), s: [], r: [], l: [] }, zr = rawlist(m.zm_refs); if (length(zr)) { for (let t in zr) { let k = substr(t, 0, 1), v = substr(t, 2); if (substr(t, 1, 1) == ":" && r[k] && k != "c" && v != "") push(r[k], v); } } else { let d = uniq(words(m.remote_domain_lists)), sn = uniq(words(m.remote_subnet_lists)); for (let u in d) { if (!is_rs(u)) push(r.l, u); else if (index(sn, u) >= 0) push(r.r, u); else push(r.s, u); } for (let u in sn) if (index(d, u) < 0) push(is_rs(u) ? r.r : r.l, u); for (let u in [ ...words(m.local_domain_lists), ...words(m.local_subnet_lists) ]) push(r.l, u); } r.s = uniq(r.s); r.r = uniq(r.r); r.l = uniq(r.l); return r; } function sec_configured(m) { let mode = sec_mode(m); if (mode == "iface") return s(m.interface) != ""; if (mode == "sub") return length(sub_urls(m)) > 0; if (mode == "json") return s(m.outbound_json) != ""; return length(sec_links(m)) > 0; } function sec_label(m, main) { let l = trim(s(m.zm_label)); return l != "" ? l : main ? "Основная" : s(m[".name"]); } function sec_summary(c, sec, main) { let m = c.get_all(CFG, sec) || {}, mode = sec_mode(m), rf = sec_refs(m); return { name: sec, main, label: sec_label(m, main), enabled: main || s(m.disabled) != "1", mode, iface: s(m.interface), links: length(sec_links(m)), refs: { c: rf.c, s: rf.s, r: rf.r }, lists: rf.l, domains: length(domain_lines(m)), subnets: length(subnet_lines(m)) }; } function sec_list(c, p) { let r = []; for (let x in sections(c, "section")) { let n = x[".name"]; if (n == p.sec) push(r, sec_summary(c, n, true)); else if (index(p.conns, n) >= 0) push(r, sec_summary(c, n, false)); } return r; } function bypass_info(c) { let m = c.get_all(CFG, BYPASS) || {}; return { domains: domain_lines(m), subnets: subnet_lines(m) }; } function devices() { let r = [], seen = {}; for (let l in split(s(fs.readfile(LEASES)), "\n")) { let f = split(trim(l), /[ \t]+/); if (length(f) < 4 || !match(f[2], /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) || seen[f[2]]) continue; seen[f[2]] = true; push(r, { ip: f[2], mac: lc(f[1]), name: f[3] == "*" ? "" : f[3] }); } return sort(r, (a, b) => { let x = map(split(a.ip, "."), int), y = map(split(b.ip, "."), int); for (let i = 0; i < 4; i++) if (x[i] != y[i]) return x[i] - y[i]; return 0; }); } function tunnels() { let r = []; for (let n in (fs.lsdir(NETDIR) || [])) { if (n == "lo" || match(n, /^(sit|ip6tnl|gre|erspan|teql|ifb|dummy|bond)/)) continue; if (trim(s(fs.readfile(NETDIR + "/" + n + "/type"))) != "65534") continue; let st = trim(s(fs.readfile(NETDIR + "/" + n + "/operstate"))); push(r, { name: n, up: st != "down", steer: !!match(n, /^zmwarp[0-9]*$/) }); } return sort(r, (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); } function dns_info(st) { let t = s(st.dns_type || "udp"); let sv = rawlist(st.zm_dns_servers), bs = rawlist(st.zm_bootstrap), cur = s(st.dns_server), bcur = s(st.bootstrap_dns_server); if (!length(sv)) sv = cur != "" ? [ cur ] : [ "9.9.9.9" ]; if (!length(bs)) bs = bcur != "" ? [ bcur ] : [ "9.9.9.9" ]; let active = index(sv, cur), bactive = index(bs, bcur); if (active < 0) active = 0; if (bactive < 0) bactive = 0; return { type: t, server: sv[0], bootstrap: bs[0], servers: sv, bootstraps: bs, active, bactive, detour: s(st.dns_via_outbound) == "1", failover: length(sv) > 1 }; } function cmd_get(want) { let c = cursor(), p = pick(c), sec = p.sec, main = p.sec; want = s(want); if (want != "" && want != main && index(p.conns, want) >= 0) sec = want; let m = c.get_all(CFG, sec) || {}, st = c.get_all(CFG, "settings") || {}, mm = c.get_all(CFG, main) || {}; let mode = sec_mode(m), rf = sec_refs(m), subs = sub_urls(m); out({ sec, main, is_main: sec == main, main_exists: mm[".type"] != null && sec_configured(mm), label: sec_label(m, sec == main), enabled: sec == main || s(m.disabled) != "1", sections: sec_list(c, p), bypass: bypass_info(c), exists: m[".type"] != null && sec_configured(m), mode: mode == "json" ? "links" : mode, links: sec_links(m), sub: length(subs) ? subs[0] : "", subs, sub_interval: s(m.subscription_update_interval || "1h"), iface: s(m.interface), fastest: true, exclude: s(m.zm_exclude), hide_names: rawlist(m.zm_hide), jsons: mode == "json" ? 1 : 0, refs: { c: rf.c, s: rf.s, r: rf.r }, catalog: { items: catalog_items(services_list()), meta: cat_meta() }, domains: domain_lines(m), subnets: subnet_lines(m), lists: rf.l, full: uniq(words(mm.fully_routed_ips)), excl: uniq(words(st.routing_excluded_ips)), extra: p.extra, dns: dns_info(st), quic_off: s(st.disable_quic) == "1", list_interval: s(st.update_interval || "1d"), list_update: true, lists_via: s(st.download_lists_via_proxy) == "1", devices: devices(), tunnels: tunnels() }); } /* ---------- validation ---------- */ function valid_ip(v) { let m = match(v, /^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)(\/([0-9]+))?$/); if (!m) return false; for (let i = 1; i <= 4; i++) if (int(m[i]) > 255) return false; return m[6] == null || int(m[6]) <= 32; } function dns_value_ok(t, v) { if (v == "" || match(v, /[ \t@|,;'"<>]/)) return false; if (t == "udp") return valid_ip(replace(v, /:[0-9]+$/, "")) && index(v, "/") < 0; if (t == "dot") return !match(v, /^[a-z]+:\/\//i) && index(v, "/") < 0 && !!match(v, /^[A-Za-z0-9.-]+(:[0-9]+)?$/) && !!match(v, /\./); if (match(v, /^https:\/\//i)) return !!match(v, /^https:\/\/[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:[0-9]+)?(\/[^ ]*)?$/i); return !!match(v, /^[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:[0-9]+)?$/); } function dns_norm(t, v) { if (t == "doh" && !match(v, /^https:\/\//i)) return "https://" + v + "/dns-query"; return v; } function non_ascii(v) { for (let i = 0; i < length(v); i++) if (ord(v, i) > 127) return true; return false; } /* NetShift accepts plain domains only: lowercase letters, digits, hyphens and dots. */ function norm_domain(v) { v = lc(trim(s(v))); let m = match(v, /^full:(.+)$/); if (m) v = m[1]; v = replace(v, /^\*\./, ""); return v; } function domain_err(raw) { let v = norm_domain(raw); if (match(lc(s(raw)), /^(keyword|regex|domain_keyword|domain_regex):/)) return "«" + raw + "»: keyword: и regex: NetShift не поддерживает — впишите домен целиком, например example.com"; if (non_ascii(v)) return "«" + raw + "»: русские домены пишите в punycode — например, xn--h1alffa9f.xn--p1ai вместо россия.рф"; if (!match(replace(v, /^\./, ""), /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/)) return "это не похоже на домен: " + raw; return null; } function valid_url(v) { return !!match(v, /^https?:\/\/[^ \t\/]+(\/[^ \t]*)?$/i); } function set_list(c, sec, key, list) { if (length(list)) c.set(CFG, sec, key, list); else c.delete(CFG, sec, key); } function set_text(c, sec, key, list) { if (length(list)) c.set(CFG, sec, key, join("\n", list)); else c.delete(CFG, sec, key); } function del_keys(c, sec, keys) { for (let k in keys) c.delete(CFG, sec, k); } /* Writes what NetShift should use for a section's servers, applying the server filter. */ function apply_servers(c, sec) { let m = c.get_all(CFG, sec) || {}, mode = sec_mode(m), f = excl_vars(m); if (mode == "links") { let vis = visible_links(m, f); del_keys(c, sec, LINK_KEYS); if (length(vis) == 1) { c.set(CFG, sec, "proxy_config_type", "selector"); c.set(CFG, sec, "selector_proxy_links", vis); } else if (length(vis) > 1) { c.set(CFG, sec, "proxy_config_type", "urltest"); c.set(CFG, sec, "urltest_proxy_links", vis); } c.delete(CFG, sec, "subscription_filter_exclude_keywords"); return length(vis); } if (mode == "sub") { let kw = filter_keywords(f); set_list(c, sec, "subscription_filter_exclude_keywords", kw); } return -1; } function set_lists(c, sec, rf, services, domains, subnets) { set_list(c, sec, "community_lists", services); let zr = []; for (let k in [ "s", "r", "l" ]) for (let v in rf[k]) push(zr, k + ":" + v); set_list(c, sec, "zm_refs", zr); set_list(c, sec, "remote_domain_lists", uniq([ ...rf.s, ...rf.r, ...rf.l ])); set_list(c, sec, "remote_subnet_lists", uniq([ ...rf.r, ...rf.l ])); del_keys(c, sec, [ "local_domain_lists", "local_subnet_lists", "user_domains", "user_subnets" ]); if (length(domains)) { c.set(CFG, sec, "user_domain_list_type", "text"); c.set(CFG, sec, "user_domains_text", join("\n", domains)); } else { c.set(CFG, sec, "user_domain_list_type", "disabled"); c.delete(CFG, sec, "user_domains_text"); } if (length(subnets)) { c.set(CFG, sec, "user_subnet_list_type", "text"); c.set(CFG, sec, "user_subnets_text", join("\n", subnets)); } else { c.set(CFG, sec, "user_subnet_list_type", "disabled"); c.delete(CFG, sec, "user_subnets_text"); } } function parse_domains(v) { let r = []; for (let x in uniq(tokens(v))) { let e = domain_err(x); if (e) fail(e); push(r, norm_domain(x)); } return uniq(r); } function cmd_set() { let raw = fs.stdin.read("all"), d; try { d = json(s(raw)); } catch (e) { fail("не удалось прочитать настройки"); } if (type(d) != "object") fail("пустые настройки"); let c = cursor(), p0 = pick(c), sec = s(d.sec) || p0.sec, created = false; if (sec == "new") { if (c.get(CFG, p0.sec) != "section" || !sec_configured(c.get_all(CFG, p0.sec))) fail("сначала настройте основную секцию"); if (length(p0.conns) >= 8) fail("секций можно сделать не больше 9"); let i = 1; while (c.get(CFG, "zm_s" + i) != null) i++; sec = "zm_s" + i; created = true; } if (!match(sec, /^[A-Za-z0-9_]+$/) || sec == BYPASS || sec == "settings") fail("неверное имя секции"); let st = c.get(CFG, sec); if (st != null && st != "section") fail("неверное имя секции"); if (st != null && !is_conn(c.get_all(CFG, sec)) && s(c.get(CFG, sec, "connection_type")) != "") fail("эту секцию Forkozz не редактирует"); let is_main = sec == p0.sec, mainsec = p0.sec; if (!is_main && (c.get(CFG, mainsec) != "section" || !sec_configured(c.get_all(CFG, mainsec)))) fail("сначала настройте основную секцию"); let label = trim(s(d.label)); if (match(label, /[\r\n\t]/) || length(label) > 60) fail("название секции — одна строка до 60 символов"); if (!is_main && label == "") fail("дайте секции название"); if (label != "") for (let x in sec_list(c, p0)) if (x.name != sec && fold(x.label) == fold(label)) fail("секция «" + label + "» уже есть — выберите другое название"); let mode = s(d.mode); if (index([ "links", "sub", "iface" ], mode) < 0) fail("неизвестный способ подключения"); let links = []; for (let l in lines(d.links)) for (let w in split(l, /[ \t]+/)) { if (w == "") continue; if (!length(links) || match(w, /^[A-Za-z0-9]+:\/\//)) push(links, w); else links[length(links) - 1] += "%20" + w; } links = uniq(links); for (let x in links) if (!match(x, SCHEMES)) fail(match(x, /^(tuic|http|https|anytls|wireguard|hysteria):\/\//i) ? "NetShift не поддерживает такие ссылки: " + substr(x, 0, 40) + " — подойдут vless, vmess, trojan, ss, socks5, hysteria2" : "ссылка не поддерживается: " + substr(x, 0, 40)); if (mode == "links" && length(links) && !sb_extended()) { let xh = filter(map(links, link_xhttp), (x) => x != null); if (length(xh)) fail("сервер «" + xh[0] + "» работает через XHTTP — выберите расширенный sing-box в Настройках"); let vm = filter(links, (x) => match(x, /^vmess:\/\//i)); if (length(vm)) fail("сервер «" + (link_name(vm[0]) || "vmess") + "» — VMess, он работает только на расширенном sing-box. Выберите его в Настройках"); } let sub = trim(s(d.sub)), iface = trim(s(d.iface)); if (mode == "links" && !length(links)) fail("добавьте хотя бы одну ссылку на сервер"); if (mode == "sub" && !valid_url(sub)) fail("ссылка на подписку должна начинаться с https://"); if (mode == "iface" && !match(iface, /^[A-Za-z0-9_.@-]{1,15}$/)) fail("выберите сетевой интерфейс"); let known = services_list(), refs = type(d.refs) == "object" ? d.refs : {}; let services = filter(uniq(words(refs.c)), (x) => index(known, x) >= 0); let rsets = uniq(words(refs.s)), rsubs = uniq(words(refs.r)), rplain = uniq(words(refs.l)); for (let x in uniq(words(refs.c))) if (index(known, x) < 0 && EXTRA_SRS[x]) push(rsets, EXTRA_SRS[x]); rsets = uniq(rsets); for (let x in [ ...rsets, ...rsubs ]) if (!match(x, /^https?:\/\/[^ \t]+$/i)) fail("неверная ссылка на набор правил: " + substr(x, 0, 60)); for (let x in rplain) if (!valid_url(x)) fail("неверная ссылка на список: " + substr(x, 0, 60)); let domains = parse_domains(d.domains); let subnets = uniq(tokens(d.subnets)); for (let x in subnets) if (!valid_ip(x)) fail("это не похоже на IP или подсеть: " + x); let lists = uniq(tokens(d.lists)); for (let x in lists) if (!valid_url(x)) fail("внешний список должен быть ссылкой https://…: " + x); lists = uniq([ ...lists, ...rplain ]); let full = uniq(tokens(d.full)), excl = uniq(tokens(d.excl)); for (let x in [ ...full, ...excl ]) if (!valid_ip(x)) fail("неверный адрес устройства: " + x); excl = filter(excl, (x) => index(full, x) < 0); if (!length(services) && !length(rsets) && !length(rsubs) && !length(domains) && !length(subnets) && !length(lists) && !(is_main && length(full))) fail(is_main ? "выберите хотя бы один сервис, домен или устройство" : "выберите для секции хотя бы один сервис или домен"); for (let x in sections(c, "section")) { let n = x[".name"]; if (n == sec || !is_conn(x) || (n != p0.sec && index(p0.conns, n) < 0)) continue; let other = sec_label(x, n == p0.sec), orf = sec_refs(x); for (let v in services) if (index(words(x.community_lists), v) >= 0) fail("список «" + v + "» уже идёт через секцию «" + other + "» — один список можно включить только в одной секции"); for (let v in [ ...rsets, ...rsubs ]) if (index([ ...orf.s, ...orf.r ], v) >= 0) fail("набор правил уже идёт через секцию «" + other + "»: " + substr(v, 0, 60)); let od = domain_lines(x); for (let v in domains) if (index(od, v) >= 0) fail("домен " + v + " уже есть в секции «" + other + "» — оставьте его в одной секции"); let os = subnet_lines(x); for (let v in subnets) if (index(os, v) >= 0) fail("адрес " + v + " уже есть в секции «" + other + "» — оставьте его в одной секции"); } let bp = type(d.bypass) == "object" ? d.bypass : null, bdom = [], bsub = []; if (bp) { for (let x in uniq(tokens(bp.domains))) { let e = domain_err(x); if (e) fail("в исключениях " + e); push(bdom, norm_domain(x)); } bdom = uniq(bdom); bsub = uniq(tokens(bp.subnets)); for (let x in bsub) if (!valid_ip(x)) fail("в исключениях это не похоже на IP или подсеть: " + x); if (length(bdom) + length(bsub) > 2000) fail("исключений слишком много — не больше 2000 строк"); } let xstr = trim(s(d.exclude)); if (mode == "iface") xstr = ""; if (match(xstr, /[\r\n]/) || length(xstr) > 1600) fail("маркеры — одной строкой через |, не длиннее 1600 символов"); let xv = { m: excl_list(xstr), h: mode == "iface" ? [] : rawlist(c.get(CFG, sec, "zm_hide")) }; if (length(xv.m) > 40) fail("маркеров можно указать не больше 40"); if (!length(xv.m)) xstr = ""; if (mode == "links" && excl_any(xv) && !length(filter(links, (l) => !excl_hit(link_name(l), xv)))) fail("под эти маркеры попадают все серверы — тогда Forkozz работать не сможет. Уберите лишние маркеры"); if (!c.get(CFG, sec)) c.set(CFG, sec, "section"); if (label != "") c.set(CFG, sec, "zm_label", label); if (is_main || d.enabled !== false) c.delete(CFG, sec, "disabled"); else c.set(CFG, sec, "disabled", "1"); c.delete(CFG, sec, "global_proxy"); del_keys(c, sec, [ ...LINK_KEYS, "outbound_json", "subscription_url", "interface", "subscription_filter_exclude_keywords", "subscription_filter_include_keywords" ]); if (mode == "iface") { c.set(CFG, sec, "connection_type", "vpn"); c.delete(CFG, sec, "proxy_config_type"); c.set(CFG, sec, "interface", iface); c.set(CFG, sec, "domain_resolver_enabled", "0"); del_keys(c, sec, [ "zm_links", "zm_exclude", "zm_hide", "subscription_update_interval" ]); } else { c.set(CFG, sec, "connection_type", "proxy"); c.delete(CFG, sec, "domain_resolver_enabled"); if (c.get(CFG, sec, "enable_udp_over_tcp") == null) c.set(CFG, sec, "enable_udp_over_tcp", "0"); if (xstr != "") c.set(CFG, sec, "zm_exclude", xstr); else c.delete(CFG, sec, "zm_exclude"); if (mode == "links") { c.set(CFG, sec, "proxy_config_type", "urltest"); c.set(CFG, sec, "zm_links", links); c.delete(CFG, sec, "subscription_update_interval"); } else { c.set(CFG, sec, "proxy_config_type", "subscription"); c.set(CFG, sec, "subscription_url", [ sub ]); let iv = s(d.sub_interval); if (index(SUB_IVS, iv) < 0) iv = "1h"; c.set(CFG, sec, "subscription_update_interval", iv); c.delete(CFG, sec, "zm_links"); } apply_servers(c, sec); } set_lists(c, sec, { s: rsets, r: rsubs, l: lists }, services, domains, subnets); if (is_main || d.full != null) { set_list(c, mainsec, "fully_routed_ips", full); if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); set_list(c, "settings", "routing_excluded_ips", excl); } if (!is_main) c.delete(CFG, sec, "fully_routed_ips"); if (bp) { if (length(bdom) || length(bsub)) { if (c.get(CFG, BYPASS) == null) c.set(CFG, BYPASS, "section"); c.set(CFG, BYPASS, "zm_label", "Исключения"); c.set(CFG, BYPASS, "connection_type", "exclusion"); c.delete(CFG, BYPASS, "disabled"); set_lists(c, BYPASS, { s: [], r: [], l: [] }, [], bdom, bsub); c.delete(CFG, BYPASS, "zm_refs"); c.reorder(CFG, BYPASS, 1); } else if (c.get(CFG, BYPASS) != null) c.delete(CFG, BYPASS); } if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); let dns = type(d.dns) == "object" ? d.dns : null; if (dns) { let t = s(dns.type); if (index([ "udp", "dot", "doh" ], t) < 0) fail("неизвестный тип DNS"); let list = type(dns.servers) == "array" && length(dns.servers) ? dns.servers : [ dns.server ]; let blist = type(dns.bootstraps) == "array" && length(dns.bootstraps) ? dns.bootstraps : [ dns.bootstrap ]; let svs = [], bss = [], seen = {}; for (let x in list) { x = trim(s(x)); if (x == "" || seen[x]) continue; seen[x] = true; if (!dns_value_ok(t, x)) fail(t == "udp" ? "для UDP нужен IP-адрес DNS, например 9.9.9.9 (ошибка в «" + x + "»)" : t == "dot" ? "для DoT нужен адрес сервера без https://, например dns.quad9.net (ошибка в «" + x + "»)" : "для DoH нужен адрес или ссылка https://…/dns-query (ошибка в «" + x + "»)"); push(svs, dns_norm(t, x)); } svs = uniq(svs); if (!length(svs)) fail("укажите хотя бы один DNS-сервер"); if (length(svs) > 8) fail("DNS-серверов можно указать не больше 8"); seen = {}; for (let x in blist) { x = trim(s(x)); if (x == "" || seen[x]) continue; seen[x] = true; if (!valid_ip(x) || index(x, "/") >= 0) fail("Bootstrap DNS — только IPv4-адреса, например 77.88.8.8 (ошибка в «" + x + "»)"); push(bss, x); } if (!length(bss)) push(bss, "9.9.9.9"); if (length(bss) > 4) bss = slice(bss, 0, 4); c.set(CFG, "settings", "dns_type", t); c.set(CFG, "settings", "dns_server", svs[0]); c.set(CFG, "settings", "bootstrap_dns_server", bss[0]); c.set(CFG, "settings", "zm_dns_servers", svs); c.set(CFG, "settings", "zm_bootstrap", bss); if (dns.detour != null) { c.set(CFG, "settings", "dns_via_outbound", dns.detour ? "1" : "0"); if (dns.detour) c.set(CFG, "settings", "dns_outbound_section", mainsec); else c.delete(CFG, "settings", "dns_outbound_section"); } } if (d.quic_off != null) c.set(CFG, "settings", "disable_quic", d.quic_off ? "1" : "0"); if (d.lists_via != null) { c.set(CFG, "settings", "download_lists_via_proxy", d.lists_via ? "1" : "0"); if (d.lists_via) c.set(CFG, "settings", "download_lists_via_proxy_section", mainsec); else c.delete(CFG, "settings", "download_lists_via_proxy_section"); } if (index(LIST_IVS, s(d.list_interval)) >= 0) c.set(CFG, "settings", "update_interval", s(d.list_interval)); c.save(CFG); c.commit(CFG); out({ ok: true, sec, created }); } /* ---------- servers ---------- */ function group_tag(sec) { return sec + "-out"; } function sec_names(c, sec) { let m = c.get_all(CFG, sec) || {}, names = {}; if (sec_mode(m) == "links") { let vis = visible_links(m, excl_vars(m)); for (let i = 0; i < length(vis); i++) { let n = link_name(vis[i]); names[sec + "-" + (i + 1) + "-out"] = n != "" ? n : link_host(vis[i]) || ("сервер " + (i + 1)); } } names[sec + "-urltest-out"] = "Авто"; return names; } function node_info(px, names, t) { let p = px[t] || {}, h = type(p.history) == "array" && length(p.history) ? p.history[length(p.history) - 1] : null; return { name: s(names[t] || t), country: "", type: s(p.type), now: s(p.now || ""), delay: h ? int(h.delay || 0) : -1, members: lc(s(p.type)) == "urltest" && type(p.all) == "array" ? p.all : null }; } function b64any(v) { let b = replace(replace(trim(s(v)), /-/g, "+"), /_/g, "/"); while (length(b) % 4) b += "="; let r = null; try { r = b64dec(b); } catch (e) { r = null; } return r; } function sub_cache_path(sec) { return STATE + "/fk.sub." + sec + ".json"; } /* Reads traffic, expiry and title from the provider's headers (Subscription-Userinfo, Profile-Title). */ function cmd_subinfo(sec) { let c = cursor(), urls = sub_urls(c.get_all(CFG, sec) || {}); if (!length(urls)) { out({ ok: false }); return; } let hdr = sh("curl -sS -m 12 -A 'v2rayN/7.0.0' -D - -o /dev/null " + q(urls[0])); let info = { title: "", used: null, total: null, unlimited: false, expire: null, at: time() }; for (let l in split(hdr, /\r?\n/)) { let m = match(l, /^([A-Za-z-]+):[ \t]*(.*)$/); if (!m) continue; let k = lc(m[1]), v = trim(m[2]); if (k == "subscription-userinfo") { let up = 0, down = 0; for (let kv in split(v, ";")) { let p = match(trim(kv), /^([a-z]+)=([0-9]+)/); if (!p) continue; if (p[1] == "upload") up = int(p[2]); else if (p[1] == "download") down = int(p[2]); else if (p[1] == "total") info.total = int(p[2]); else if (p[1] == "expire") info.expire = int(p[2]) > 0 ? int(p[2]) : null; } info.used = up + down; if (info.total == 0) { info.total = null; info.unlimited = true; } } else if (k == "profile-title") { let bm = match(v, /^base64:(.*)$/); info.title = bm ? s(b64any(bm[1])) : v; } } try { fs.mkdir(STATE); } catch (e) {} fs.writefile(sub_cache_path(sec), sprintf("%J", info)); out({ ok: true }); } function sub_info(sec) { let j = null; try { j = json(s(fs.readfile(sub_cache_path(sec)))); } catch (e) { j = null; } if (type(j) != "object" || time() - int(j.at || 0) > 1800) system("(ucode " + q(sourcepath()) + " subinfo " + q(sec) + " >/dev/null 2>&1 &)"); if (type(j) != "object" || (j.title == "" && j.used == null && j.expire == null)) return null; return { title: s(j.title), used: j.used, total: j.total, unlimited: !!j.unlimited, expire: j.expire }; } const FELL_FILE = getenv("ZM_FK_FELL") || STATE + "/fk.fell"; function fell_file(sec) { let c = cursor(); return sec == "" || sec == pick(c).sec ? FELL_FILE : FELL_FILE + "." + sec; } function proxies() { let j = jcmd(BIN + " clash_api get_proxies"); return j && type(j.proxies) == "object" ? j.proxies : null; } function cmd_servers(sec) { let px = proxies(); if (!px) fail("sing-box не отвечает — Forkozz выключен или ещё запускается"); let g = px[group_tag(sec)]; if (!g || type(g.all) != "array") fail("список серверов ещё не готов — примените настройки"); let c = cursor(), xm = c.get_all(CFG, sec) || {}, xv = excl_vars(xm), names = sec_names(c, sec); let nodes = {}; for (let t in g.all) { nodes[t] = node_info(px, names, t); for (let m in (nodes[t].members || [])) if (!nodes[m]) nodes[m] = node_info(px, names, m); } let fell = ""; try { fell = trim(s(fs.readfile(fell_file(sec)))); } catch (e) { fell = ""; } out({ group: group_tag(sec), now: s(g.now), list: g.all, nodes, sub: sec_mode(xm) == "sub" ? sub_info(sec) : null, hidden: length(xv.h), exclude: s(xm.zm_exclude), hide_names: xv.h, fell }); } function proxy_alive(tag) { let l = jcmd(BIN + " clash_api get_proxy_latency " + q(tag) + " 5000"); return type(l) == "object" && int(l.delay || 0) > 0; } function cmd_heal(sec, act) { let hc = cursor(), hm = hc.get_all(CFG, sec) || {}; if (s(hm.connection_type) != "proxy" || s(hm.disabled) == "1") { out({ ok: true, manual: false, dead: false }); return; } let px = proxies(), g = px ? px[group_tag(sec)] : null; if (!g || type(g.all) != "array" || s(g.now) == "") { out({ ok: false }); return; } let now = s(g.now), names = sec_names(hc, sec); if (px[now] && lc(s(px[now].type)) == "urltest") { out({ ok: true, manual: false, dead: false }); return; } if (proxy_alive(now) || proxy_alive(now)) { out({ ok: true, manual: true, dead: false }); return; } let nm = (t) => s(names[t] || t); if (act != "switch") { out({ ok: true, manual: true, dead: true, name: nm(now) }); return; } let pickt = null; for (let t in g.all) if (px[t] && lc(s(px[t].type)) == "urltest") { pickt = t; break; } if (!pickt) for (let t in g.all) { if (t == now) continue; if (proxy_alive(t)) { pickt = t; break; } } if (!pickt) { out({ ok: true, manual: true, dead: true, changed: false, name: nm(now) }); return; } let r = jcmd(BIN + " clash_api set_group_proxy " + q(group_tag(sec)) + " " + q(pickt)); if (type(r) == "object" && (r.error || r.success === false)) { out({ ok: false, manual: true, dead: true, changed: false, name: nm(now) }); return; } try { fs.writefile(fell_file(sec), nm(now) + "\n"); } catch (e) {} out({ ok: true, manual: true, dead: true, changed: true, name: nm(now), to: px[pickt] && lc(s(px[pickt].type)) == "urltest" ? "" : nm(pickt) }); } function cmd_latency(sec) { let j = jcmd(BIN + " clash_api get_group_latency " + q(group_tag(sec)) + " 5000"); if (type(j) != "object") fail("проверка задержки не удалась"); if (j.message && length(j) == 1) fail(s(j.message)); out({ ok: true, delays: j }); } function cmd_select(sec, tag) { if (s(tag) == "") fail("не выбран сервер"); let j = jcmd(BIN + " clash_api set_group_proxy " + q(group_tag(sec)) + " " + q(tag)); if (type(j) != "object" || j.error || j.success === false) fail(s((j && (j.message || j.error)) || "сервер не переключился")); try { fs.unlink(fell_file(sec)); } catch (e) {} out({ ok: true }); } function cmd_hide(sec) { let raw = fs.stdin.read("all"), d; try { d = json(s(raw)); } catch (e) { fail("не удалось прочитать список"); } if (type(d) != "object") fail("пустой запрос"); let c = cursor(); if (d.sec != null && s(d.sec) != "") { let p = pick(c); sec = s(d.sec); if (sec != p.sec && index(p.conns, sec) < 0) fail("такой секции нет"); } if (c.get(CFG, sec) != "section") fail("Forkozz ещё не настроен"); let m = c.get_all(CFG, sec) || {}, mode = sec_mode(m); if (mode == "iface") fail("у туннеля нет серверов"); let names = []; if (d.reset) { c.delete(CFG, sec, "zm_hide"); c.delete(CFG, sec, "zm_exclude"); } else { let seen = {}; for (let n in (type(d.names) == "array" ? d.names : [])) { n = s(n); if (n == "" || length(n) > 400 || match(n, /[\r\n]/) || seen[n]) continue; seen[n] = true; push(names, n); } if (length(names) > 300) fail("слишком много серверов"); let xstr = null; if (d.markers != null) { xstr = trim(s(d.markers)); if (match(xstr, /[\r\n]/) || length(xstr) > 1600) fail("слишком длинный список фраз"); if (!length(excl_list(xstr))) xstr = ""; } let f = { m: excl_list(xstr != null ? xstr : m.zm_exclude), h: names }; if (mode == "links") { if (!length(filter(sec_links(m), (l) => !excl_hit(link_name(l), f)))) fail("нельзя скрыть все серверы — тогда Forkozz работать не сможет"); } else { let px = proxies(), g = px ? px[group_tag(sec)] : null; if (g && type(g.all) == "array") { let real = filter(g.all, (t) => !(px[t] && lc(s(px[t].type)) == "urltest")); if (length(real) && !length(filter(real, (t) => !excl_hit(t, f)))) fail("нельзя скрыть все серверы — тогда Forkozz работать не сможет"); } } if (length(names)) c.set(CFG, sec, "zm_hide", names); else c.delete(CFG, sec, "zm_hide"); if (xstr != null) { if (xstr != "") c.set(CFG, sec, "zm_exclude", xstr); else c.delete(CFG, sec, "zm_exclude"); } } apply_servers(c, sec); c.save(CFG); c.commit(CFG); out({ ok: true, hidden: length(names) }); } function cmd_fixsel(sec) { out({ ok: true, changed: false }); } function nn_ru(n, one, few, many) { let a = n % 10, h = n % 100; return n + " " + (a == 1 && h != 11 ? one : a >= 2 && a <= 4 && (h < 12 || h > 14) ? few : many); } function cmd_secdel(sec) { let c = cursor(), p = pick(c); if (sec == "" || sec == p.sec) fail("основную секцию удалить нельзя"); if (index(p.conns, sec) < 0) fail("такой секции нет"); c.delete(CFG, sec); for (let k in [ "dns_outbound_section", "download_lists_via_proxy_section" ]) if (s(c.get(CFG, "settings", k)) == sec) c.set(CFG, "settings", k, p.sec); c.save(CFG); c.commit(CFG); try { fs.unlink(sub_cache_path(sec)); } catch (e) {} out({ ok: true }); } function cmd_secnames() { let p = pick(cursor()); print(join("\n", [ p.sec, ...p.conns ]), "\n"); } function cmd_diag() { let checks = []; function add(v, what, why) { push(checks, { verdict: v, what, why: why || "" }); } let en = length(filter(fs.lsdir("/etc/rc.d") || [], (f) => !!match(f, /^S[0-9]+netshift$/))) > 0; let nft = sh("nft list table inet " + NFT_TABLE + " >/dev/null 2>&1 && echo y") != ""; let sbr = sh("pidof sing-box") != ""; add(nft ? "ok" : "fail", "Служба Forkozz", nft ? "запущена" + (en ? " и включена в автозапуск" : ", но не в автозапуске") : "не запущена — нажмите «Включить»"); add(sbr ? "ok" : "fail", "sing-box", sbr ? "работает" : "не работает — посмотрите журнал ниже"); let sb = jcmd(BIN + " get_sing_box_status") || {}; add(int(sb.dns_configured) == 1 ? "ok" : "fail", "DNS роутера", int(sb.dns_configured) == 1 ? "запросы идут через Forkozz" : "dnsmasq не перенаправлен на Forkozz"); let fk = jcmd(BIN + " check_fakeip") || {}; add(fk.fakeip ? "ok" : "warn", "FakeIP", fk.fakeip ? "работает (" + s(fk.IP) + ")" : "тестовый домен не получил адрес FakeIP"); if (sbr) { let c = cursor(), list = filter(sec_list(c, pick(c)), (x) => x.enabled), many = length(list) > 1; for (let x in list) { let l = jcmd(BIN + " clash_api get_proxy_latency " + q(group_tag(x.name)) + " 5000") || {}; let ms = int(l.delay || 0), what = many ? "Секция «" + x.label + "»" : "Подключение к серверу"; let dead = x.mode == "iface" ? "туннель " + x.iface + " не отвечает — проверьте его на вкладке AmneziaWG" : "сервер не отвечает — проверьте ссылку или выберите другой сервер"; add(ms > 0 ? (ms < 1500 ? "ok" : "warn") : "fail", what, ms > 0 ? "отвечает за " + ms + " мс" : dead); } let bp = bypass_info(c), bn = length(bp.domains) + length(bp.subnets); if (bn) add("ok", "Исключения", nn_ru(bn, "строка", "строки", "строк") + " — идут напрямую, мимо всех секций"); } out({ checks }); } /* ---------- per-section state for the Forkozz overview (cheap: no traffic unless probe) ---------- */ function hs_age(dev) { for (let tool in [ "awg", "wg" ]) { let o = sh(tool + " show " + q(dev) + " latest-handshakes 2>/dev/null"); if (o == "") continue; let mx = 0; for (let l in split(o, "\n")) { let f = split(trim(l), /[ \t]+/); if (length(f) >= 2 && int(f[1]) > mx) mx = int(f[1]); } return mx > 0 ? time() - mx : -1; } return null; } function cmd_secstate(probe) { let c = cursor(), p = pick(c), px = proxies(), r = []; for (let x in sec_list(c, p)) { let e = { name: x.name, label: x.label, main: x.main, enabled: x.enabled, mode: x.mode, iface: x.iface, state: "", server: "", delay: -1, auto: false, count: 0, hs: null, up: null, sub: "" }; if (x.mode == "iface") { let ex = !!fs.access(NETDIR + "/" + x.iface); e.up = ex && trim(s(fs.readfile(NETDIR + "/" + x.iface + "/operstate"))) != "down"; e.hs = ex ? hs_age(x.iface) : null; } if (!x.enabled) e.state = "off"; else if (!px) e.state = "stopped"; else if (x.mode == "iface") e.state = !e.up ? "bad" : e.hs == null || (e.hs >= 0 && e.hs < 180) ? "ok" : "bad"; else { let g = px[group_tag(x.name)]; if (!g || type(g.all) != "array") e.state = "wait"; else { let names = sec_names(c, x.name), now = s(g.now), cur = now; if (px[now] && lc(s(px[now].type)) == "urltest") { e.auto = true; cur = s(px[now].now); } e.count = length(filter(g.all, (t) => !(px[t] && lc(s(px[t].type)) == "urltest"))); let ni = node_info(px, names, cur); e.server = cur != "" ? ni.name : ""; e.delay = ni.delay; e.state = ni.delay > 0 ? "ok" : "unknown"; if (x.mode == "sub") { let si = sub_info(x.name); e.sub = si ? s(si.title) : ""; } } } if (probe && px && e.state != "off" && e.state != "wait") { let l = jcmd(BIN + " clash_api get_proxy_latency " + q(group_tag(x.name)) + " 4000") || {}; let ms = int(l.delay || 0); e.probed = true; e.state = ms > 0 ? "ok" : "bad"; if (ms > 0) e.delay = ms; } push(r, e); } out({ running: !!px, sections: r }); } /* ---------- route check: replays sing-box route rules for a domain or an IP ---------- */ function ip4n(a) { let m = match(s(a), /^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$/); return m ? ((int(m[1]) * 256 + int(m[2])) * 256 + int(m[3])) * 256 + int(m[4]) : null; } function cidr_hit(ip, cidrs) { let n = ip4n(ip); if (n == null) return false; for (let c in (type(cidrs) == "array" ? cidrs : [ cidrs ])) { let p = split(s(c), "/"), b = ip4n(p[0]), bits = length(p) > 1 ? int(p[1]) : 32; if (b == null || bits < 0 || bits > 32) continue; let div = 1; for (let i = bits; i < 32; i++) div *= 2; if (int(n / div) == int(b / div)) return true; } return false; } function as_arr(v) { return v == null ? [] : type(v) == "array" ? v : [ v ]; } function dom_hit(host, r) { for (let d in as_arr(r.domain)) if (host == lc(d)) return true; for (let d in as_arr(r.domain_suffix)) { d = lc(replace(s(d), /^\./, "")); if (host == d || (length(host) > length(d) && substr(host, length(host) - length(d) - 1) == "." + d)) return true; } for (let d in as_arr(r.domain_keyword)) if (index(host, lc(d)) >= 0) return true; for (let d in as_arr(r.domain_regex)) { try { if (match(host, regexp(s(d)))) return true; } catch (e) {} } return false; } function rs_file(rs) { if (s(rs.type) == "local") return { path: s(rs.path), fmt: s(rs.format || "source") }; if (s(rs.type) != "remote" || s(rs.url) == "") return null; let name = RS_CACHE + "/" + replace(s(rs.tag), /[^A-Za-z0-9_.-]/g, "_") + (s(rs.format) == "binary" ? ".srs" : ".json"); let st = fs.stat(name); if (!st || time() - st.mtime > 86400) { try { fs.mkdir(RS_CACHE); } catch (e) {} sh("curl -sSL -m 25 -o " + q(name + ".tmp") + " " + q(rs.url) + " && mv -f " + q(name + ".tmp") + " " + q(name)); st = fs.stat(name); } return st ? { path: name, fmt: s(rs.format || "binary") } : null; } function rs_hit(tag, target, sets) { let rs = sets[tag]; if (!rs) return false; let f = rs_file(rs); if (!f) return false; let r = sh("sing-box rule-set match -f " + q(f.fmt) + " " + q(f.path) + " " + q(target) + " 2>&1 | cat"); return !!match(r, /(^|\n)[^\n]*match rules/); } function rule_hit(r, host, ip, sets) { if (r.source_ip_cidr != null || r.protocol != null || r.port != null || r.inbound == null && r.outbound == null) return false; if (r.type == "logical") return false; let has = false; if (host != "" && (r.domain != null || r.domain_suffix != null || r.domain_keyword != null || r.domain_regex != null)) { has = true; if (dom_hit(host, r)) return true; } if (ip != "" && r.ip_cidr != null) { has = true; if (cidr_hit(ip, as_arr(r.ip_cidr))) return true; } for (let t in as_arr(r.rule_set)) { has = true; if (host != "" && rs_hit(t, host, sets)) return true; if (ip != "" && rs_hit(t, ip, sets)) return true; } return false; } function cmd_route(target, addr) { let cfgj = null; try { cfgj = json(s(fs.readfile(SB_CONF))); } catch (e) { cfgj = null; } if (type(cfgj) != "object" || type(cfgj.route) != "object") { out({ action: "", outbound: "", section: "", rule: "" }); return; } target = lc(s(target)); let isip = !!match(target, /^[0-9.]+$/) || index(target, ":") >= 0; let host = isip ? "" : target, ip = isip ? target : (ip4n(addr) != null && !match(s(addr), /^198\.1[89]\./) ? s(addr) : ""); let sets = {}; for (let rs in as_arr(cfgj.route.rule_set)) sets[s(rs.tag)] = rs; let c = cursor(), p = pick(c), labels = {}; for (let x in sec_list(c, p)) labels[group_tag(x.name)] = x; for (let r in as_arr(cfgj.route.rules)) { if (type(r) != "object" || index([ "route", "reject" ], s(r.action || "route")) < 0) continue; if (r.domain == "ip.podkop.fyi" || r.domain == "fakeip.podkop.fyi") continue; if (!rule_hit(r, host, ip, sets)) continue; if (s(r.action) == "reject") { let bs = null; for (let rt in as_arr(r.rule_set)) for (let k in keys(labels)) { let x = labels[k]; if (!bs && index(s(rt), x.name + "-") == 0) bs = x; } out({ action: "block", outbound: "", section: bs ? bs.name : "", rule: bs ? bs.label : "" }); return; } let ob = s(r.outbound); if (ob == "direct-out") { out({ action: "bypass", outbound: ob, section: BYPASS, rule: "Исключения" }); return; } let x = labels[ob]; out({ action: x ? "connection" : "outbound", outbound: ob, section: x ? x.name : "", rule: x ? x.label : ob }); return; } out({ action: "direct", outbound: "direct-out", section: "", rule: "" }); } /* ---------- migration from a Forkop configuration ---------- */ function cmd_import(dir) { let oc = uci.cursor(dir); oc.load("forkop"); let c = cursor(), known = services_list(), notes = [], all = []; oc.foreach("forkop", "section", (x) => { push(all, x); }); let kids = (t, n) => { let r = []; oc.foreach("forkop", t, (x) => { if (s(x.section) == n) push(r, x); }); return r; }; let ost = oc.get_all("forkop", "settings") || {}; let main = null; for (let x in all) if (x[".name"] == "main") main = "main"; if (!main) for (let x in all) { let a = s(x.action || "connection"); if (index([ "connection", "proxy", "vpn", "outbound" ], a) >= 0) { main = x[".name"]; break; } } for (let x in sections(c, "section")) c.delete(CFG, x[".name"]); if (!c.get(CFG, "settings")) c.set(CFG, "settings", "settings"); let olddom = (m) => { let r = []; for (let k in [ "domain", "domain_suffix_text", "domain_text" ]) r = [ ...r, ...lines(m[k]) ]; for (let k in [ "domain_suffix" ]) r = [ ...r, ...words(m[k]) ]; let ok = []; for (let x in uniq(r)) { if (domain_err(x)) { push(notes, "пропущен домен «" + x + "» (NetShift понимает только обычные домены)"); continue; } push(ok, norm_domain(x)); } if (length(words(m.domain_keyword)) || length(words(m.domain_regex))) push(notes, "keyword: и regex: из секции «" + s(m.label || m[".name"]) + "» не перенесены — NetShift их не поддерживает"); return uniq(ok); }; let oldsub = (m) => uniq([ ...lines(m.ip_cidr), ...lines(m.ip_cidr_text) ]); let oldrefs = (m) => { let rf = { s: uniq(words(m.rule_set)), r: uniq(words(m.rule_set_with_subnets)), l: [] }, svc = []; for (let v in uniq(words(m.community_lists))) { if (index(known, v) >= 0) push(svc, v); else if (EXTRA_SRS[v]) push(rf.s, EXTRA_SRS[v]); else push(notes, "список «" + v + "» NetShift не знает — пропущен"); } for (let u in uniq([ ...words(m.domain_ip_lists), ...words(m.remote_domain_lists), ...words(m.remote_subnet_lists) ])) { if (!valid_url(u)) { push(notes, "локальный список " + u + " не перенесён — укажите ссылку https://"); continue; } push(is_rs(u) ? (index(words(m.remote_subnet_lists), u) >= 0 ? rf.r : rf.s) : rf.l, u); } rf.s = uniq(rf.s); rf.r = uniq(rf.r); rf.l = uniq(rf.l); return { rf, svc }; }; let order = []; for (let x in all) if (x[".name"] == "zm_bypass" || s(x.action) == "bypass") push(order, x); for (let x in all) if (x[".name"] == main) push(order, x); for (let x in all) if (index(order, x) < 0) push(order, x); let bdom = [], bsub = []; for (let x in order) { let n = x[".name"], a = s(x.action || "connection"); if (a == "bypass") { bdom = [ ...bdom, ...olddom(x) ]; bsub = [ ...bsub, ...oldsub(x) ]; continue; } if (index([ "connection", "proxy", "vpn", "outbound" ], a) < 0) { push(notes, "секция «" + s(x.label || n) + "» (" + a + ") не перенесена — NetShift так не умеет"); continue; } if (!match(n, /^[A-Za-z0-9_]+$/) || n == "settings" || n == BYPASS) { let i = 1; while (c.get(CFG, "zm_s" + i) != null) i++; n = "zm_s" + i; } c.set(CFG, n, "section"); let lb = trim(s(x.label)); if (lb != "" && lb != "Zapret Manager") c.set(CFG, n, "zm_label", lb); if (s(x.enabled) == "0" && x[".name"] != main) c.set(CFG, n, "disabled", "1"); let ifs = kids("section_interface", x[".name"]), subs = kids("subscription_url", x[".name"]); let links = uniq(words(x.selector_proxy_links)); if (length(ifs)) { c.set(CFG, n, "connection_type", "vpn"); c.set(CFG, n, "interface", s(ifs[0].name)); c.set(CFG, n, "domain_resolver_enabled", "0"); } else if (length(subs)) { c.set(CFG, n, "connection_type", "proxy"); c.set(CFG, n, "proxy_config_type", "subscription"); c.set(CFG, n, "subscription_url", [ s(subs[0].url) ]); let iv = s(subs[0].subscription_update_interval || "1h"); c.set(CFG, n, "subscription_update_interval", index(SUB_IVS, iv) >= 0 ? iv : "1h"); } else if (length(links)) { c.set(CFG, n, "connection_type", "proxy"); c.set(CFG, n, "proxy_config_type", "urltest"); c.set(CFG, n, "zm_links", links); } else { push(notes, "в секции «" + s(x.label || n) + "» нет серверов, подписки или туннеля — настройте её заново"); c.set(CFG, n, "connection_type", "proxy"); c.set(CFG, n, "proxy_config_type", "urltest"); c.set(CFG, n, "disabled", "1"); } c.set(CFG, n, "enable_udp_over_tcp", "0"); if (s(x.zm_exclude) != "") c.set(CFG, n, "zm_exclude", s(x.zm_exclude)); if (length(rawlist(x.zm_hide))) c.set(CFG, n, "zm_hide", rawlist(x.zm_hide)); apply_servers(c, n); let o = oldrefs(x); set_lists(c, n, o.rf, o.svc, olddom(x), oldsub(x)); if (x[".name"] == main) { set_list(c, n, "fully_routed_ips", uniq(words(x.fully_routed_ips))); set_list(c, "settings", "routing_excluded_ips", uniq(words(x.excluded_source_ip_cidr))); } } bdom = uniq(bdom); bsub = uniq(bsub); if (length(bdom) || length(bsub)) { c.set(CFG, BYPASS, "section"); c.set(CFG, BYPASS, "zm_label", "Исключения"); c.set(CFG, BYPASS, "connection_type", "exclusion"); set_lists(c, BYPASS, { s: [], r: [], l: [] }, [], bdom, bsub); c.delete(CFG, BYPASS, "zm_refs"); c.reorder(CFG, BYPASS, 1); } let t = s(ost.dns_type || "udp"); if (index([ "udp", "dot", "doh" ], t) < 0) t = "udp"; let svs = filter(map(arr(ost.dns_server), (v) => dns_norm(t, v)), (v) => dns_value_ok(t, v)); if (!length(svs)) svs = t == "udp" ? [ "9.9.9.9" ] : t == "dot" ? [ "dns.quad9.net" ] : [ "https://dns.quad9.net/dns-query" ]; let bss = filter(arr(ost.bootstrap_dns_server), (v) => valid_ip(v) && index(v, "/") < 0); if (!length(bss)) bss = [ "9.9.9.9" ]; c.set(CFG, "settings", "dns_type", t); c.set(CFG, "settings", "dns_server", svs[0]); c.set(CFG, "settings", "zm_dns_servers", slice(svs, 0, 8)); c.set(CFG, "settings", "bootstrap_dns_server", bss[0]); c.set(CFG, "settings", "zm_bootstrap", slice(bss, 0, 4)); if (s(ost.dns_detour_enabled) == "1" && main) { c.set(CFG, "settings", "dns_via_outbound", "1"); c.set(CFG, "settings", "dns_outbound_section", main); } c.set(CFG, "settings", "disable_quic", s(ost.disable_quic || "1") == "1" ? "1" : "0"); let iv = s(ost.update_interval || "1d"); c.set(CFG, "settings", "update_interval", index(LIST_IVS, iv) >= 0 ? iv : iv == "6h" ? "3h" : "1d"); if (s(ost.download_lists_via_proxy) == "1" && main) { c.set(CFG, "settings", "download_lists_via_proxy", "1"); c.set(CFG, "settings", "download_lists_via_proxy_section", main); } c.save(CFG); c.commit(CFG); out({ ok: true, main, notes }); } let mode = ARGV[0] || ""; let sec = ARGV[1] || ""; if (mode != "import" && mode != "route" && sec != "" && !match(sec, /^[A-Za-z0-9_]+$/)) fail("неверное имя секции"); if (sec == "" && index([ "get", "set", "secdel", "import", "route", "diag", "secnames", "secstate" ], mode) < 0) sec = pick(cursor()).sec; if (mode == "get") cmd_get(sec); else if (mode == "set") cmd_set(); else if (mode == "secdel") cmd_secdel(sec); else if (mode == "secnames") cmd_secnames(); else if (mode == "servers") cmd_servers(sec); else if (mode == "latency") cmd_latency(sec); else if (mode == "select") cmd_select(sec, ARGV[2]); else if (mode == "fixsel") cmd_fixsel(sec); else if (mode == "heal") cmd_heal(sec, ARGV[2] || ""); else if (mode == "hide") cmd_hide(sec); else if (mode == "diag") cmd_diag(); else if (mode == "secstate") cmd_secstate(sec == "probe"); else if (mode == "subinfo") cmd_subinfo(sec); else if (mode == "route") cmd_route(ARGV[1], ARGV[2]); else if (mode == "import") cmd_import(sec); else fail("неизвестная команда"); ZM_INSTALLER_EOF chmod 0644 '/opt/zapret-manager-luci/netshift.uc' rm -f '/opt/zapret-manager-luci/forkop.uc' mkdir -p /usr/libexec/rpcd chmod 0755 /usr/libexec/rpcd cat > '/opt/zapret-manager-luci/rpcd-plugin.zm-new' << 'ZM_INSTALLER_EOF' #!/bin/sh . /usr/share/libubox/jshn.sh BACKEND="/opt/zapret-manager-luci/backend.sh" list_methods() { json_init json_add_object "status"; json_close_object json_add_object "system_info"; json_close_object json_add_object "job_status"; json_add_string "job" "string"; json_close_object json_add_object "log_tail"; json_add_string "job" "string"; json_close_object json_add_object "zapret_action"; json_add_string "action" "string"; json_close_object json_add_object "zapret_latest_version"; json_close_object json_add_object "zapret2_action"; json_add_string "action" "string"; json_close_object json_add_object "strategy_list_v"; json_close_object json_add_object "strategy_set_v"; json_add_string "version" "string"; json_close_object json_add_object "strategy_list_flowseal"; json_add_string "action" "string"; json_close_object json_add_object "strategy_set_flowseal"; json_add_string "name" "string"; json_close_object json_add_object "strategy_list_youtube"; json_add_string "action" "string"; json_close_object json_add_object "strategy_set_youtube"; json_add_string "name" "string"; json_close_object json_add_object "youtube_quic_set"; json_add_string "mode" "string"; json_close_object json_add_object "discord_status"; json_close_object json_add_object "discord_set_dv"; json_add_string "num" "string"; json_close_object json_add_object "discord_set_fake"; json_add_string "file" "string"; json_close_object json_add_object "game_status"; json_close_object json_add_object "game_set"; json_add_string "choice" "string"; json_close_object json_add_object "game_set_fake"; json_add_string "file" "string"; json_close_object json_add_object "game_toggle_xtreme"; json_close_object json_add_object "system_status"; json_close_object json_add_object "system_check_connectivity"; json_close_object json_add_object "system_toggle_quic"; json_close_object json_add_object "system_toggle_ipv6"; json_close_object json_add_object "system_toggle_flow_offloading_fix"; json_close_object json_add_object "system_uninstall_panel"; json_close_object json_add_object "sysinfo_run"; json_close_object json_add_object "sysinfo_get"; json_close_object json_add_object "mirror_status"; json_close_object json_add_object "mirror_set"; json_add_string "id" "string"; json_close_object json_add_object "exclusions_status"; json_close_object json_add_object "exclusions_toggle"; json_add_string "ip" "string"; json_close_object json_add_object "exclusions_clear"; json_close_object json_add_object "nfqws_opt_get"; json_close_object json_add_object "zapret_lists_status"; json_close_object json_add_object "zapret_list_get"; json_add_string "id" "string"; json_close_object json_add_object "zapret_list_set"; json_add_string "id" "string"; json_add_string "content" "string"; json_close_object json_add_object "zapret_list_restore"; json_add_string "id" "string"; json_close_object json_add_object "nfqws_opt_set"; json_add_string "content" "string"; json_close_object json_add_object "tg_status"; json_close_object json_add_object "tg_action"; json_add_string "variant" "string"; json_add_string "action" "string"; json_close_object json_add_object "tg_restart_all"; json_close_object json_add_object "tgws_status"; json_close_object json_add_object "tgws_action"; json_add_string "action" "string"; json_close_object json_add_object "hosts_status"; json_close_object json_add_object "hosts_toggle"; json_add_string "block" "string"; json_close_object json_add_object "hosts_replace_geohide"; json_add_string "region" "string"; json_close_object json_add_object "hosts_reset"; json_close_object json_add_object "hosts_file_get"; json_close_object json_add_object "hosts_file_set"; json_add_string "content" "string"; json_close_object json_add_object "doh_status"; json_close_object json_add_object "doh_install"; json_close_object json_add_object "doh_remove"; json_close_object json_add_object "doh_set"; json_add_string "provider" "string"; json_close_object json_add_object "doh_bootstrap_set"; json_add_string "value" "string"; json_close_object json_add_object "doh_force_set"; json_add_string "mode" "string"; json_close_object json_add_object "test_status"; json_close_object json_add_object "test_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "test_results"; json_add_string "mode" "string"; json_close_object json_add_object "zm_update_status"; json_close_object json_add_object "zm_update_action"; json_close_object json_add_object "mixomo_status"; json_close_object json_add_object "mixomo_action"; json_add_string "action" "string"; json_close_object json_add_object "mixomo_config_get"; json_close_object json_add_object "mixomo_config_set"; json_add_string "content" "string"; json_close_object json_add_object "mixomo_subscription_set"; json_add_string "url" "string"; json_close_object json_add_object "mixomo_filter_set"; json_add_string "value" "string"; json_close_object json_add_object "mixomo_proxies"; json_close_object json_add_object "mixomo_magitrickle_list_set"; json_add_string "id" "string"; json_close_object json_add_object "mixomo_autorestart_set"; json_add_string "mode" "string"; json_add_string "value" "string"; json_close_object json_add_object "mixomo_ui_action"; json_add_string "which" "string"; json_close_object json_add_object "mixomo_warp_status"; json_close_object 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 "health"; json_close_object json_add_object "system_reboot"; json_close_object json_add_object "versions"; json_add_string "action" "string"; json_close_object json_add_object "awg_status"; json_close_object json_add_object "awg_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "steer_status"; json_close_object json_add_object "steer_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "bytetube_action"; json_add_string "action" "string"; json_close_object json_add_object "forkop_status"; json_close_object json_add_object "forkop_config_get"; json_close_object json_add_object "forkop_config_set"; json_add_string "content" "string"; json_close_object json_add_object "forkop_action"; json_add_string "action" "string"; json_add_string "mode" "string"; json_close_object json_add_object "jobs_cancel"; json_add_string "job" "string"; json_close_object json_add_object "ui_theme_get"; json_close_object json_add_object "ui_theme_set"; json_add_string "theme" "string"; json_close_object json_dump } call_method() { local method="$1" local input input="$(cat)" json_load "$input" 2>/dev/null case "$method" in status) "$BACKEND" status ;; system_info) "$BACKEND" system_info ;; job_status) json_get_var job job; "$BACKEND" job_status "$job" ;; log_tail) json_get_var job job; "$BACKEND" log_tail "$job" ;; zapret_action) json_get_var action action; "$BACKEND" zapret_action "$action" ;; zapret_latest_version) "$BACKEND" zapret_latest_version ;; zapret2_action) json_get_var action action; "$BACKEND" zapret2_action "$action" ;; strategy_list_v) "$BACKEND" strategy_list_v ;; strategy_set_v) json_get_var version version; "$BACKEND" strategy_set_v "$version" ;; strategy_list_flowseal) json_get_var action action; "$BACKEND" strategy_list_flowseal "$action" ;; strategy_set_flowseal) json_get_var name name; "$BACKEND" strategy_set_flowseal "$name" ;; strategy_list_youtube) json_get_var action action; "$BACKEND" strategy_list_youtube "$action" ;; strategy_set_youtube) json_get_var name name; "$BACKEND" strategy_set_youtube "$name" ;; youtube_quic_set) json_get_var mode mode; "$BACKEND" youtube_quic_set "$mode" ;; discord_status) "$BACKEND" discord_status ;; discord_set_dv) json_get_var num num; "$BACKEND" discord_set_dv "$num" ;; discord_set_fake) json_get_var file file; "$BACKEND" discord_set_fake "$file" ;; game_status) "$BACKEND" game_status ;; game_set) json_get_var choice choice; "$BACKEND" game_set "$choice" ;; game_set_fake) json_get_var file file; "$BACKEND" game_set_fake "$file" ;; game_toggle_xtreme) "$BACKEND" game_toggle_xtreme ;; system_status) "$BACKEND" system_status ;; system_check_connectivity) "$BACKEND" system_check_connectivity ;; system_toggle_quic) "$BACKEND" system_toggle_quic ;; system_toggle_ipv6) "$BACKEND" system_toggle_ipv6 ;; system_toggle_flow_offloading_fix) "$BACKEND" system_toggle_flow_offloading_fix ;; system_uninstall_panel) "$BACKEND" system_uninstall_panel ;; sysinfo_run) "$BACKEND" sysinfo_run ;; sysinfo_get) "$BACKEND" sysinfo_get ;; mirror_status) "$BACKEND" mirror_status ;; mirror_set) json_get_var id id; "$BACKEND" mirror_set "$id" ;; exclusions_status) "$BACKEND" exclusions_status ;; exclusions_toggle) json_get_var ip ip; "$BACKEND" exclusions_toggle "$ip" ;; exclusions_clear) "$BACKEND" exclusions_clear ;; nfqws_opt_get) "$BACKEND" nfqws_opt_get ;; zapret_lists_status) "$BACKEND" zapret_lists_status ;; zapret_list_get) json_get_var id id; "$BACKEND" zapret_list_get "$id" ;; zapret_list_set) json_get_var id id; json_get_var content content; printf '%s' "$content" | "$BACKEND" zapret_list_set "$id" @stdin ;; zapret_list_restore) json_get_var id id; "$BACKEND" zapret_list_restore "$id" ;; nfqws_opt_set) json_get_var content content; printf '%s' "$content" | "$BACKEND" nfqws_opt_set @stdin ;; 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 ;; tgws_status) "$BACKEND" tgws_status ;; tgws_action) json_get_var action action; "$BACKEND" tgws_action "$action" ;; hosts_status) "$BACKEND" hosts_status ;; hosts_toggle) json_get_var block block; "$BACKEND" hosts_toggle "$block" ;; hosts_replace_geohide) json_get_var region region; "$BACKEND" hosts_replace_geohide "$region" ;; hosts_reset) "$BACKEND" hosts_reset ;; hosts_file_get) "$BACKEND" hosts_file_get ;; hosts_file_set) json_get_var content content; printf '%s' "$content" | "$BACKEND" hosts_file_set @stdin ;; doh_status) "$BACKEND" doh_status ;; doh_install) "$BACKEND" doh_install ;; doh_remove) "$BACKEND" doh_remove ;; doh_set) json_get_var provider provider; "$BACKEND" doh_set "$provider" ;; doh_bootstrap_set) json_get_var value value; "$BACKEND" doh_bootstrap_set "$value" ;; doh_force_set) json_get_var mode mode; "$BACKEND" doh_force_set "$mode" ;; test_status) "$BACKEND" test_status ;; test_action) json_get_var action action; json_get_var mode mode; "$BACKEND" test_action "$action" "$mode" ;; test_results) json_get_var mode mode; "$BACKEND" test_results "$mode" ;; zm_update_status) "$BACKEND" zm_update_status ;; zm_update_action) "$BACKEND" zm_update_action ;; mixomo_status) "$BACKEND" mixomo_status ;; mixomo_action) json_get_var action action; "$BACKEND" mixomo_action "$action" ;; mixomo_config_get) "$BACKEND" mixomo_config_get ;; mixomo_config_set) json_get_var content content; printf '%s' "$content" | "$BACKEND" mixomo_config_set @stdin ;; mixomo_subscription_set) json_get_var url url; "$BACKEND" mixomo_subscription_set "$url" ;; mixomo_filter_set) json_get_var value value; "$BACKEND" mixomo_filter_set "$value" ;; mixomo_proxies) "$BACKEND" mixomo_proxies ;; mixomo_magitrickle_list_set) json_get_var id id; "$BACKEND" mixomo_magitrickle_list_set "$id" ;; mixomo_autorestart_set) json_get_var mode mode; json_get_var value value; "$BACKEND" mixomo_autorestart_set "$mode" "$value" ;; mixomo_ui_action) json_get_var which which; "$BACKEND" mixomo_ui_action "$which" ;; mixomo_warp_status) "$BACKEND" mixomo_warp_status ;; 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; printf '%s' "$content" | "$BACKEND" mixomo_warp_config_set @stdin ;; bytetube_installed) "$BACKEND" bytetube_installed ;; health) "$BACKEND" health ;; system_reboot) "$BACKEND" system_reboot ;; versions) json_get_var action action; "$BACKEND" versions "$action" ;; awg_status) "$BACKEND" awg_status ;; awg_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" awg_action "$action" @stdin ;; steer_status) "$BACKEND" steer_status ;; steer_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" steer_action "$action" @stdin ;; bytetube_action) json_get_var action action; "$BACKEND" bytetube_action "$action" ;; forkop_status) "$BACKEND" forkop_status ;; forkop_config_get) "$BACKEND" forkop_config_get ;; forkop_config_set) json_get_var content content; printf '%s' "$content" | "$BACKEND" forkop_config_set @stdin ;; forkop_action) json_get_var action action; json_get_var mode mode; printf '%s' "$mode" | "$BACKEND" forkop_action "$action" @stdin ;; jobs_cancel) json_get_var job job; "$BACKEND" jobs_cancel "$job" ;; ui_theme_get) "$BACKEND" ui_theme_get ;; ui_theme_set) json_get_var theme theme; "$BACKEND" ui_theme_set "$theme" ;; *) echo '{"error":"unknown method"}'; return 1 ;; esac } case "$1" in list) list_methods ;; call) t="/tmp/zm-rpc.$$" call_method "$2" > "$t" sz="$(wc -c < "$t" 2>/dev/null | tr -d ' ')" if [ "${sz:-0}" -gt 400000 ]; then echo '{"error":"ответ роутера слишком большой — отменено, чтобы не перегрузить службу входа (rpcd)"}' else cat "$t" fi rm -f "$t" ;; *) echo '{"error":"usage: zapret-manager {list|call }"}'; exit 1 ;; esac ZM_INSTALLER_EOF chmod 0755 '/opt/zapret-manager-luci/rpcd-plugin.zm-new' mv -f '/opt/zapret-manager-luci/rpcd-plugin.zm-new' '/usr/libexec/rpcd/zapret-manager' mkdir -p /usr/share/rpcd/acl.d chmod 0755 /usr/share/rpcd/acl.d cat > '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' { "luci-app-zapret-manager": { "description": "Grant access to the Zapret Manager backend", "read": { "ubus": { "zapret-manager": [ "status", "job_status", "log_tail", "system_info", "strategy_list_v", "strategy_list_flowseal", "strategy_list_youtube", "discord_status", "hosts_status", "hosts_file_get", "doh_status", "game_status", "system_status", "mirror_status", "exclusions_status", "nfqws_opt_get", "zapret_lists_status", "zapret_list_get", "tg_status", "tgws_status", "test_status", "test_results", "zm_update_status", "mixomo_status", "mixomo_proxies", "mixomo_config_get", "mixomo_warp_status", "zapret_latest_version", "bytetube_installed", "health", "versions", "awg_status", "steer_status", "forkop_status", "forkop_config_get", "sysinfo_get", "ui_theme_get" ], "system": [ "info" ] }, "uci": [ "bytetube" ], "file": { "/usr/bin/bytetube": [ "exec" ], "/etc/init.d/bytetube": [ "exec" ] } }, "write": { "ubus": { "zapret-manager": [ "zapret_action", "zapret2_action", "strategy_set_v", "strategy_set_flowseal", "strategy_set_youtube", "youtube_quic_set", "discord_set_dv", "discord_set_fake", "hosts_toggle", "doh_set", "doh_bootstrap_set", "doh_force_set", "hosts_replace_geohide", "hosts_reset", "hosts_file_set", "doh_install", "doh_remove", "game_set", "game_set_fake", "game_toggle_xtreme", "system_check_connectivity", "system_toggle_quic", "system_toggle_ipv6", "system_toggle_flow_offloading_fix", "system_uninstall_panel", "mirror_set", "exclusions_toggle", "exclusions_clear", "nfqws_opt_set", "zapret_list_set", "zapret_list_restore", "tg_action", "tg_restart_all", "tgws_action", "test_action", "zm_update_action", "mixomo_action", "mixomo_config_set", "mixomo_subscription_set", "mixomo_filter_set", "mixomo_magitrickle_list_set", "mixomo_autorestart_set", "mixomo_ui_action", "mixomo_warp_action", "mixomo_warp_integrate_action", "mixomo_warp_config_set", "bytetube_action", "awg_action", "steer_action", "forkop_config_set", "forkop_action", "sysinfo_run", "jobs_cancel", "ui_theme_set", "system_reboot" ] }, "uci": [ "bytetube" ] } } } ZM_INSTALLER_EOF chmod 0644 '/usr/share/rpcd/acl.d/luci-app-zapret-manager.json' mkdir -p /usr/share/luci/menu.d chmod 0755 /usr/share/luci/menu.d cat > '/usr/share/luci/menu.d/luci-app-zapret-manager.json' << 'ZM_INSTALLER_EOF' { "admin/services/zapret-manager": { "title": "Zapret Manager", "order": 60, "action": { "type": "alias", "path": "admin/services/zapret-manager/dashboard" } }, "admin/services/zapret-manager/dashboard": { "title": "Дашборд", "order": 10, "action": { "type": "view", "path": "zapret-manager/dashboard" } }, "admin/services/zapret-manager/steer": { "title": "Steer", "order": 26, "action": { "type": "view", "path": "zapret-manager/steer" } }, "admin/services/zapret-manager/strategy": { "title": "Zapret", "order": 20, "action": { "type": "view", "path": "zapret-manager/strategy" } }, "admin/services/zapret-manager/forkozz": { "title": "Forkozz", "order": 27, "action": { "type": "view", "path": "zapret-manager/forkozz" } }, "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, "action": { "type": "view", "path": "zapret-manager/hosts" } }, "admin/services/zapret-manager/doh": { "title": "DNS over HTTPS", "order": 50, "action": { "type": "view", "path": "zapret-manager/doh" } }, "admin/services/zapret-manager/awg": { "title": "AmneziaWG", "order": 52, "action": { "type": "view", "path": "zapret-manager/awg" } }, "admin/services/zapret-manager/tgproxy": { "title": "TG WS Proxy", "order": 55, "action": { "type": "view", "path": "zapret-manager/tgproxy" } }, "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 chmod 0644 '/usr/share/luci/menu.d/luci-app-zapret-manager.json' mkdir -p /www/luci-static/resources/zapret-manager chmod 0755 /www/luci-static/resources/zapret-manager cat > '/www/luci-static/resources/zapret-manager/common.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require baseclass'; 'require rpc'; 'require ui'; var UBUS_TEXT = { 1: 'неверная команда', 2: 'неверный параметр', 3: 'метод не найден', 4: 'не найдено', 5: 'нет данных', 6: 'нет доступа — выйдите и войдите заново', 7: 'роутер не ответил вовремя', 8: 'не поддерживается', 9: 'ошибка', 10: 'нет соединения' }; function zmDeclare(o) { o.filter = function(r) { if (typeof r === 'number') return r === 0 ? {} : { error: 'Роутер: ' + (UBUS_TEXT[r] || ('ошибка ubus ' + r)), ubus: r }; return r == null ? {} : r; }; var fn = rpc.declare(o); return function() { var self = this, args = arguments, left = 6; if (document.body.classList.contains('zmw-body')) return fn.apply(self, args); function go() { return fn.apply(self, args).catch(function(e) { if (left-- > 0 && /Object not found/i.test(String((e && e.message) || e))) return new Promise(function(ok) { setTimeout(ok, 1500); }).then(go); throw e; }); } return go(); }; } var callStatus = zmDeclare({ object: 'zapret-manager', method: 'status', expect: {} }); var callAwgStatus = zmDeclare({ object: 'zapret-manager', method: 'awg_status', expect: {} }); var callAwgAction = zmDeclare({ object: 'zapret-manager', method: 'awg_action', params: ['action', 'mode'], expect: {} }); var callSteerStatus = zmDeclare({ object: 'zapret-manager', method: 'steer_status', expect: {} }); var callSteerAction = zmDeclare({ object: 'zapret-manager', method: 'steer_action', params: ['action', 'mode'], expect: {} }); var callSystemInfo = zmDeclare({ object: 'zapret-manager', method: 'system_info', expect: {} }); var callVersions = zmDeclare({ object: 'zapret-manager', method: 'versions', params: ['action'], expect: {} }); var callJobStatus = zmDeclare({ object: 'zapret-manager', method: 'job_status', params: ['job'], expect: {} }); var callLogTail = zmDeclare({ object: 'zapret-manager', method: 'log_tail', params: ['job'], expect: {} }); var callZapretAction = zmDeclare({ object: 'zapret-manager', method: 'zapret_action', params: ['action'], expect: {} }); var callZapretLatestVersion = zmDeclare({ object: 'zapret-manager', method: 'zapret_latest_version', expect: {} }); var callZapret2Action = zmDeclare({ object: 'zapret-manager', method: 'zapret2_action', params: ['action'], expect: {} }); var callStrategyListV = zmDeclare({ object: 'zapret-manager', method: 'strategy_list_v', expect: {} }); var callStrategySetV = zmDeclare({ object: 'zapret-manager', method: 'strategy_set_v', params: ['version'], expect: {} }); var callStrategyListFlowseal = zmDeclare({ object: 'zapret-manager', method: 'strategy_list_flowseal', params: ['action'], expect: {} }); var callStrategySetFlowseal = zmDeclare({ object: 'zapret-manager', method: 'strategy_set_flowseal', params: ['name'], expect: {} }); var callStrategyListYoutube = zmDeclare({ object: 'zapret-manager', method: 'strategy_list_youtube', params: ['action'], expect: {} }); var callStrategySetYoutube = zmDeclare({ object: 'zapret-manager', method: 'strategy_set_youtube', params: ['name'], expect: {} }); var callYoutubeQuicSet = zmDeclare({ object: 'zapret-manager', method: 'youtube_quic_set', params: ['mode'], expect: {} }); var callDiscordStatus = zmDeclare({ object: 'zapret-manager', method: 'discord_status', expect: {} }); var callDiscordSetDv = zmDeclare({ object: 'zapret-manager', method: 'discord_set_dv', params: ['num'], expect: {} }); var callDiscordSetFake = zmDeclare({ object: 'zapret-manager', method: 'discord_set_fake', params: ['file'], expect: {} }); var callGameStatus = zmDeclare({ object: 'zapret-manager', method: 'game_status', expect: {} }); var callGameSet = zmDeclare({ object: 'zapret-manager', method: 'game_set', params: ['choice'], expect: {} }); var callGameSetFake = zmDeclare({ object: 'zapret-manager', method: 'game_set_fake', params: ['file'], expect: {} }); var callGameToggleXtreme = zmDeclare({ object: 'zapret-manager', method: 'game_toggle_xtreme', expect: {} }); var callSystemStatus = zmDeclare({ object: 'zapret-manager', method: 'system_status', expect: {} }); var callSystemCheckConnectivity = zmDeclare({ object: 'zapret-manager', method: 'system_check_connectivity', expect: {} }); var callSystemToggleQuic = zmDeclare({ object: 'zapret-manager', method: 'system_toggle_quic', expect: {} }); var callSystemToggleIpv6 = zmDeclare({ object: 'zapret-manager', method: 'system_toggle_ipv6', expect: {} }); var callSystemToggleFlowOffloadingFix = zmDeclare({ object: 'zapret-manager', method: 'system_toggle_flow_offloading_fix', expect: {} }); var callSystemUninstallPanel = zmDeclare({ object: 'zapret-manager', method: 'system_uninstall_panel', expect: {} }); var callSysinfoRun = zmDeclare({ object: 'zapret-manager', method: 'sysinfo_run', expect: {} }); var callSysinfoGet = zmDeclare({ object: 'zapret-manager', method: 'sysinfo_get', expect: {} }); var callMirrorStatus = zmDeclare({ object: 'zapret-manager', method: 'mirror_status', expect: {} }); var callMirrorSet = zmDeclare({ object: 'zapret-manager', method: 'mirror_set', params: ['id'], expect: {} }); var callExclusionsStatus = zmDeclare({ object: 'zapret-manager', method: 'exclusions_status', expect: {} }); var callExclusionsToggle = zmDeclare({ object: 'zapret-manager', method: 'exclusions_toggle', params: ['ip'], expect: {} }); var callExclusionsClear = zmDeclare({ object: 'zapret-manager', method: 'exclusions_clear', expect: {} }); var callZapretListsStatus = zmDeclare({ object: 'zapret-manager', method: 'zapret_lists_status', expect: {} }); var callZapretListGet = zmDeclare({ object: 'zapret-manager', method: 'zapret_list_get', params: ['id'], expect: {} }); var callZapretListSet = zmDeclare({ object: 'zapret-manager', method: 'zapret_list_set', params: ['id', 'content'], expect: {} }); var callZapretListRestore = zmDeclare({ object: 'zapret-manager', method: 'zapret_list_restore', params: ['id'], expect: {} }); var callNfqwsOptGet = zmDeclare({ object: 'zapret-manager', method: 'nfqws_opt_get', expect: {} }); var callNfqwsOptSet = zmDeclare({ object: 'zapret-manager', method: 'nfqws_opt_set', params: ['content'], expect: {} }); var callTgStatus = zmDeclare({ object: 'zapret-manager', method: 'tg_status', expect: {} }); var callTgAction = zmDeclare({ object: 'zapret-manager', method: 'tg_action', params: ['variant', 'action'], expect: {} }); var callTgRestartAll = zmDeclare({ object: 'zapret-manager', method: 'tg_restart_all', expect: {} }); var callTgwsStatus = zmDeclare({ object: 'zapret-manager', method: 'tgws_status', expect: {} }); var callTgwsAction = zmDeclare({ object: 'zapret-manager', method: 'tgws_action', params: ['action'], expect: {} }); var callHostsStatus = zmDeclare({ object: 'zapret-manager', method: 'hosts_status', expect: {} }); var callHostsToggle = zmDeclare({ object: 'zapret-manager', method: 'hosts_toggle', params: ['block'], expect: {} }); var callHostsReplaceGeohide = zmDeclare({ object: 'zapret-manager', method: 'hosts_replace_geohide', params: ['region'], expect: {} }); var callHostsReset = zmDeclare({ object: 'zapret-manager', method: 'hosts_reset', expect: {} }); var callHostsFileGet = zmDeclare({ object: 'zapret-manager', method: 'hosts_file_get', expect: {} }); var callHostsFileSet = zmDeclare({ object: 'zapret-manager', method: 'hosts_file_set', params: ['content'], expect: {} }); var callDohStatus = zmDeclare({ object: 'zapret-manager', method: 'doh_status', expect: {} }); var callDohInstall = zmDeclare({ object: 'zapret-manager', method: 'doh_install', expect: {} }); var callDohRemove = zmDeclare({ object: 'zapret-manager', method: 'doh_remove', expect: {} }); var callDohSet = zmDeclare({ object: 'zapret-manager', method: 'doh_set', params: ['provider'], expect: {} }); var callDohBootstrapSet = zmDeclare({ object: 'zapret-manager', method: 'doh_bootstrap_set', params: ['value'], expect: {} }); var callDohForceSet = zmDeclare({ object: 'zapret-manager', method: 'doh_force_set', params: ['mode'], expect: {} }); var callTestStatus = zmDeclare({ object: 'zapret-manager', method: 'test_status', expect: {} }); var callTestAction = zmDeclare({ object: 'zapret-manager', method: 'test_action', params: ['action', 'mode'], expect: {} }); var callTestResults = zmDeclare({ object: 'zapret-manager', method: 'test_results', params: ['mode'], expect: {} }); var callZmUpdateStatus = zmDeclare({ object: 'zapret-manager', method: 'zm_update_status', expect: {} }); var callZmUpdateAction = zmDeclare({ object: 'zapret-manager', method: 'zm_update_action', expect: {} }); var callMixomoStatus = zmDeclare({ object: 'zapret-manager', method: 'mixomo_status', expect: {} }); var callMixomoAction = zmDeclare({ object: 'zapret-manager', method: 'mixomo_action', params: ['action'], expect: {} }); var callMixomoConfigGet = zmDeclare({ object: 'zapret-manager', method: 'mixomo_config_get', expect: {} }); var callMixomoConfigSet = zmDeclare({ object: 'zapret-manager', method: 'mixomo_config_set', params: ['content'], expect: {} }); var callMixomoApplySubscription = zmDeclare({ object: 'zapret-manager', method: 'mixomo_subscription_set', params: ['url'], expect: {} }); var callMixomoFilterSet = zmDeclare({ object: 'zapret-manager', method: 'mixomo_filter_set', params: ['value'], expect: {} }); var callMixomoProxies = zmDeclare({ object: 'zapret-manager', method: 'mixomo_proxies', expect: {} }); var callMixomoMagitrickleListSet = zmDeclare({ object: 'zapret-manager', method: 'mixomo_magitrickle_list_set', params: ['id'], expect: {} }); var callMixomoAutorestartSet = zmDeclare({ object: 'zapret-manager', method: 'mixomo_autorestart_set', params: ['mode', 'value'], expect: {} }); var callMixomoUiAction = zmDeclare({ object: 'zapret-manager', method: 'mixomo_ui_action', params: ['which'], expect: {} }); var callMixomoWarpStatus = zmDeclare({ object: 'zapret-manager', method: 'mixomo_warp_status', expect: {} }); var callMixomoWarpAction = zmDeclare({ object: 'zapret-manager', method: 'mixomo_warp_action', params: ['endpoint_mode'], expect: {} }); var callMixomoWarpIntegrateAction = zmDeclare({ object: 'zapret-manager', method: 'mixomo_warp_integrate_action', expect: {} }); var callMixomoWarpConfigSet = zmDeclare({ object: 'zapret-manager', method: 'mixomo_warp_config_set', params: ['content'], expect: {} }); var callHealth = zmDeclare({ object: 'zapret-manager', method: 'health', expect: {} }); var callSystemReboot = zmDeclare({ object: 'zapret-manager', method: 'system_reboot', expect: {} }); var callBoardInfo = rpc.declare({ object: 'system', method: 'info', expect: {} }); var callForkopStatus = zmDeclare({ object: 'zapret-manager', method: 'forkop_status', expect: {} }); var callForkopConfigGet = zmDeclare({ object: 'zapret-manager', method: 'forkop_config_get', expect: {} }); var callForkopConfigSet = zmDeclare({ object: 'zapret-manager', method: 'forkop_config_set', params: ['content'], expect: {} }); var callForkopAction = zmDeclare({ object: 'zapret-manager', method: 'forkop_action', params: ['action', 'mode'], expect: {} }); var callJobsCancel = zmDeclare({ object: 'zapret-manager', method: 'jobs_cancel', params: ['job'], expect: {} }); function parseSize(v) { var m = String(v == null ? '' : v).trim().match(/^([\d.,]+)\s*([KMGT]?)i?B?$/i); if (!m) return NaN; return parseFloat(m[1].replace(',', '.')) * ({ '': 1, K: 1024, M: 1048576, G: 1073741824, T: 1099511627776 })[m[2].toUpperCase()]; } var _zmRevealed = {}; function secret(text) { text = String(text == null ? '' : text); var el = E('span', { 'class': 'zm-secret' + (_zmRevealed[text] ? ' zm-secret-open' : ''), 'title': _zmRevealed[text] ? 'Нажмите, чтобы скрыть' : 'Нажмите, чтобы показать', 'role': 'button', 'tabindex': '0' }, [ text ]); function toggle(ev) { if (ev) { ev.preventDefault(); ev.stopPropagation(); } var open = !el.classList.contains('zm-secret-open'); el.classList.toggle('zm-secret-open', open); el.title = open ? 'Нажмите, чтобы скрыть' : 'Нажмите, чтобы показать'; if (open) _zmRevealed[text] = true; else delete _zmRevealed[text]; } el.addEventListener('click', toggle); el.addEventListener('keydown', function(ev) { if (ev.key === 'Enter' || ev.key === ' ') toggle(ev); }); return el; } function fmtSize(b) { if (!isFinite(b)) return '—'; var u = [ 'Б', 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = 0; while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } return (b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1).replace(/\.0$/, '')) + ' ' + u[i]; } function stateBadge(st, textOk) { if (st === 1) return badge(true, textOk || 'запущен', ''); if (st === 2) return badge(false, '', 'остановлен'); return badge(false, '', 'не установлен'); } 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) { } } var cacheGuardDone = false; function cacheGuard() { if (cacheGuardDone) return; cacheGuardDone = true; try { if (document.body.classList.contains('zmw-body') || !window.fetch || !window.localStorage) return; var base = String(L.env.base_url || L.resource('x').replace(/\/x$/, '')), ver = L.env.resource_version; fetch(base + '/zapret-manager/build.txt?t=' + Date.now(), { cache: 'no-store' }).then(function(r) { return r.ok ? r.text() : ''; }).then(function(t) { var lines = String(t || '').split(/\r?\n/).map(function(x) { return x.trim(); }).filter(Boolean); var build = lines.shift(), had = null, stale = false; if (!/^[0-9]{6,}$/.test(build || '')) return; try { had = localStorage.getItem('zm.build'); } catch (e) { return; } if (had === build) return; function grab(url, mode) { return fetch(url, { cache: mode }).then(function(r) { return r.ok ? r.text() : null; }).catch(function() { return null; }); } return lines.filter(function(p) { return /^[A-Za-z0-9_\/.-]+\.(js|css)$/.test(p) && p.indexOf('..') < 0; }).reduce(function(pr, p) { var url = base + '/' + p + (/\.js$/.test(p) && ver != null ? '?v=' + ver : ''); return pr.then(function() { return grab(url, 'force-cache').then(function(a) { return grab(url, 'reload').then(function(b) { if (a != null && b != null && a !== b) stale = true; }); }); }); }, Promise.resolve()).then(function() { try { localStorage.setItem('zm.build', build); if (localStorage.getItem('zm.build') !== build) return; } catch (e) { return; } if (!stale) return; toast('Панель обновлена — загружаем новую версию', 'info'); setTimeout(function() { location.reload(); }, 700); }); }).catch(function() {}); } catch (e) {} } function injectCss() { cacheGuard(); detectMissingThemeVar(); if (document.getElementById('zm-css')) return; var l = document.createElement('link'); l.id = 'zm-css'; l.rel = 'stylesheet'; l.href = L.resource('view/zapret-manager/style.css'); document.head.appendChild(l); } function badge(ok, textOk, textBad) { var cls = ok ? 'zm-ok' : (textBad === 'не установлен' ? 'zm-off' : 'zm-bad'); return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), ok ? textOk : textBad ]); } function logMinPref() { try { return localStorage.getItem('zm.log.min') === '1'; } catch (e) { return false; } } function logBar(logEl) { if (!logEl._zmBar) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'zm-log-min'; var sync = function() { var min = logEl.classList.contains('zm-log-collapsed'); btn.textContent = min ? '▢' : '_'; btn.title = min ? 'Развернуть вывод' : 'Свернуть вывод'; }; btn.addEventListener('click', function(ev) { ev.preventDefault(); ev.stopPropagation(); var min = !logEl.classList.contains('zm-log-collapsed'); logEl.classList.toggle('zm-log-collapsed', min); try { localStorage.setItem('zm.log.min', min ? '1' : '0'); } catch (e) {} sync(); logEl.scrollTop = logEl.scrollHeight; }); var bar = document.createElement('div'); bar.className = 'zm-log-bar'; bar.appendChild(btn); logEl._zmBar = bar; logEl._zmSync = sync; if (logMinPref()) logEl.classList.add('zm-log-collapsed'); } logEl._zmSync(); return logEl._zmBar; } function renderLog(logEl, text) { logEl.innerHTML = ''; var lines = (text || '').split('\n'); if (lines.some(function(l) { return l !== ''; })) logEl.appendChild(logBar(logEl)); lines.forEach(function(line) { if (line === '') return; var div = document.createElement('div'); var m = line.match(/^(==>)\s?(.*)$/); if (m) { var arrow = document.createElement('span'); arrow.className = 'zm-log-arrow'; arrow.textContent = m[1] + ' '; div.appendChild(arrow); var msgSpan = document.createElement('span'); var msg = m[2]; if (/ОШИБКА|error|не удалось/i.test(msg)) msgSpan.className = 'zm-log-msg-error'; else if (/Готово|успешно|примен[её]н|установлен|удал[её]н|сгенерирован|сохран[её]н|переключен|обновлен|завершен/i.test(msg)) msgSpan.className = 'zm-log-msg-ok'; else msgSpan.className = 'zm-log-msg-info'; msgSpan.textContent = msg; div.appendChild(msgSpan); } else if (/^\s*Подождите/.test(line)) { div.className = 'zm-log-wait'; div.textContent = line.replace(/^\s+/, ''); } else if (/^\s*✓/.test(line)) { div.className = 'zm-log-step zm-log-step-ok'; div.textContent = line; } else if (/^\s*✗/.test(line)) { div.className = 'zm-log-step zm-log-step-bad'; div.textContent = line; } else if (/^\s*[→↻]/.test(line)) { div.className = 'zm-log-step'; div.textContent = line; } else if (/^\s+!/.test(line)) { div.className = 'zm-log-msg-warn'; div.textContent = line; } else if (/^!!/.test(line)) { div.className = 'zm-log-msg-warn'; div.textContent = line; } else { div.className = 'zm-log-code'; div.textContent = line; } logEl.appendChild(div); }); logEl.scrollTop = logEl.scrollHeight; } var _dockEl = null, _dockBars = []; function dockEl() { if (!_dockEl || !document.body.contains(_dockEl)) { _dockEl = E('div', { 'class': 'zm-dock' }); document.body.appendChild(_dockEl); } return _dockEl; } function dockCtx(ph) { var n = ph && ph.parentNode, depth = 0, h; while (n && n !== document.body && depth < 8) { h = n.querySelector && n.querySelector('h3, h4'); if (h && h.textContent) return h.textContent.replace(/\s+/g, ' ').trim().slice(0, 40); n = n.parentNode; depth++; } return ''; } function dockSync() { var dk = _dockEl, live = []; _dockBars.forEach(function(el) { var inPage = el.parentNode && el.parentNode !== dk && document.body.contains(el); if (inPage) { if (!el._zmPh) el._zmPh = E('span', { 'class': 'zm-dock-ph', 'style': 'display:none' }); if (el._zmPh.nextSibling !== el) el.parentNode.insertBefore(el._zmPh, el); } var ph = el._zmPh, phIn = !!(ph && document.body.contains(ph)); if (!phIn && !inPage) { if (dk && el.parentNode === dk) dk.removeChild(el); return; } live.push(el); var vis = phIn && ph.parentNode.getClientRects().length > 0; if (el._zmWant && vis && el.style.display !== 'none') { if (el.parentNode !== dockEl()) { dk = _dockEl; dk.appendChild(el); } } else if (dk && el.parentNode === dk) { if (phIn) ph.parentNode.insertBefore(el, ph.nextSibling); else dk.removeChild(el); } }); _dockBars = live; if (!_dockEl) return; var shown = Array.prototype.filter.call(_dockEl.children, function(el) { return el.style.display !== 'none'; }); var bars = shown.filter(function(el) { return !el._zmGlobal; }); bars.forEach(function(el) { var c = bars.length > 1 ? dockCtx(el._zmPh) : ''; if (c !== (el._zmCtx || '')) { el._zmCtx = c; if (el._zmRender) el._zmRender(); } }); _dockEl.classList.toggle('zm-dock-on', shown.length > 0); var pad = shown.length ? (_dockEl.offsetHeight + 28) + 'px' : ''; if (document.body.style.paddingBottom !== pad) document.body.style.paddingBottom = pad; toastFit(); } function dock(el, show) { el._zmWant = !!show; if (_dockBars.indexOf(el) < 0) _dockBars.push(el); dockSync(); if (show && el.parentNode !== _dockEl) { el._zmTry = (el._zmTry || 0) + 1; if (el._zmTry < 15) setTimeout(function() { if (el._zmWant) dock(el, true); }, 300); } else el._zmTry = 0; } setInterval(dockSync, 600); window.addEventListener('hashchange', function() { setTimeout(dockSync, 50); }); var _activePolls = {}; var JOB_NAMES = { steer: 'Steer', forkop: 'Forkozz', awg: 'AmneziaWG', strategy_test: 'тест стратегий', mirror_set: 'смена зеркала' }; var _stuck = {}, _stopEl = null, _stopBusy = false; function jobName(j) { if (JOB_NAMES[j]) return JOB_NAMES[j]; if (/^mixomo/.test(j)) return 'Mixomo'; if (/^bytetube/.test(j)) return 'ByeTube'; if (/zapret2/.test(j)) return 'Zapret2'; if (/zapret/.test(j)) return 'Zapret'; if (/^tgws/.test(j)) return 'sTGWS'; if (/^tg_/.test(j)) return 'TG WS Proxy'; if (/^doh/.test(j)) return 'DNS over HTTPS'; return j; } function stopAll() { if (_stopBusy) return; if (!confirm('Остановить все операции?\n\nВсе их процессы будут принудительно завершены. Если прервать установку, её можно просто запустить заново.')) return; _stopBusy = true; renderStopBar(); callJobsCancel('').then(function(res) { _stopBusy = false; if (res && res.error) { toast(res.error, 'error'); renderStopBar(); return; } var n = (res && res.stopped || []).length; toast(n ? 'Остановлено: ' + res.stopped.map(jobName).join(', ') : 'Активных операций не было', n ? 'warning' : 'info'); _stuck = {}; renderStopBar(); }).catch(function() { _stopBusy = false; renderStopBar(); toast('Роутер не ответил — попробуйте ещё раз', 'error'); }); } function renderStopBar() { var keys = Object.keys(_stuck); if (!keys.length) { if (_stopEl && _stopEl.parentNode) _stopEl.parentNode.removeChild(_stopEl); _stopEl = null; dockSync(); return; } var mins = Math.max.apply(null, keys.map(function(k) { return _stuck[k]; })); mins = Math.max(2, Math.floor(mins / 60)); if (!_stopEl) { _stopEl = E('div', { 'class': 'zm-stopbar', 'role': 'alert' }); _stopEl._zmGlobal = true; _stopEl._t = E('b', {}); _stopEl._b = E('button', { 'class': 'cbi-button zm-stopbar-btn', 'click': stopAll }); _stopEl.appendChild(E('div', { 'class': 'zm-stopbar-text' }, [ _stopEl._t, E('span', {}, 'Если она зависла — остановите её. Роутер завершит все её процессы.') ])); _stopEl.appendChild(_stopEl._b); } _stopEl._t.textContent = 'Операция «' + keys.map(jobName).join(', ') + '» не отвечает уже ' + mins + ' мин'; _stopEl._b.disabled = !!_stopBusy; _stopEl._b.textContent = _stopBusy ? 'Останавливаем…' : 'Остановить все операции'; if (_stopEl.parentNode !== dockEl()) dockEl().insertBefore(_stopEl, dockEl().firstChild); dockSync(); } function pollJob(job, logEl, onDone, onTick) { if (_activePolls[job]) { clearInterval(_activePolls[job]); delete _activePolls[job]; } logEl.classList.add('zm-show'); var failCount = 0; var finished = false, inFlight = false, gone = 0, startHash = location.hash; var lastSig = null, progAt = Date.now(), lastTxt = '', deadPolls = 0; function end(ok) { finished = true; clearInterval(timer); delete _activePolls[job]; if (_stuck[job] != null) { delete _stuck[job]; renderStopBar(); } onDone(ok); } var timer = setInterval(function() { if (finished || inFlight) return; if (location.hash !== startHash && !document.body.contains(logEl)) { if (++gone >= 3) { finished = true; clearInterval(timer); if (_activePolls[job] === timer) delete _activePolls[job]; if (_stuck[job] != null) { delete _stuck[job]; renderStopBar(); } } return; } gone = 0; inFlight = true; Promise.all([ callJobStatus(job), callLogTail(job) ]).then(function(res) { inFlight = false; if (finished) return; failCount = 0; var st = res[0], lg = res[1]; var txt = (lg && lg.lines) || ''; if (lg && lg.error && !txt) txt = lastTxt || ('==> Журнал операции не прочитался: ' + lg.error); lastTxt = txt; var sig = txt.split('\n').filter(function(l) { return !/^\s*Подождите/.test(l); }).join('\n'); if (sig !== lastSig) { lastSig = sig; progAt = Date.now(); } renderLog(logEl, txt); var idle = Math.round((Date.now() - progAt) / 1000); var done = !!(st && st.done === true); var tl = txt.replace(/\s+$/, ''), lastLine = tl.slice(tl.lastIndexOf('\n') + 1); if (!done && idle >= 20 && !/^\s*Подождите/.test(lastLine)) { var w = document.createElement('div'); w.className = 'zm-log-wait'; w.textContent = 'Подождите ' + new Array(Math.floor((idle - 20) / 5) + 2).join('.'); logEl.appendChild(w); logEl.scrollTop = logEl.scrollHeight; } var was = _stuck[job] != null; if (!done && idle >= 120) _stuck[job] = idle; else delete _stuck[job]; if (was || _stuck[job] != null) renderStopBar(); if (typeof onTick === 'function') onTick(); if (!done && st && st.running === false) deadPolls++; else deadPolls = 0; if (deadPolls >= 6) { renderLog(logEl, (txt ? txt.replace(/\s+$/, '') + '\n' : '') + '==> Операция прервалась: процесс на роутере завершился, не оставив итога. Обновите страницу и проверьте состояние.'); try { window.dispatchEvent(new CustomEvent('zm:changed', { detail: { job: job } })); } catch (e) {} end(false); return; } if (done) { try { window.dispatchEvent(new CustomEvent('zm:changed', { detail: { job: job } })); } catch (e) {} if (st.rc === '130') toast('Операция остановлена', 'warning'); if (!txt) renderLog(logEl, st.rc === '0' ? '==> Готово' : '==> Операция завершилась с ошибкой (код ' + (st.rc || '?') + ')'); end(st.rc === '0'); } }).catch(function() { inFlight = false; if (finished) return; failCount++; if (failCount >= 40) { renderLog(logEl, '==> ОШИБКА: роутер не отвечает, не удалось получить статус операции.'); end(false); } }); }, 1200); _activePolls[job] = timer; } function refreshBanner(message) { return E('div', { 'class': 'zm-refresh-banner zm-show' }, [ E('span', {}, message || 'Список меню LuCI мог измениться — выйдите и зайдите заново, чтобы увидеть изменения.'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { fetch(L.url('admin/logout'), { credentials: 'same-origin' }).catch(function() {}).then(function() { location.reload(); }); } }, 'Выйти из LuCI') ]); } function toastContainer() { var c = document.getElementById('zm-toast-container'); if (!c) { c = document.createElement('div'); c.id = 'zm-toast-container'; document.body.appendChild(c); } return c; } function toastFit() { var c = document.getElementById('zm-toast-container'); if (!c) return; var vh = window.innerHeight, dt = vh; if (_dockEl && _dockEl.classList.contains('zm-dock-on')) { var r = _dockEl.getBoundingClientRect(); if (r.height > 0 && r.top > 0) dt = r.top; } var room; if (document.body.classList.contains('zmw-body')) { var bot = dt < vh ? (vh - dt + 14) + 'px' : ''; if (c.style.bottom !== bot) c.style.bottom = bot; room = dt - (dt < vh ? 14 : 28) - 16; } else { room = dt - 14 - c.getBoundingClientRect().top; } room = Math.max(90, Math.floor(room)); c.style.maxHeight = room + 'px'; while (c.children.length > 1 && c.scrollHeight > room) c.removeChild(c.firstChild); } window.zmToastFit = toastFit; function toast(message, kind, duration) { var container = toastContainer(); var txt = String(message == null ? '' : message); Array.prototype.slice.call(container.children).forEach(function(o) { if (o._zmText === txt) container.removeChild(o); }); var el = E('div', { 'class': 'zm-toast zm-toast-' + (kind || 'info') }, [ E('span', { 'class': 'zm-toast-icon' }), E('span', { 'class': 'zm-toast-text' }, [ String(message == null ? '' : message) ]) ]); el._zmText = txt; container.appendChild(el); toastFit(); requestAnimationFrame(function() { el.classList.add('zm-toast-show'); }); var hide = function() { el.classList.remove('zm-toast-show'); setTimeout(function() { el.parentNode && el.parentNode.removeChild(el); }, 250); }; el.addEventListener('click', hide); setTimeout(hide, duration || (kind === 'error' ? 14000 : 9000)); } function notifyStrategyResult(res, okLabel) { if (res.error) { toast(res.error, 'error'); return false; } toast(okLabel + ' применена', 'info'); return true; } var SVC_BRANDS = [ [ /youtube/i, '#ff0033', 'YT' ], [ /discord/i, '#5865f2', 'DC' ], [ /telegram/i, '#229ed9', 'TG' ], [ /whatsapp/i, '#25d366', 'WA' ], [ /instagram|facebook|\bmeta\b/i, '#e1306c', 'IG' ], [ /twitter|^x\b/i, '#16181c', 'X' ], [ /tiktok/i, '#fe2c55', 'TT' ], [ /google play/i, '#01875f', 'GP' ], [ /gemini|google ai/i, '#4285f4', 'AI' ], [ /chatgpt|openai|claude|\bai\b|(^|[^а-яё])ии([^а-яё]|$)/i, '#10a37f', 'AI' ], [ /githubusercontent|github raw/i, '#57606a', 'GR' ], [ /github/i, '#24292f', 'GH' ], [ /google/i, '#4285f4', 'G' ], [ /roblox/i, '#e2231a', 'RB' ], [ /supercell|brawl|clash/i, '#f59e0b', 'SC' ], [ /rezka/i, '#3f8f3f', 'HD' ], [ /spotify/i, '#1db954', 'SP' ], [ /twitch/i, '#9146ff', 'TW' ], [ /netflix/i, '#e50914', 'NF' ], [ /steam/i, '#1b2838', 'ST' ], [ /cloudflare/i, '#f38020', 'CF' ], [ /amazon|aws|cloudfront/i, '#ff9900', 'AW' ], [ /linkedin/i, '#0a66c2', 'IN' ], [ /soundcloud/i, '#ff5500', 'SC' ], [ /reddit/i, '#ff4500', 'RD' ], [ /nalog/i, '#1f4e8c', 'НЛ' ], [ /rutor|rutracker|torrent/i, '#6b7280', 'RT' ], [ /hetzner/i, '#d50c2d', 'HZ' ], [ /ovh/i, '#123f6d', 'OV' ], [ /digitalocean/i, '#0080ff', 'DO' ], [ /hodca|хостинг/i, '#0f766e', 'HC' ] ]; var SVC_PALETTE = [ '#6366f1', '#0ea5e9', '#14b8a6', '#10b981', '#84cc16', '#f59e0b', '#f97316', '#ef4444', '#ec4899', '#8b5cf6', '#64748b', '#0891b2' ]; var SVC_KEY_ICO = { geoblock: [ '#0ea5e9', 'ГБ' ], block: [ '#ef4444', 'ЗБ' ], news: [ '#64748b', 'НВ' ], anime: [ '#ec4899', 'АН' ], porn: [ '#be123c', '18' ], hodca: [ '#0f766e', 'HC' ], russia_inside: [ '#2563eb', 'ВС' ], russia_outside: [ '#7c3aed', 'РФ' ], ukraine_inside: [ '#0057b7', 'UA' ], custom: [ '#0891b2', 'СВ' ] }; function svcIco(name, key) { name = String(name || ''); var kk = SVC_KEY_ICO[String(key || '').replace(/^c_itdoginfo_/, '')]; if (kk) return { color: kk[0], ico: kk[1] }; for (var i = 0; i < SVC_BRANDS.length; i++) if (SVC_BRANDS[i][0].test(name) || (key && SVC_BRANDS[i][0].test(key))) return { color: SVC_BRANDS[i][1], ico: SVC_BRANDS[i][2] }; var src = String(key || name), h = 0; for (var j = 0; j < src.length; j++) h = (h * 31 + src.charCodeAt(j)) >>> 0; var words = name.replace(/^[^A-Za-zА-Яа-яЁё0-9]+/, '').split(/[\s._\-·(),]+/).filter(function(w) { return w; }); var ico = words.length > 1 ? (words[0].charAt(0) + words[1].charAt(0)) : (words[0] || '?').slice(0, 2); return { color: SVC_PALETTE[h % SVC_PALETTE.length], ico: ico.toUpperCase() }; } function svcTx(v) { return (typeof v === 'string' || typeof v === 'number') ? [ String(v) ] : v; } var RI_IDS = { geoblock: 1, block: 1, news: 1, anime: 1, porn: 1, youtube: 1, discord: 1, meta: 1, instagram: 1, whatsapp: 1, twitter: 1, x: 1, tiktok: 1, hdrezka: 1 }; function riCovers(id) { return !!RI_IDS[String(id || '').replace(/^c_itdoginfo_/, '')]; } function riNote(items, sel) { var inc = (items || []).filter(function(s) { return !s.group && riCovers(s.id); }).map(function(s) { return s.name; }); return E('div', { 'class': 'zm-ri-note' }, [ E('b', {}, 'Включено «Всё сразу» (Russia inside). '), inc.length ? 'Уже входят: ' + inc.join(', ') + '. ' : '', 'Их отдельно включать не нужно. Telegram, GitHub и дополнительные списки в него не входят — включайте их отдельно.' ]); } function svcCard(o) { var ic = svcIco(o.name, o.key); var on = o.on || o.inc; return E('div', { 'class': 'zm-svc' + (on ? ' zm-svc-on' : '') + (o.inc && !o.on ? ' zm-svc-inc' : '') + (o.cls ? ' ' + o.cls : ''), 'title': o.title || (o.inc && !o.on ? 'Входит в «Всё сразу» (Russia inside)' : (typeof o.name === 'string' ? o.name : '')), 'click': o.click }, [ E('div', { 'class': 'zm-svc-ico', 'style': 'background:' + (o.color || ic.color) }, o.ico || ic.ico), E('div', { 'class': 'zm-svc-text' }, [ E('div', { 'class': 'zm-svc-name' }, svcTx(o.name)), o.inc && !o.on ? E('div', { 'class': 'zm-svc-sub' }, 'входит в «Всё сразу»') : o.sub ? E('div', { 'class': 'zm-svc-sub' }, svcTx(o.sub)) : E([]) ]), o.right || E('div', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : '') + (o.inc && !o.on ? ' zm-switch-inc' : '') }, [ E('span') ]) ]); } function swRow(on, label, hint, click, disabled) { return E('div', { 'class': 'zm-swrow' + (disabled ? ' zm-swrow-off' : ''), 'click': function(ev) { if (!disabled && click) click(ev); } }, [ E('div', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : '') }, [ E('span') ]), E('div', { 'class': 'zm-swrow-text' }, [ E('div', { 'class': 'zm-swrow-label' }, label), hint ? E('div', { 'class': 'zm-hint' }, hint) : E([]) ]) ]); } function saveBar(o) { o = o || {}; var el = E('div', { 'class': 'zm-savebar', 'style': 'display:none' }); var state = { dirty: false, busy: false }; function render() { el.innerHTML = ''; el.style.display = state.dirty || state.busy ? '' : 'none'; if (el.style.display) return; el.appendChild(E('span', { 'class': 'zm-savebar-dot' })); el.appendChild(E('span', { 'class': 'zm-savebar-text' }, [ (el._zmCtx ? el._zmCtx + ': ' : '') + (state.busy ? (o.busyText || 'Применяем…') : (state.text || o.text || 'Есть несохранённые изменения')) ])); el.appendChild(E('div', { 'class': 'zm-savebar-btns' }, [ E('button', { 'class': 'cbi-button', 'disabled': state.busy ? '' : null, 'click': function() { if (o.onCancel) o.onCancel(); } }, o.cancelLabel || 'Отменить'), E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': state.busy ? '' : null, 'click': function() { if (o.onSave) o.onSave(); } }, state.busy ? (o.busyLabel || 'Применяем…') : (state.label || o.saveLabel || 'Сохранить и применить')) ])); } el.set = function(dirty, busy, text, label) { state.dirty = !!dirty; state.busy = !!busy; state.text = text || ''; state.label = label || ''; render(); var show = state.dirty || state.busy; el._zmWant = show; if (show) setTimeout(function() { if (state.dirty || state.busy) dock(el, true); }, 0); else dock(el, false); return el; }; el.isDirty = function() { return state.dirty; }; el._zmRender = render; render(); return el; } function editorBar(ta, o) { var orig = ta.value, busy = false; var bar = saveBar({ saveLabel: o.saveLabel, text: o.text, onCancel: function() { if (o.onCancel) { o.onCancel(); return; } ta.value = orig; sync(); }, onSave: function() { busy = true; sync(); Promise.resolve(o.onSave(ta.value)).then(function(ok) { busy = false; if (ok !== false) orig = ta.value; sync(); }, function() { busy = false; sync(); }); } }); function sync() { bar.set(ta.value !== orig, busy); } ta.addEventListener('input', sync); bar.reset = function(v) { if (v != null) ta.value = v; orig = ta.value; busy = false; sync(); }; bar.dirty = function() { return ta.value !== orig; }; return bar; } function blockCard(card, title, hint, text) { card.innerHTML = ''; card.appendChild(E('h3', {}, title)); if (hint) card.appendChild(E('p', { 'class': 'zm-hint' }, hint)); card.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-block-banner' }, [ E('span', {}, text) ])); return card; } function svcGrid(list) { return E('div', { 'class': 'zm-svc-grid' }, list); } function svcGroup(o) { var body = E('div', { 'style': o.open ? '' : 'display:none' }, [ o.body ]); var head = svcCard({ name: o.name, sub: o.sub, key: o.key, color: o.color, ico: o.ico, on: false, cls: 'zm-svc-group' + (o.open ? ' zm-svc-open' : ''), right: E('span', { 'style': 'display:inline-flex; align-items:center; gap:10px' }, [ o.badge || E([]), E('span', { 'class': 'zm-svc-chev' }, '›') ]), click: function() { var open = body.style.display === 'none'; body.style.display = open ? '' : 'none'; head.classList.toggle('zm-svc-open', open); if (o.toggle) o.toggle(open); } }); return E('div', { 'class': 'zm-svc-groupwrap' }, [ head, body ]); } function rebootWait(note) { var t0 = Date.now(); var ov = E('div', { 'class': 'zm-reboot-ov' }, [ E('div', { 'class': 'zm-reboot-box' }, [ E('b', {}, 'Роутер перезагружается'), note ]) ]); document.body.appendChild(ov); function tick() { var sec = Math.round((Date.now() - t0) / 1000); note.textContent = 'Страница переподключится сама, обычно через 1–2 минуты. Прошло ' + sec + ' с.'; if (sec < 25) { setTimeout(tick, 1000); return; } var ctl = window.AbortController ? new AbortController() : null; var tm = ctl ? setTimeout(function() { ctl.abort(); }, 4000) : 0; fetch(location.pathname + location.search, { cache: 'no-store', credentials: 'same-origin', signal: ctl ? ctl.signal : undefined }).then(function(r) { clearTimeout(tm); if (r.ok) location.reload(); else setTimeout(tick, 3000); }, function() { clearTimeout(tm); setTimeout(tick, 3000); }); } tick(); } function rebootRouter() { if (!confirm('Перезагрузить роутер?\n\nИнтернет пропадёт на 1–2 минуты, потом всё поднимется само.')) return Promise.resolve(false); return callSystemReboot().then(function(r) { if (r && r.error) { toast(r.error, 'error'); return false; } rebootWait(E('span', { 'class': 'zm-hint', 'style': 'margin:0' })); return true; }, function() { toast('Роутер не ответил', 'error'); return false; }); } function rebootBanner(text) { var btn = E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { btn.disabled = true; rebootRouter().then(function(ok) { if (!ok) btn.disabled = false; }); } }, 'Перезагрузить роутер'); return E('div', { 'class': 'zm-refresh-banner zm-show zm-reboot-banner' }, [ E('span', {}, String(text)), btn ]); } function rebootSlot() { var el = E('div', { 'class': 'zm-reboot-slot' }); el.refresh = function() {}; if (document.body.classList.contains('zmw-body')) return el; el.refresh = function() { callHealth().then(function(h) { el.innerHTML = ''; if (h && h.reboot_hint) el.appendChild(rebootBanner(h.reboot_hint)); }).catch(function() {}); }; el.refresh(); window.addEventListener('zm:changed', function() { setTimeout(el.refresh, 800); }); return el; } function alertBanners(h, done) { var box = E('div', { 'class': 'zm-alerts' }); function one(text, btn, key, call, okText) { box.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, [ E('span', {}, text), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function(ev) { var b = ev.currentTarget; b.disabled = true; toast('Применяем…', 'warning'); callSystemStatus().then(function(s) { if (s && s[key] === true) return { ok: true }; return call(); }).then(function(r) { if (r && r.error) { toast(r.error, 'error'); b.disabled = false; return; } if (r && r[key] === false) return call().then(function() { toast('Не получилось — попробуйте ещё раз', 'error'); b.disabled = false; }); toast(okText, 'info'); if (done) done(); }).catch(function() { b.disabled = false; toast('Роутер не ответил', 'error'); }); } }, btn) ])); } if (h && h.reboot_hint) box.appendChild(rebootBanner(h.reboot_hint)); if (h && h.flow_warn) one('Включён Flow Offloading, а FIX для него не применён — Zapret может работать с перебоями.', 'Применить FIX', 'flow_offloading_fix', callSystemToggleFlowOffloadingFix, 'FIX применён'); if (h && h.ipv6_warn) one('Роутер работает по IPv6, а в Zapret IPv6 выключен — часть сайтов может открываться без обхода.', 'Включить IPv6', 'ipv6_enabled', callSystemToggleIpv6, 'IPv6 в Zapret включён'); return box; } function exclParse(v, exact) { var seen = {}, out = []; String(v || '').split(/[|,\n]/).forEach(function(x) { x = x.trim(); var k = exact ? x : x.toLowerCase(); if (x && !seen[k]) { seen[k] = true; out.push(x); } }); return out; } function exclNorm(v) { return exclParse(v).join('|'); } function exclVariants(list) { var seen = {}, out = []; (list || []).forEach(function(m) { var lo = m.toLowerCase(), up = m.toUpperCase(); [ m, lo, up, up.charAt(0) + lo.slice(1) ].forEach(function(x) { if (x && !seen[x]) { seen[x] = true; out.push(x); } }); }); return out; } function exclHit(name, list) { var n = String(name || '').toLowerCase(); return (list || []).some(function(m) { return m && n.indexOf(m.toLowerCase()) !== -1; }); } function exclField(o) { var input = E('input', { 'type': 'text', 'class': 'cbi-input-text zm-excl-input', 'spellcheck': 'false', 'maxlength': '400', 'placeholder': o.placeholder || 'LTE|Мобильный|🇪🇺', 'style': 'width:100%; max-width:560px' }); input.value = o.value || ''; var info = E('div', { 'class': 'zm-hint', 'style': 'margin:6px 0 0' }); function sync() { var list = exclParse(input.value), names = typeof o.names === 'function' ? (o.names() || []) : []; var hit = names.filter(function(n) { return exclHit(n, list); }).length; info.textContent = !list.length ? (o.emptyText || 'Фильтр выключен — видны все серверы.') : names.length ? 'Под фильтр попадает ' + hit + ' из ' + names.length + (hit >= names.length ? ' — это все серверы, так работать не будет' : '') : 'Маркеров: ' + list.length + '. Регистр букв не важен.'; info.style.color = list.length && names.length && hit >= names.length ? 'var(--zm-bad, #dc2626)' : ''; if (o.onChange) o.onChange(exclNorm(input.value)); } input.addEventListener('input', sync); var el = E('div', { 'class': 'zm-excl' }, [ E('div', { 'class': 'zm-label', 'style': 'margin-bottom:6px' }, [ o.label || 'Скрывать серверы, в названии которых есть' ]), input, info, E('p', { 'class': 'zm-hint', 'style': 'margin:6px 0 0' }, [ o.hint || 'Маркеры через | — например LTE|Мобильный|🇪🇺. Совпадение по части названия, регистр не важен. Такие серверы не показываются и не используются.' ]) ]); el.input = input; el.value = function() { return exclNorm(input.value); }; el.set = function(v) { input.value = v || ''; sync(); }; el.refresh = sync; setTimeout(sync, 0); return el; } function exactHit(name, list) { var n = String(name || ''); return (list || []).some(function(m) { return m && n.indexOf(m) !== -1; }); } function hideTokens(names, exact) { var hit = exact ? exactHit : exclHit; var rxFlag, rxPic, rxWord; try { rxFlag = new RegExp('\\p{Regional_Indicator}{2}', 'gu'); rxPic = new RegExp('\\p{Extended_Pictographic}', 'gu'); rxWord = new RegExp('[\\p{L}\\p{N}]+', 'gu'); } catch (e) { return []; } var cand = {}; function add(key, label, kind) { var c = cand[key] || (cand[key] = { key: key, kind: kind, forms: {} }); c.forms[label] = (c.forms[label] || 0) + 1; } names.forEach(function(n) { n = String(n || ''); var flags = n.match(rxFlag) || []; flags.forEach(function(f) { add(f, f, 'flag'); }); var rest = n; flags.forEach(function(f) { rest = rest.split(f).join(' '); }); (rest.match(rxPic) || []).forEach(function(x) { add(x, x, 'pic'); }); (n.match(rxWord) || []).forEach(function(w) { if (w.length < 2 || /^\d+$/.test(w)) return; add(exact ? w : w.toLowerCase(), w, 'word'); }); }); var total = names.length, out = []; Object.keys(cand).forEach(function(k) { var c = cand[k], best = '', bn = -1; Object.keys(c.forms).forEach(function(f) { if (c.forms[f] > bn) { bn = c.forms[f]; best = f; } }); var cnt = names.filter(function(n) { return hit(n, [ k ]); }).length; if (cnt < 2 || cnt >= total) return; out.push({ marker: c.kind === 'word' ? best : k, label: c.kind === 'pic' ? k + '️' : best, kind: c.kind, count: cnt }); }); var rank = { flag: 0, pic: 1, word: 2 }; out.sort(function(x, y) { return rank[x.kind] - rank[y.kind] || y.count - x.count || (x.label < y.label ? -1 : 1); }); var lim = { flag: 40, pic: 12, word: 24 }, used = { flag: 0, pic: 0, word: 0 }; return out.filter(function(t) { return used[t.kind]++ < lim[t.kind]; }); } function hidePicker(o) { o = o || {}; var W = o.words || {}; var hit = o.exact ? exactHit : exclHit, low = o.exact ? function(x) { return x; } : function(x) { return x.toLowerCase(); }; var noun = o.noun || 'серверы'; var wHide = W.hide || 'Скрыть', wHidden = W.hidden || 'Скрыто', wOn = W.on || 'скрыт', wOff = W.off || 'виден'; var wDesc = W.desc || ('Эти ' + noun + ' не показываются в списке и не используются.'); var wHint = W.hint || ('Отметьте ' + noun + ', которые нужно скрыть, и нажмите «Сохранить». Скрытые не показываются и не используются — ни вручную, ни в автовыборе.'); var el = E('div', { 'class': 'zm-hide' }); var items = [], info = {}, selecting = false, pick = {}, phr = [], phr0 = '', phrI = [], saving = false, lastSig = ''; var bar = saveBar({ saveLabel: 'Сохранить', busyText: 'Сохраняем…', onCancel: function() { stop(); }, onSave: save }); function real() { return items.filter(function(it) { return !it.gone; }); } function hiddenCount() { return items.filter(function(it) { return it.hidden || it.locked; }).length; } function byPhrase(it) { return !!((phr.length && hit(it.name, phr)) || (it.locked && phrKey(phr) === phr0)); } function isOn(it) { return byPhrase(it) || !!pick[it.name]; } function pickedCount() { return real().filter(isOn).length; } function uniq(a) { var o = {}; a.forEach(function(x) { o[x] = true; }); return Object.keys(o).sort().join('\n'); } function phrKey(a) { return a.map(low).sort().join('|'); } function mode(on) { selecting = on; if (o.onMode) o.onMode(on); render(); } function start() { pick = {}; phr = exclParse(info.markers || '', o.exact); phr0 = phrKey(phr); phrI = phr.slice(); items.forEach(function(it) { if (it.hidden) pick[it.name] = true; }); mode(true); } function stop() { pick = {}; phr = []; bar.set(false); mode(false); } function sigWas() { return uniq(items.filter(function(it) { return it.hidden && !hit(it.name, phrI); }).map(function(it) { return it.name; })) + '#' + phr0; } function sigNow() { return uniq(Object.keys(pick).filter(function(k) { return pick[k] && !hit(k, phr); })) + '#' + phrKey(phr); } function syncBar() { var same = sigWas() === sigNow(); bar.set(true, saving, wHidden + ' будет: ' + pickedCount() + ' из ' + real().length + (same ? ' — без изменений' : ''), same ? 'Готово' : 'Сохранить'); Array.prototype.forEach.call(el.querySelectorAll('.zm-hide-tools button, .zm-hide-quick button'), function(b) { b.disabled = saving; }); } function save() { if (saving) return; if (sigWas() === sigNow()) { stop(); return; } var names = Object.keys(pick).filter(function(k) { return pick[k] && !hit(k, phr); }); var r = real(), left = r.filter(function(it) { return !isOn(it); }).length; if (r.length && !left) { toast('Нельзя убрать все ' + noun + ' — тогда подключение не будет работать', 'warning'); return; } var mk = phr.join('|'); saving = true; syncBar(); Promise.resolve(o.onSave(names, mk)).then(function(ok) { saving = false; if (ok === false) { syncBar(); return; } var keep = phr.slice(); info.hideNames = names; info.markers = mk; items = items.filter(function(it) { return !it.gone || names.indexOf(it.name) >= 0; }); items.forEach(function(it) { it.hidden = names.indexOf(it.name) >= 0; it.locked = !it.hidden && keep.length > 0 && hit(it.name, keep); }); stop(); }, function() { saving = false; syncBar(); }); } function reset() { if (!confirm('Вернуть все ' + noun + ' в список?')) return; Promise.resolve(o.onReset()).then(function(ok) { if (ok !== false) stop(); }, function() {}); } function markGrid() { Array.prototype.forEach.call(el.querySelectorAll('.zm-hide-grid > .zm-node'), function(t) { var it = t._zmItem, ph = byPhrase(it), on = ph || !!pick[it.name]; t.classList.toggle('zm-hide-on', on); t.classList.toggle('zm-hide-lock', !!ph); t.title = ph ? 'Убран по фразе — снимите фразу выше, чтобы вернуть' : ''; t.querySelector('.zm-hide-mark').textContent = ph ? 'по фразе' : on ? wOn : wOff; }); Array.prototype.forEach.call(el.querySelectorAll('.zm-hide-quick button'), function(b) { b.classList.toggle('zm-hide-q-on', phr.some(function(x) { return low(x) === low(b._zmMarker); })); }); syncBar(); } function togglePhrase(m) { if (saving) return; var lo = low(m), had = phr.some(function(x) { return low(x) === lo; }); phr = had ? phr.filter(function(x) { return low(x) !== lo; }) : phr.concat([ m ]); markGrid(); } function quickBox() { var names = real().map(function(it) { return it.name; }); var toks = hideTokens(names, o.exact), seen = {}; toks.forEach(function(t) { seen[low(t.marker)] = true; }); phr.forEach(function(m) { if (seen[low(m)]) return; toks.push({ marker: m, label: m, kind: 'own', count: names.filter(function(n) { return hit(n, [ m ]); }).length }); }); if (!toks.length) return E([]); var btns = toks.map(function(t) { var b = E('button', { 'type': 'button', 'class': 'zm-hide-q zm-hide-q-' + t.kind, 'title': 'Встречается в названиях: ' + t.count, 'click': function() { togglePhrase(t.marker); } }, [ E('span', {}, [ t.label ]), E('small', {}, [ String(t.count) ]) ]); b._zmMarker = t.marker; return b; }); return E('div', { 'class': 'zm-hide-quick' }, [ E('div', { 'class': 'zm-hint', 'style': 'margin:0 0 6px' }, [ 'Быстрый выбор: нажмите на слово, флаг или значок — уберутся все ' + noun + ', в названии которых оно есть (и новые с ним тоже). Число — сколько совпадений сейчас.' ]), E('div', { 'class': 'zm-hide-qlist' }, btns) ]); } function render() { el.innerHTML = ''; if (info.error && !items.length && !hiddenCount()) { el.appendChild(E('p', { 'class': 'zm-hint' }, [ info.error ])); return; } var hc = hiddenCount(); if (!selecting) { var acts = []; if (!info.noEdit && (items.length > 1 || hc)) acts.push(E('button', { 'class': 'cbi-button', 'disabled': o.busy && o.busy() ? '' : null, 'click': start }, hc || info.markers ? (W.edit || 'Изменить список скрытых') : wHide + ' ' + noun + '…')); if (hc || info.markers) acts.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'disabled': o.busy && o.busy() ? '' : null, 'click': reset }, W.reset || ('Вернуть все ' + noun))); if (!acts.length) return; if (hc || info.markers) { var hidden = items.filter(function(it) { return it.hidden || it.locked; }).map(function(it) { return it.name || '(без названия)'; }); var chips = hidden.slice(0, 12).map(function(n) { return E('span', { 'class': 'zm-hide-chip' }, [ String(n) ]); }); if (hidden.length > 12) chips.push(E('span', { 'class': 'zm-hide-chip zm-hide-more' }, [ '+ ещё ' + (hidden.length - 12) ])); var box = E('div', { 'class': 'zm-hide-box' }, [ E('div', { 'class': 'zm-hide-head' }, [ E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), hc ? wHidden + ': ' + hc : wHidden + ' по фразам' ]), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ wDesc ]) ]), chips.length ? E('div', { 'class': 'zm-hide-chips' }, chips) : E([]), info.markers ? E('p', { 'class': 'zm-hint', 'style': 'margin:0' }, [ 'Также убираются названия с «' + exclParse(info.markers, o.exact).join('», «') + '».' ]) : E([]), E('div', { 'class': 'zm-actions', 'style': 'margin:0' }, acts) ]); el.appendChild(box); } else el.appendChild(E('div', { 'class': 'zm-hide-bar' }, [ E('div', { 'class': 'zm-actions', 'style': 'margin:0' }, acts) ])); bar.set(false); return; } el.appendChild(E('p', { 'class': 'zm-hint' }, [ wHint ])); el.appendChild(quickBox()); var grid = E('div', { 'class': 'zm-nodes zm-hide-grid' }); items.forEach(function(it) { var t = E('div', { 'class': 'zm-node', 'click': function() { if (saving || byPhrase(it)) return; pick[it.name] = !pick[it.name]; markGrid(); } }, [ E('div', { 'class': 'zm-node-name' }, [ String(it.name || '(без названия)') ]), E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ String(it.foot || '') ]), E('span', { 'class': 'zm-hide-mark' }, [ '' ]) ]) ]); t._zmItem = it; grid.appendChild(t); }); el.appendChild(grid); el.appendChild(E('div', { 'class': 'zm-actions zm-hide-tools' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (!saving) { pick = {}; phr = []; markGrid(); } } }, 'Снять всё'), E('button', { 'class': 'cbi-button', 'click': stop }, 'Выйти без изменений') ])); el.appendChild(bar); markGrid(); } el.update = function(list, i) { info = i || {}; items = (list || []).filter(function(it) { return it && it.name != null; }); var have = {}; items.forEach(function(it) { have[it.name] = true; }); (info.hideNames || []).forEach(function(n) { if (n && !have[n]) { have[n] = true; items.push({ name: n, foot: 'нет в подписке', hidden: true, gone: true }); } }); var sig = JSON.stringify([ items.map(function(it) { return [ it.name, it.foot, !!it.hidden, !!it.locked, !!it.gone ]; }), info.markers || '', info.error || '', !!info.noEdit ]); if (selecting && sig === lastSig) return; lastSig = sig; render(); }; el.selecting = function() { return selecting; }; el.appendChild(bar); return el; } function linkInfo(l) { var m = /^([a-z0-9]+):\/\//i.exec(l), proto = m ? m[1].toLowerCase() : '', name = '', host = ''; var h = l.indexOf('#'); if (h >= 0) { try { name = decodeURIComponent(l.slice(h + 1).replace(/\+/g, ' ')); } catch (e) { name = l.slice(h + 1); } } var body = m ? l.slice(m[0].length, h >= 0 ? h : undefined) : l; if (proto === 'vmess') { try { var j = JSON.parse(atob(body.replace(/[?\/].*$/, ''))); name = name || j.ps || ''; host = (j.add || '') + (j.port ? ':' + j.port : ''); } catch (e) {} } if (!host) { var at = body.lastIndexOf('@'), rest = at >= 0 ? body.slice(at + 1) : body; rest = rest.replace(/[?\/].*$/, ''); if (/^[\w.\-\[\]:]+$/.test(rest) && /[.:\]]/.test(rest)) host = rest; } return { proto: proto, name: name, host: host }; } function linksEditor(o) { o = o || {}; var re = o.re || /^[a-z0-9]+:\/\/\S+$/i; var lines = [], editing = -1, raw = false; var el = E('div', { 'class': 'zm-links-ed' }); var addTa = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'autocapitalize': 'off', 'rows': '3', 'placeholder': o.placeholder || 'vless://…', 'style': 'min-height:70px' }); var rawTa = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'autocapitalize': 'off', 'placeholder': o.placeholder || 'vless://…', 'style': 'min-height:130px' }); rawTa.addEventListener('input', function() { lines = parse(rawTa.value); emit(); }); function parse(t) { return String(t || '').split(/\r?\n/).map(function(x) { return x.trim(); }).filter(function(x) { return x && !/^(#|\/\/)/.test(x); }); } function emit() { if (o.onChange) o.onChange(lines.join('\n')); } function btn(text, cls, fn, title) { return E('button', { 'type': 'button', 'class': 'cbi-button' + (cls ? ' ' + cls : ''), 'title': title || '', 'disabled': o.busy && o.busy() ? '' : null, 'click': fn }, text); } function addLines(t) { var add = parse(t), bad = 0, dup = 0, n = 0, keep = []; add.forEach(function(x) { if (!re.test(x)) { bad++; keep.push(x); return; } if (lines.indexOf(x) >= 0) { dup++; return; } lines.push(x); n++; }); if (n) emit(); addTa.value = keep.join('\n'); if (bad) toast('Не распознано строк: ' + bad + (o.kinds ? ' — нужны ссылки ' + o.kinds : ''), 'warning'); else if (dup && !n) toast('Эти ссылки уже есть в списке', 'warning'); return n > 0 || (!bad && !dup); } function row(l, i) { var inf = linkInfo(l), ok = re.test(l); if (editing === i) { var inp = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'spellcheck': 'false', 'autocapitalize': 'off', 'style': 'flex:1; min-width:0' }); inp.value = l; setTimeout(function() { try { inp.focus(); } catch (e) {} }, 0); function apply() { var v = inp.value.trim(); if (!v) { lines.splice(i, 1); editing = -1; emit(); render(); return; } if (!re.test(v)) { toast('Это не ссылка' + (o.kinds ? ' ' + o.kinds : ''), 'warning'); return; } if (v !== l && lines.indexOf(v) >= 0) { toast('Такая ссылка уже есть', 'warning'); return; } lines[i] = v; editing = -1; emit(); render(); } inp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); apply(); } else if (ev.key === 'Escape') { editing = -1; render(); } }); return E('div', { 'class': 'zm-link-row zm-link-edit' }, [ E('span', { 'class': 'zm-link-num' }, String(i + 1)), inp, E('div', { 'class': 'zm-link-btns' }, [ btn('Готово', 'cbi-button-positive', apply), btn('Отмена', '', function() { editing = -1; render(); }) ]) ]); } return E('div', { 'class': 'zm-link-row' + (ok ? '' : ' zm-link-bad') }, [ E('span', { 'class': 'zm-link-num' }, String(i + 1)), E('div', { 'class': 'zm-link-main' }, [ E('div', { 'class': 'zm-link-name' }, [ inf.name || inf.host || l.slice(0, 60) ]), E('div', { 'class': 'zm-link-sub' }, [ (inf.proto || '?') + (inf.host ? ' · ' + inf.host : '') + (ok ? '' : ' · не распознана') ]) ]), E('div', { 'class': 'zm-link-btns' }, [ btn('Изменить', '', function() { editing = i; render(); }), btn('✕', 'cbi-button-remove', function() { if (!confirm('Убрать сервер «' + (inf.name || inf.host || 'без названия') + '» из списка?')) return; lines.splice(i, 1); editing = -1; emit(); render(); }, 'Убрать из списка') ]) ]); } function render() { el.innerHTML = ''; if (raw) { rawTa.value = lines.join('\n'); el.appendChild(rawTa); el.appendChild(E('div', { 'class': 'zm-actions zm-links-tools' }, [ btn('Показать списком', '', function() { lines = parse(rawTa.value); raw = false; render(); }) ])); return; } if (lines.length) el.appendChild(E('div', { 'class': 'zm-links' }, lines.map(row))); else el.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 8px' }, [ o.empty || 'Ссылок пока нет — добавьте ниже.' ])); el.appendChild(E('div', { 'class': 'zm-links-add' }, [ E('div', { 'class': 'zm-links-add-label' }, lines.length ? 'Добавить серверы' : 'Ссылки на серверы'), addTa, E('div', { 'class': 'zm-actions zm-links-tools' }, [ btn('Добавить', 'cbi-button-action', function() { if (!addTa.value.trim()) { toast('Вставьте ссылку в поле', 'warning'); addTa.focus(); return; } var had = lines.length; addLines(addTa.value); if (lines.length !== had || !addTa.value) render(); }), lines.length ? btn('Изменить текстом', '', function() { editing = -1; raw = true; render(); }) : '' ]) ])); } el.set = function(v) { lines = parse(v); editing = -1; render(); }; el.get = function() { return lines.join('\n'); }; el.pending = function() { return addTa.value.trim(); }; el.flush = function() { if (raw) { lines = parse(rawTa.value); emit(); } if (!addTa.value.trim()) return true; addLines(addTa.value); render(); return !addTa.value.trim(); }; el.set(o.value || ''); return el; } function routeCheck(o) { o = o || {}; var busy = false, res = null, err = '', last = ''; var el = E('div', { 'class': 'zm-route' }); var inp = E('input', { 'type': 'text', 'class': 'cbi-input-text zm-route-input', 'spellcheck': 'false', 'autocapitalize': 'off', 'autocomplete': 'off', 'inputmode': 'url', 'placeholder': o.placeholder || 'youtube.com' }); inp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); run(); } }); var btn = E('button', { 'type': 'button', 'class': 'cbi-button cbi-button-action', 'click': function() { run(); } }, 'Проверить'); var chips = E('div', { 'class': 'zm-route-chips' }); var out = E('div', { 'class': 'zm-route-out' }); (o.examples || [ 'youtube.com', 'instagram.com', 'discord.com', 'chatgpt.com', 'epidemz.net.co', 'speedtest.net', 'telegram.org' ]).forEach(function(x) { chips.appendChild(E('button', { 'type': 'button', 'class': 'zm-route-chip', 'click': function() { inp.value = x; run(); } }, x)); }); function off() { return !!(o.disabled && o.disabled()); } function run() { var v = inp.value.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/[\/?#].*$/, '').replace(/^[^@]*@/, ''); if (/^[^:\[\]]+:\d+$/.test(v)) v = v.replace(/:\d+$/, ''); if (busy) return; if (!v) { toast('Введите домен или IP-адрес', 'warning'); inp.focus(); return; } inp.value = v; if (off()) { toast(o.offText || 'Сейчас проверить нельзя', 'warning'); return; } busy = true; err = ''; last = v; paint(); Promise.resolve(o.call(v)).then(function(r) { busy = false; if (!r || r.error) { res = null; err = (r && r.error) || 'Не удалось проверить'; } else res = r; paint(); }, function(e) { busy = false; res = null; err = 'Не удалось проверить: ' + ((e && e.message) || e); paint(); }); } function paint() { btn.disabled = busy; btn.textContent = busy ? 'Проверяем…' : 'Проверить'; inp.disabled = busy; out.innerHTML = ''; if (busy) { out.appendChild(E('div', { 'class': 'zm-hint zm-route-wait' }, 'Спрашиваем роутер, куда пойдёт ' + last + '…')); return; } if (err) { out.appendChild(E('div', { 'class': 'zm-route-res zm-route-bad' }, [ E('div', { 'class': 'zm-route-head' }, [ E('span', { 'class': 'zm-badge zm-bad' }, [ E('span', { 'class': 'zm-dot' }), 'ошибка' ]), E('span', { 'class': 'zm-route-what' }, err) ]) ])); return; } if (!res) return; var v = o.verdict(res) || {}; var box = E('div', { 'class': 'zm-route-res zm-route-' + (v.tone || 'off') }, [ E('div', { 'class': 'zm-route-head' }, [ E('span', { 'class': 'zm-badge ' + (v.cls || 'zm-off') }, [ E('span', { 'class': 'zm-dot' }), v.label || '?' ]), E('span', { 'class': 'zm-route-what' }, [ E('b', {}, res.target || last), v.title ? ' — ' + v.title : '' ]) ]) ]); (v.lines || []).forEach(function(l) { if (l && l[1]) box.appendChild(E('div', { 'class': 'zm-route-line' }, [ E('span', { 'class': 'zm-route-k' }, l[0]), E('span', { 'class': 'zm-route-v' }, [].concat(l[1])) ])); }); if (v.note) box.appendChild(E('div', { 'class': 'zm-hint zm-route-note' }, [].concat(v.note))); if (v.raw) box.appendChild(E('details', { 'class': 'zm-route-raw' }, [ E('summary', {}, 'Ответ роутера'), E('pre', { 'class': 'zm-log' }, v.raw) ])); out.appendChild(box); } el.appendChild(E('div', { 'class': 'zm-route-form' }, [ inp, btn ])); el.appendChild(chips); el.appendChild(out); el.reset = function() { res = null; err = ''; paint(); }; paint(); return el; } function hostsWarn(name) { var href = document.body.classList.contains('zmw-body') ? '#/hosts' : L.url('admin/services/zapret-manager/hosts'); var el = E('div', { 'class': 'zm-refresh-banner zm-show zm-hosts-warn', 'role': 'note', 'style': 'display:none' }, [ E('span', { 'style': 'line-height:1.5' }, [ 'В ', E('a', { 'href': href }, 'hosts'), ' есть сторонние записи — они могут мешать работе ' + name + '.' ]) ]); el.set = function(on) { el.style.display = on ? '' : 'none'; return el; }; return el; } return baseclass.extend({ injectCss: injectCss, badge: badge, pollJob: pollJob, alertBanners: alertBanners, svcCard: svcCard, svcGrid: svcGrid, svcGroup: svcGroup, renderLog: renderLog, refreshBanner: refreshBanner, toast: toast, notifyStrategyResult: notifyStrategyResult, status: callStatus, systemInfo: callSystemInfo, zapretAction: callZapretAction, zapretLatestVersion: callZapretLatestVersion, zapret2Action: callZapret2Action, strategyListV: callStrategyListV, strategySetV: callStrategySetV, strategyListFlowseal: callStrategyListFlowseal, strategySetFlowseal: callStrategySetFlowseal, strategyListYoutube: callStrategyListYoutube, strategySetYoutube: callStrategySetYoutube, youtubeQuicSet: callYoutubeQuicSet, discordStatus: callDiscordStatus, discordSetDv: callDiscordSetDv, discordSetFake: callDiscordSetFake, gameStatus: callGameStatus, gameSet: callGameSet, gameSetFake: callGameSetFake, gameToggleXtreme: callGameToggleXtreme, systemStatus: callSystemStatus, systemCheckConnectivity: callSystemCheckConnectivity, systemToggleQuic: callSystemToggleQuic, systemToggleIpv6: callSystemToggleIpv6, systemToggleFlowOffloadingFix: callSystemToggleFlowOffloadingFix, systemUninstallPanel: callSystemUninstallPanel, sysinfoRun: callSysinfoRun, sysinfoGet: callSysinfoGet, mirrorStatus: callMirrorStatus, mirrorSet: callMirrorSet, exclusionsStatus: callExclusionsStatus, exclusionsToggle: callExclusionsToggle, exclusionsClear: callExclusionsClear, nfqwsOptGet: callNfqwsOptGet, zapretListsStatus: callZapretListsStatus, zapretListGet: callZapretListGet, zapretListSet: callZapretListSet, zapretListRestore: callZapretListRestore, nfqwsOptSet: callNfqwsOptSet, tgStatus: callTgStatus, tgAction: callTgAction, tgRestartAll: callTgRestartAll, tgwsStatus: callTgwsStatus, tgwsAction: callTgwsAction, hostsStatus: callHostsStatus, hostsToggle: callHostsToggle, hostsReplaceGeohide: callHostsReplaceGeohide, hostsReset: callHostsReset, hostsFileGet: callHostsFileGet, hostsFileSet: callHostsFileSet, dohStatus: callDohStatus, dohInstall: callDohInstall, dohRemove: callDohRemove, dohSet: callDohSet, dohBootstrapSet: callDohBootstrapSet, dohForceSet: callDohForceSet, testStatus: callTestStatus, testAction: callTestAction, testResults: callTestResults, zmUpdateStatus: callZmUpdateStatus, zmUpdateAction: callZmUpdateAction, mixomoStatus: callMixomoStatus, mixomoAction: callMixomoAction, mixomoConfigGet: callMixomoConfigGet, mixomoConfigSet: callMixomoConfigSet, mixomoApplySubscription: callMixomoApplySubscription, mixomoFilterSet: callMixomoFilterSet, mixomoProxies: callMixomoProxies, mixomoMagitrickleListSet: callMixomoMagitrickleListSet, mixomoAutorestartSet: callMixomoAutorestartSet, mixomoUiAction: callMixomoUiAction, mixomoWarpStatus: callMixomoWarpStatus, mixomoWarpAction: callMixomoWarpAction, mixomoWarpIntegrateAction: callMixomoWarpIntegrateAction, mixomoWarpConfigSet: callMixomoWarpConfigSet, health: callHealth, awgStatus: callAwgStatus, awgAction: callAwgAction, steerStatus: callSteerStatus, versions: callVersions, steerAction: callSteerAction, boardInfo: callBoardInfo, forkopStatus: callForkopStatus, forkopConfigGet: callForkopConfigGet, forkopConfigSet: callForkopConfigSet, forkopAction: callForkopAction, jobsCancel: callJobsCancel, dock: dock, riCovers: riCovers, riNote: riNote, swRow: swRow, saveBar: saveBar, exclParse: exclParse, exclNorm: exclNorm, exclVariants: exclVariants, exclHit: exclHit, exclField: exclField, hidePicker: hidePicker, linksEditor: linksEditor, linkInfo: linkInfo, routeCheck: routeCheck, rebootBanner: rebootBanner, rebootRouter: rebootRouter, rebootSlot: rebootSlot, hostsWarn: hostsWarn, editorBar: editorBar, blockCard: blockCard, parseSize: parseSize, fmtSize: fmtSize, secret: secret, stateBadge: stateBadge }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/zapret-manager/common.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/dashboard.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require ui'; 'require zapret-manager.common as zm'; return view.extend({ load: function() { zm.injectCss(); return Promise.all([ zm.status(), zm.dohStatus().catch(function() { return {}; }), zm.hostsStatus().catch(function() { return { items: [] }; }), zm.systemStatus().catch(function() { return {}; }), zm.systemInfo().catch(function() { return {}; }), zm.zmUpdateStatus().catch(function() { return {}; }), zm.health().catch(function() { return {}; }), zm.boardInfo().catch(function() { return {}; }) ]); }, render: function(all) { var view = this; var data = all[0], dohData = all[1], hostsData = all[2], sysData = all[3]; var sysInfo = all[4] || {}; var zmUpdate = all[5] || {}; var healthData = all[6] || {}; var boardInfo = all[7] || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var overviewEl = E('div', {}); var cards = E('div', { 'class': 'zm-cards' }); function row(label, node) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); } function st(h, key, fallback) { return (h && typeof h[key] === 'number') ? h[key] : fallback; } function renderOverview(d, doh, hosts, sys, h) { var hostsEnabled = (hosts.items || []).filter(function(it) { return it.enabled; }).length; var hostsTotal = (hosts.items || []).length; var sysFlags = []; if (sys.quic_blocked) sysFlags.push('QUIC заблокирован'); if (sys.ipv6_enabled) sysFlags.push('IPv6 в Zapret включён'); if (sys.flow_offloading_fix) sysFlags.push('Flow Offloading fix'); var zrSt = st(h, 'zapret', d.zapret === 'installed' ? (d.zapret_running ? 1 : 2) : 0); var zr2St = st(h, 'zapret2', d.zapret2 === 'installed' ? (d.zapret2_running ? 1 : 2) : 0); var dohSt = st(h, 'doh', doh.installed ? 1 : 0); var dohNode = zm.stateBadge(dohSt); var items = []; items.push(row('Zapret', zm.stateBadge(zrSt))); if (zrSt !== 0 && d.strategy) items.push(row('Стратегия', E('span', {}, [ String(d.strategy) ]))); items.push(row('Zapret2', zm.stateBadge(zr2St))); var warnBadge = function(text) { return E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), text ]); }; var offBadge = function(text) { return E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), text ]); }; var stSt = st(h, 'steer', 0); var stVia = { warp: 'через WARP', own: 'через свой WARP', vpn: 'через VPN' }[h.steer_exit] || ''; items.push(row('Steer', stSt === 1 ? zm.badge(true, 'работает' + (stVia ? ' · ' + stVia : ''), '') : stSt === 2 ? zm.badge(false, '', 'не работает' + (stVia ? ' · ' + stVia : '')) : stSt === 5 ? offBadge(h.steer_off ? 'выключен' : h.steer_exit === 'none' ? 'подключите WARP или VPN' : 'сервисы не выбраны') : zm.badge(false, '', 'не установлен'))); var fkSt = st(h, 'forkop', 0); items.push(row('Forkozz', fkSt === 1 ? zm.badge(true, 'работает', '') : fkSt === 2 ? zm.badge(false, '', 'не работает') : fkSt === 5 ? offBadge('выключен') : zm.badge(false, '', 'не установлен'))); items.push(row('ByeTube', zm.stateBadge(st(h, 'bytetube', 0)))); items.push(row('TG WS Proxy', zm.stateBadge(st(h, 'tg', 0)))); items.push(row('Mixomo', zm.stateBadge(st(h, 'mixomo', 0)))); items.push(row('DNS over HTTPS', dohNode)); var awgSt = st(h, 'awg', 0); items.push(row('AmneziaWG', awgSt === 1 ? zm.badge(true, h.awg_up ? 'туннелей работает: ' + h.awg_up : 'установлен', '') : awgSt === 3 ? warnBadge('работает ' + (h.awg_up || 0) + ' из ' + (h.awg_total || 0)) : awgSt === 2 ? zm.badge(false, '', 'туннели не отвечают') : awgSt === 5 ? offBadge('установлен, туннелей нет') : zm.badge(false, '', 'не установлен'))); items.push(row('Домены в hosts', hosts.geohide ? zm.badge(true, 'GeoHide ' + hosts.geohide.toUpperCase(), '') : hostsTotal ? (hostsEnabled > 0 ? zm.badge(true, 'включено ' + hostsEnabled + ' из ' + hostsTotal, '') : E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'ничего не включено' ])) : E('span', {}, '—'))); if (sysFlags.length) items.push(row('Система', E('span', { 'style': 'display:flex;flex-wrap:wrap;gap:6px;flex:1 1 0;min-width:0' }, sysFlags.map(function(f) { return E('span', { 'class': 'zm-badge zm-off' }, f); })))); var half = Math.ceil(items.length / 2); return E('div', { 'class': 'zm-card', 'style': 'margin-bottom:4px' }, [ E('h3', {}, 'Компоненты'), E('div', { 'class': 'bt-cols' }, [ E('div', { 'class': 'bt-col' }, items.slice(0, half)), E('div', { 'class': 'bt-col' }, items.slice(half)) ]) ]); } function renderCards() { cards.innerHTML = ''; var mem = boardInfo.memory || {}; var ramAvail = mem.available != null ? mem.available : ((mem.free || 0) + (mem.buffered || 0) + (mem.cached || 0)); var ru = zm.parseSize(sysInfo.root_used), rf = zm.parseSize(sysInfo.root_free); var tu = zm.parseSize(sysInfo.tmp_used), tf = zm.parseSize(sysInfo.tmp_free); var rows = [ row('Модель', E('span', {}, [ String(sysInfo.model || '—') ])), row('Архитектура', E('span', {}, [ String(sysInfo.arch || '—') ])), row('Платформа', E('span', {}, [ String(sysInfo.target || '—') ])), row('OpenWrt', E('span', {}, [ String(sysInfo.openwrt || '—') ])) ]; function meter(label, val, pct, mid, hi) { pct = Math.max(0, Math.min(100, pct || 0)); return E('div', { 'class': 'zm-meter zmw-mem-row' + (pct >= hi ? ' zm-meter-hi zmw-mem-hi' : pct >= mid ? ' zm-meter-mid zmw-mem-mid' : '') }, [ E('div', { 'class': 'zm-meter-head zmw-mem-head' }, [ E('span', { 'class': 'zm-meter-label zmw-mem-label' }, label), E('span', { 'class': 'zm-meter-val zmw-mem-val' }, val) ]), E('div', { 'class': 'zm-meter-bar zmw-mem-bar' }, [ E('i', { 'style': 'width:' + pct.toFixed(0) + '%' }) ]) ]); } function usage(label, used, total) { return total > 0 ? meter(label, zm.fmtSize(used) + ' из ' + zm.fmtSize(total), used / total * 100, 65, 85) : null; } var memRows = []; var t = parseFloat(sysInfo.cpu_temp); if (!isNaN(t)) memRows.push(meter('Температура ЦП', sysInfo.cpu_temp + ' °C', t, 65, 80)); var cl = parseInt(sysInfo.cpu_load, 10); if (!isNaN(cl)) memRows.push(meter('Нагрузка ЦП', cl + '%', cl, 70, 90)); memRows.push(usage('ОЗУ', mem.total - ramAvail, mem.total)); memRows.push(usage('Флеш', ru, ru + rf)); memRows.push(usage('/tmp', tu, tu + tf)); memRows = memRows.filter(Boolean); var up = parseInt(boardInfo.uptime, 10), extras = []; if (up > 0) { var dd = Math.floor(up / 86400), hh = Math.floor(up % 86400 / 3600), mm = Math.floor(up % 3600 / 60); extras.push(row('Время работы', E('span', {}, (dd ? dd + ' дн ' : '') + (dd || hh ? hh + ' ч ' : '') + mm + ' мин'))); } var ims = parseInt(sysInfo.inet_ms, 10); extras.push(row('Интернет', sysInfo.inet === 'ok' ? E('span', { 'class': 'zm-badge ' + (ims >= 200 ? 'zm-warn' : 'zm-ok') }, [ E('span', { 'class': 'zm-dot' }), isNaN(ims) ? 'есть' : 'есть · ' + ims + ' мс' ]) : sysInfo.inet === 'fail' ? E('span', { 'class': 'zm-badge zm-bad' }, [ E('span', { 'class': 'zm-dot' }), 'нет' ]) : E('span', { 'class': 'zm-badge zm-off' }, [ E('span', { 'class': 'zm-dot' }), 'проверяем…' ]))); rows = rows.concat(extras); cards.appendChild(E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Система'), E('div', { 'class': 'bt-cols' }, [ E('div', { 'class': 'bt-col' }, rows), E('div', { 'class': 'bt-col zm-meters' }, memRows) ]) ])); } function refreshOverview() { Promise.all([ zm.status(), zm.dohStatus().catch(function() { return {}; }), zm.hostsStatus().catch(function() { return { items: [] }; }), zm.systemStatus().catch(function() { return {}; }), zm.health().catch(function() { return {}; }) ]).then(function(res) { overviewEl.innerHTML = ''; overviewEl.appendChild(renderOverview(res[0], res[1], res[2], res[3], res[4])); }); } var verEl = E('div', {}); var verTimer = null, verData = null; function renderVersions() { verEl.innerHTML = ''; var d = verData || { items: [], pending: true }; var items = d.items || []; var card = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Версии') ]); if (!items.length) { card.appendChild(E('p', { 'class': 'zm-hint' }, d.pending ? 'Проверяем версии установленных пакетов…' : 'Пакеты Zapret Manager пока не установлены.')); } else { var rows = items.map(function(it) { var fresh = !it.latest || it.latest === it.installed; return row(it.name, E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('span', {}, [ String(it.installed || '') ]), fresh ? E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), 'актуальна' ]) : E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), 'доступна ' + it.latest ]) ])); }); var half = Math.ceil(rows.length / 2); card.appendChild(E('div', { 'class': 'bt-cols' }, [ E('div', { 'class': 'bt-col' }, rows.slice(0, half)), E('div', { 'class': 'bt-col' }, rows.slice(half)) ])); } card.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': d.pending ? '' : null, 'click': function() { loadVersions('refresh'); } }, d.pending ? 'Проверяем…' : 'Проверить снова'), d.ts ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Проверено: ' + d.ts) : '' ])); verEl.appendChild(card); } function loadVersions(action) { if (verTimer) { clearTimeout(verTimer); verTimer = null; } if (action && verData) { verData.pending = true; renderVersions(); } (zm.versions ? zm.versions(action || '') : Promise.resolve({ items: [] })).then(function(res) { verData = res || { items: [] }; renderVersions(); if (verData.pending && document.body.contains(verEl)) verTimer = setTimeout(function() { loadVersions(''); }, 3000); }).catch(function() { verData = { items: (verData && verData.items) || [] }; renderVersions(); }); } overviewEl.appendChild(renderOverview(data, dohData, hostsData, sysData, healthData)); renderCards(); var updateEl = E('div', {}); wrap.appendChild(updateEl); if (!document.body.classList.contains('zmw-body')) wrap.appendChild(zm.alertBanners(healthData, function() { location.reload(); })); wrap.appendChild(E('div', { 'class': 'zm-header' }, [ 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'), E('a', { 'href': 'http://' + window.location.hostname + ':7788/', 'target': '_blank', 'rel': 'noreferrer', 'class': 'zm-webui-link' }, 'Web UI ↗') ]) ])); wrap.appendChild(overviewEl); wrap.appendChild(cards); wrap.appendChild(verEl); renderVersions(); loadVersions('auto'); this.refreshOverview = refreshOverview; var sysTick = 0, sysBusy = false; var sysTimer = setInterval(function() { if (!document.body.contains(cards)) { if (sysTick > 2) clearInterval(sysTimer); sysTick++; return; } if (document.hidden || sysBusy) return; sysBusy = true; sysTick++; Promise.all([ zm.systemInfo().catch(function() { return null; }), zm.boardInfo().catch(function() { return null; }) ]).then(function(r) { if (r[0] && !r[0].error) sysInfo = r[0]; if (r[1] && r[1].memory) boardInfo = r[1]; renderCards(); if (sysTick % 3 === 0) refreshOverview(); }).catch(function() {}).then(function() { sysBusy = false; }); }, 5000); var zmUpdateBusy = false; function waitForServerAndReload() { setTimeout(function() { location.href = L.url('admin/logout'); }, 4000); } function renderZmUpdate() { updateEl.innerHTML = ''; if (!zmUpdate.latest || zmUpdate.latest === zmUpdate.current || zmUpdate.newer === false) return; updateEl.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, [ E('span', {}, [ 'Доступна новая версия панели Zapret Manager: ' + zmUpdate.latest + ' (у вас установлена ' + zmUpdate.current + ').' ]), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { if (zmUpdateBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } zmUpdateBusy = true; zm.toast('Скачиваем и проверяем новую версию…', 'info', 4000); zm.zmUpdateAction().then(function(res) { zmUpdateBusy = false; if (res.error) { zm.toast('Обновление не началось: ' + res.error, 'error', 8000); return; } zm.toast('Устанавливаем версию ' + (res.version || zmUpdate.latest) + ' — через 4 секунды вы будете выведены из LuCI. Подождите полминуты и зайдите заново', 'warning', 8000); waitForServerAndReload(); }).catch(function() { zmUpdateBusy = false; }); } }, 'Обновить панель') ])); } renderZmUpdate(); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/dashboard.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/doh.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var PROVIDERS = [ { id: 'google', label: 'Google' }, { id: 'cloudflare', label: 'Cloudflare' }, { id: 'quad9', label: 'Quad9' }, { id: 'xbox', label: 'XBOX' }, { id: 'comss', label: 'Comss' }, { id: 'dnsai', label: 'DNS-AI' }, { id: 'yandex_family', label: 'Яндекс Семейный' }, { id: 'yandex_safe', label: 'Яндекс Безопасный' }, { id: 'geohide_ru', label: 'GeoHide RU' }, { id: 'geohide_eu', label: 'GeoHide EU' }, { id: 'geohide_us', label: 'GeoHide US' } ]; function label(id) { for (var i = 0; i < PROVIDERS.length; i++) if (PROVIDERS[i].id === id) return PROVIDERS[i].label; return ''; } function badge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } function row(l, node) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, l), node ]); } function host(url) { var m = /^https?:\/\/([^\/]+)/.exec(url || ''); return m ? m[1] : url; } return view.extend({ load: function() { zm.injectCss(); return zm.dohStatus(); }, render: function(data) { data = data || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var hostsWarn = zm.hostsWarn('DNS over HTTPS'); var card = E('div', { 'class': 'zm-card' }); var logEl = E('pre', { 'class': 'zm-log' }); var busy = false, pickProv = null, pickForce = null, bootOwn = !!data.bootstrap_custom; var bar = zm.saveBar({ onCancel: function() { pickProv = null; pickForce = null; bootReset(); render(); }, onSave: applyPick }); function curForce() { return data.force_mode === 'off' ? 'off' : 'auto'; } function pending() { var n = 0; if (pickProv && pickProv !== data.current) n++; if (pickForce && pickForce !== curForce()) n++; if (bootChanged()) n++; return n; } function applyPick() { if (busy) return; var steps = []; var bw = bootWant(); if (bootChanged()) steps.push(function() { logLines.push(' → ' + (bw ? 'Ставим Bootstrap DNS: ' + bw : 'Возвращаем Bootstrap DNS провайдеров')); return zm.dohBootstrapSet(bw).then(function(res) { if (res.error) throw new Error(res.error); logLines.push(' ✓ Bootstrap DNS ' + (res.bootstrap ? res.bootstrap : 'как у провайдеров')); }); }); if (pickProv && pickProv !== data.current) steps.push(function() { logLines.push(' → Переключаем DNS на ' + label(pickProv) + ' и перезапускаем https-dns-proxy'); return zm.dohSet(pickProv).then(function(res) { if (res.error) throw new Error(res.error); logLines.push(' ✓ ' + label(pickProv) + ' применён'); }); }); if (pickForce && pickForce !== curForce()) steps.push(function() { logLines.push(' → ' + (pickForce === 'off' ? 'Выключаем' : 'Включаем') + ' перехват DNS устройств'); return zm.dohForceSet(pickForce).then(function(res) { if (res.error) throw new Error(res.error); logLines.push(' ✓ Перехват DNS ' + (pickForce === 'off' ? 'выключен' : 'включён')); }); }); if (!steps.length) return; busy = true; bar.set(true, true); var logLines = [ '==> Применяем настройки DNS over HTTPS' ]; logEl.classList.add('zm-show'); var chain = Promise.resolve(); steps.forEach(function(f) { chain = chain.then(function() { zm.renderLog(logEl, logLines.join('\n')); return f(); }); }); chain.then(function() { logLines.push('==> Готово — настройки применены'); zm.toast('Настройки DNS применены', 'info'); }).catch(function(e) { logLines.push('==> ОШИБКА: ' + (e && e.message || 'роутер не ответил')); zm.toast(e && e.message || 'Роутер не ответил', 'error'); }).then(function() { zm.renderLog(logEl, logLines.join('\n')); busy = false; pickProv = null; pickForce = null; refresh().then(function() { bootReset(); render(); }); }); } function refresh() { return zm.dohStatus().then(function(res) { if (res && !res.error) data = res; render(); }, function() { render(); }); } function job(call, name, startText, okText, errText) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } zm.toast(startText, 'warning'); busy = true; call().then(function(res) { if (res.error) { busy = false; zm.toast(res.error, 'error'); return; } if (!res.started) { busy = false; return; } zm.pollJob(name, logEl, function(ok) { busy = false; zm.toast(ok ? okText : errText, ok ? 'info' : 'error'); refresh(); }); }).catch(function() { busy = false; }); } function render() { hostsWarn.set(!!(data.installed && data.hosts_extra)); card.innerHTML = ''; if (!data.installed && data.forkozz && !busy) { zm.blockCard(card, 'DNS over HTTPS', 'Шифрованный DNS для всей сети: запросы устройств уходят к выбранному провайдеру по HTTPS, и провайдер интернета их не видит и не подменяет.', 'Стоит Forkozz — он сам шифрует DNS. Чтобы поставить DoH, удалите Forkozz.'); if (logEl.classList.contains('zm-show')) card.appendChild(logEl); renderBoot(); renderForce(); bar.set(false, false); return; } card.appendChild(E('h3', {}, 'DNS over HTTPS')); card.appendChild(E('p', { 'class': 'zm-hint' }, 'Шифрованный DNS для всей сети: запросы устройств уходят к выбранному провайдеру по HTTPS, и провайдер интернета их не видит и не подменяет.')); card.appendChild(row('Пакет', data.installed ? badge('zm-ok', 'установлен') : badge('zm-off', 'не установлен'))); var list = data.resolvers || []; if (data.installed) { card.appendChild(row('Служба', data.running ? badge('zm-ok', 'работает') : badge('zm-bad', 'остановлена'))); if (!list.length) card.appendChild(row('Сейчас используется', E('span', {}, 'резолвер не выбран'))); else card.appendChild(E('div', { 'class': 'zm-row', 'style': 'align-items:flex-start; flex-wrap:nowrap' }, [ E('span', { 'class': 'zm-label', 'style': 'line-height:22px' }, list.length > 1 ? 'Сейчас используются' : 'Сейчас используется'), E('div', { 'style': 'display:flex; flex-direction:column; gap:6px; min-width:0; flex:1 1 auto' }, list.map(function(r) { return E('div', { 'style': 'line-height:22px; overflow-wrap:anywhere' }, [ E('b', {}, [ String(label(r.provider) || host(r.url)) ]), document.createTextNode(' · ' + r.url + (r.port ? ' · порт ' + r.port : '')) ]); })) ])); card.appendChild(row('Перехват DNS устройств', data.force_dns ? badge('zm-ok', 'включён') : badge('zm-off', 'выключен'))); } card.appendChild(E('div', { 'class': 'zm-actions' }, data.installed ? [ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить DNS over HTTPS полностью?\n\nБудут удалены пакеты, настройки и правила перехвата DNS — роутер вернётся к обычному DNS.')) return; job(zm.dohRemove, 'doh_remove', 'Удаляем DNS over HTTPS', 'DNS over HTTPS удалён', 'Ошибка удаления'); } }, 'Удалить') ] : [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { job(zm.dohInstall, 'doh_install', 'Устанавливаем DNS over HTTPS', 'DNS over HTTPS установлен — выберите провайдера', 'Ошибка установки'); } }, 'Установить DNS over HTTPS') ])); if (data.installed) { card.appendChild(E('h4', { 'style': 'margin:14px 0 8px' }, 'Провайдер')); var curProv = pickProv || data.current; card.appendChild(E('div', { 'class': 'zm-grid' }, PROVIDERS.map(function(p) { return E('div', { 'class': 'zm-tile' + (curProv === p.id ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } pickProv = p.id === data.current ? null : p.id; render(); } }, p.label); }))); if (list.length > 1) card.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас настроено несколько резолверов сразу, поэтому ни одна кнопка не подсвечена. Выбор провайдера заменит их одним.')); else if (list.length === 1 && !data.current) card.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас стоит резолвер не из списка — он настроен вручную. Выбор провайдера заменит его.')); } card.appendChild(logEl); renderForce(); renderBoot(); var n = pending(); bar.set(n > 0 || busy, busy, n ? 'Есть несохранённые изменения' : ''); } var bootCard = E('div', { 'class': 'zm-card', 'style': 'display:none' }); var bootInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'spellcheck': 'false', 'autocapitalize': 'off', 'placeholder': '77.88.8.8, 1.1.1.1', 'style': 'flex:1; min-width:220px; max-width:420px' }); bootInput.value = data.bootstrap_custom || ''; function bootWant() { return bootOwn ? bootInput.value.split(/[\s,;]+/).filter(Boolean).join(',') : ''; } function bootChanged() { var w = bootWant(); return !(bootOwn && !w) && w !== (data.bootstrap_custom || ''); } function bootReset() { bootOwn = !!data.bootstrap_custom; bootInput.value = data.bootstrap_custom || ''; } bootInput.addEventListener('input', function() { var n = pending(); bar.set(n > 0 || busy, busy, n ? 'Есть несохранённые изменения' : ''); }); function renderBoot() { bootCard.innerHTML = ''; bootCard.style.display = data.installed ? '' : 'none'; if (!data.installed) return; var m = bootOwn ? 'own' : 'auto'; bootCard.appendChild(E('h3', {}, 'Bootstrap DNS')); bootCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Обычный DNS, через который https-dns-proxy один раз узнаёт адрес самого DoH-сервера (например, dns.google). Нужен, если провайдер блокирует или подменяет эти адреса.')); bootCard.appendChild(E('div', { 'class': 'zm-grid' }, [ { id: 'auto', label: 'Как у провайдера' }, { id: 'own', label: 'Свои адреса' } ].map(function(o) { return E('div', { 'class': 'zm-tile' + (m === o.id ? ' zm-active' : ''), 'click': function() { if (busy || m === o.id) return; bootOwn = o.id === 'own'; render(); if (o.id === 'own') setTimeout(function() { bootInput.focus(); }, 0); } }, o.label); }))); if (m === 'own') { bootCard.appendChild(E('div', { 'class': 'zm-actions' }, [ bootInput ])); bootCard.appendChild(E('p', { 'class': 'zm-hint' }, 'IP-адреса через запятую, до 6 штук, можно IPv6. Применяются ко всем резолверам DoH.')); } else { var list = data.resolvers || []; var cur = list.map(function(r) { return (label(r.provider) || host(r.url)) + ': ' + (r.bootstrap || 'по умолчанию https-dns-proxy (1.1.1.1, 8.8.8.8)'); }); bootCard.appendChild(E('p', { 'class': 'zm-hint' }, [ cur.length ? 'Сейчас: ' + cur.join('; ') + '.' : 'Для Google, Cloudflare, Quad9 и Яндекса берутся их собственные адреса, для остальных — адреса по умолчанию https-dns-proxy.' ])); } } var FORCE = [ { id: 'auto', label: 'Авто (рекомендуется)' }, { id: 'off', label: 'Не перехватывать' } ]; var forceCard = E('div', { 'class': 'zm-card', 'style': 'display:none' }); function renderForce() { forceCard.innerHTML = ''; forceCard.style.display = data.installed ? '' : 'none'; if (!data.installed) return; var mode = pickForce || curForce(); forceCard.appendChild(E('h3', {}, 'Перехват DNS устройств')); forceCard.appendChild(E('div', { 'class': 'zm-grid' }, FORCE.map(function(f) { return E('div', { 'class': 'zm-tile' + (mode === f.id ? ' zm-active' : ''), 'click': function() { if (mode === f.id) return; if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } pickForce = f.id === curForce() ? null : f.id; render(); } }, f.label); }))); forceCard.appendChild(E('p', { 'class': 'zm-hint' }, mode === 'off' ? 'Перехвата нет. Через DoH идут запросы устройств, которые спрашивают DNS у роутера (так настроено почти везде). Устройства с вручную прописанным DNS (например, 8.8.8.8) пойдут мимо DoH.' : 'Все устройства принудительно получают DNS через DoH, даже если в них вручную прописан другой DNS. Работает и вместе со Steer.')); } render(); wrap.appendChild(hostsWarn); wrap.appendChild(card); wrap.appendChild(bootCard); wrap.appendChild(forceCard); wrap.appendChild(bar); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/doh.js' cat > '/www/luci-static/resources/view/zapret-manager/awg.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var PHASE_TEXT = { install: 'устанавливаем AmneziaWG', update: 'переустанавливаем AmneziaWG', remove: 'удаляем AmneziaWG', gen: 'генерируем WARP', create: 'создаём интерфейс', pick: 'подбираем точку входа', replace: 'применяем конфиг', regen: 'генерируем новый WARP' }; function badge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } function row(label, node) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); } function bytes(n) { n = +n || 0; if (n < 1024) return n + ' Б'; var u = [ 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = -1; do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); return (n >= 100 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i]; } function age(sec) { sec = parseInt(sec, 10); if (isNaN(sec)) return 'не было'; if (sec < 60) return sec + ' с назад'; if (sec < 3600) return Math.floor(sec / 60) + ' мин назад'; return Math.floor(sec / 3600) + ' ч назад'; } function live(f) { var a = parseInt(f.hs_age, 10); return f.up && !isNaN(a) && a < 180; } function country(cc) { cc = String(cc || '').toUpperCase(); if (!/^[A-Z]{2}$/.test(cc)) return ''; try { return new Intl.DisplayNames([ 'ru' ], { type: 'region' }).of(cc) || cc; } catch (e) { return cc; } } function seg(list, cur, onPick) { return E('div', { 'class': 'zm-seg' }, list.map(function(it) { return E('div', { 'class': 'zm-seg-item' + (cur === it.id ? ' zm-active' : ''), 'click': function() { if (cur !== it.id) onPick(it.id); } }, it.name); })); } return view.extend({ load: function() { zm.injectCss(); return zm.awgStatus().catch(function() { return {}; }); }, render: function(data) { data = data || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var busy = false; var mainCard = E('div', { 'class': 'zm-card zm-kv' }); var logEl = E('pre', { 'class': 'zm-log' }); var ifCard = E('div', { 'class': 'zm-card zm-kv' }); var genCard = E('div', { 'class': 'zm-card zm-kv' }); var newCard = E('div', { 'class': 'zm-card zm-kv' }); var gen = { custom: false, ep: '' }; var mk = { name: '', src: 'warp', text: '', fw: true, route: false, loaded: false }; var open = {}; var testRes = {}; var confEdit = null; function refresh() { return zm.awgStatus().then(function(res) { data = res || {}; renderAll(); }).catch(function() {}); } function follow(job, action) { busy = true; logEl.classList.add('zm-show'); renderAll(); zm.pollJob(job, logEl, function(ok) { busy = false; var msg = ok ? ({ install: 'AmneziaWG установлен', update: 'AmneziaWG переустановлен', remove: 'AmneziaWG удалён', gen: 'WARP сгенерирован', create: 'Интерфейс создан', pick: 'Точка входа подобрана', mihomo: 'WARP добавлен в Mihomo', replace: 'Конфиг применён', regen: 'Новый WARP применён', fix: 'Туннель Steer перезапущен на новой точке входа' }[action] || 'Готово') : 'Не получилось — подробности в журнале'; zm.toast(msg, ok ? 'info' : 'error'); if (ok && action === 'gen') mk.loaded = false; if (action === 'replace' || action === 'regen') Object.keys(open).forEach(function(k) { if (open[k] === 'conf') open[k] = null; }); refresh(); }); } function job(action, mode, toastText) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return Promise.resolve(false); } return zm.awgAction(action, mode || '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return false; } if (toastText) zm.toast(toastText, 'warning'); follow(res.job || 'awg', action); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } function steerFix(f) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var n = f.name === 'zmwarp' ? '1' : f.name.replace(/^zmwarp/, ''); zm.steerAction('warp_fix', n).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Подбираем точку входа для ' + f.name + ', это до пары минут', 'warning'); follow('steer', 'fix'); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function quick(action, mode, okText) { return zm.awgAction(action, mode || '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return res; } if (okText) zm.toast(okText, 'info'); refresh(); return res; }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function renderMain() { mainCard.innerHTML = ''; mainCard.appendChild(E('h3', {}, 'AmneziaWG')); mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'WireGuard с маскировкой: провайдеру сложнее заметить и заблокировать туннель.')); var inst = !!data.installed; mainCard.appendChild(row('Состояние', inst ? badge('zm-ok', 'установлен') : badge('zm-off', 'не установлен'))); if (data.kmod || data.tools) { mainCard.appendChild(row('Пакеты', E('span', { 'style': 'overflow-wrap:anywhere' }, [ 'kmod-amneziawg ' + (data.kmod || '—') + ' · amneziawg-tools ' + (data.tools || '—') + (data.luci ? ' · ' + (data.luci_pkg || 'luci-proto') + ' ' + data.luci : '') ]))); } if (inst) { mainCard.appendChild(row('Модуль ядра', data.module ? badge('zm-ok', 'загружен') : badge('zm-bad', 'не загружен'))); mainCard.appendChild(row('Поддержка в сети', data.proto ? badge('zm-ok', 'есть') : badge('zm-warn', 'нет — нажмите «Доустановить»'))); } if (busy) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас: ' + (PHASE_TEXT[data.phase] || 'идёт операция') + '… Можно закрыть страницу — роутер всё доделает сам.')); return; } var acts = []; if (!inst) acts.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { job('install', '', 'Устанавливаем AmneziaWG, сеть может ненадолго пропасть…'); } }, 'Установить AmneziaWG')); else { if (!data.module || !data.proto) acts.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { job('install', '', 'Доустанавливаем AmneziaWG'); } }, 'Доустановить')); acts.push(E('button', { 'class': 'cbi-button', 'click': function() { if (!confirm('Переустановить AmneziaWG?\n\nЕсли туннели работают, новый модуль заработает после перезагрузки роутера.')) return; job('update', '', 'Переустанавливаем AmneziaWG'); } }, 'Переустановить')); acts.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { var own = (data.ifaces || []).filter(function(f) { return !/^zmwarp\d*$/.test(f.name); }).map(function(f) { return f.name; }); if (!confirm('Удалить AmneziaWG с роутера полностью?\n\n' + (own.length ? 'Вместе с ним удалятся интерфейсы: ' + own.join(', ') + ' — с их пирами и зонами файрвола.\n' : '') + 'Будут удалены пакеты, модуль ядра и сохранённые конфиги.')) return; job('remove', '', 'Удаляем AmneziaWG'); } }, 'Удалить')); } mainCard.appendChild(E('div', { 'class': 'zm-actions' }, acts)); if (inst && data.steer) mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'AmneziaWG нужен Steer — удаляется только вместе со Steer.')); } function epEditor(f) { var box = E('div', { 'style': 'margin-top:10px' }); var eps = String(data.endpoints || '').split(' ').filter(function(x) { return x; }); var input = E('input', { 'class': 'cbi-input-text', 'type': 'text', 'placeholder': 'адрес:порт', 'value': f.endpoint || '', 'style': 'max-width:320px; width:100%' }); box.appendChild(E('div', { 'class': 'zm-grid' }, eps.map(function(ep) { return E('div', { 'class': 'zm-tile' + (f.endpoint === ep ? ' zm-active' : ''), 'click': function() { input.value = ep; } }, [ String(ep) ]); }))); box.appendChild(E('div', { 'class': 'zm-actions' }, [ input, E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { quick('endpoint', f.name + '|' + input.value.trim(), 'Точка входа сменена — туннель перезапущен').then(function(r) { if (r && r.ok) delete open[f.name]; }); } }, 'Применить'), f.warp ? E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { job('pick', f.name, 'Подбираем точку входа, это до пары минут'); } }, 'Подобрать') : '' ])); box.appendChild(E('p', { 'class': 'zm-hint' }, f.warp ? '«Подобрать» проверит точки WARP изнутри туннеля и возьмёт самую быструю рабочую.' : 'Адрес и порт вашего сервера.')); return box; } function ifBlock(f) { var steer = f.owner === 'steer'; var st = !f.enabled && !f.up ? badge('zm-off', 'выключен') : live(f) ? badge('zm-ok', 'работает') : f.up ? badge('zm-warn', 'нет рукопожатия') : badge('zm-bad', 'не поднят'); var head = E('div', { 'style': 'display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:6px' }, [ E('b', { 'style': 'font-size:15px' }, [ String(f.name) ]), st, f.warp ? badge('zm-off', 'WARP') : '', steer ? badge('zm-off', 'Steer') : '', steer && data.steer_active ? (data.steer_active === f.name ? badge('zm-ok', 'сейчас через него идёт трафик') : live(f) ? badge('zm-off', 'запасной') : '') : '' ]); var box = E('div', { 'class': 'zm-awg-if' }, [ head ]); var hide = !f.warp || f.name === 'zmwarp4'; if (f.address) box.appendChild(row('Адрес', E('span', {}, hide ? zm.secret(f.address.replace(/,/g, ', ')) : f.address.replace(/,/g, ', ')))); box.appendChild(row('Точка входа', E('span', { 'style': 'overflow-wrap:anywhere' }, [ f.endpoint ? (hide ? zm.secret(f.endpoint) : f.endpoint) : '—' ]))); box.appendChild(row('Рукопожатие', E('span', {}, age(f.hs_age)))); if (f.rx || f.tx) box.appendChild(row('Трафик', E('span', {}, '↓ ' + bytes(f.rx) + ' · ↑ ' + bytes(f.tx)))); box.appendChild(row('Зона firewall', E('span', {}, [ String(f.zone || 'нет — устройства сети в туннель не попадут') ]))); box.appendChild(row('Что идёт', E('span', {}, f.route_all ? 'весь трафик роутера' : 'то, что направят Forkozz или Steer'))); var t = testRes[f.name]; if (t) { var ti = []; if (t.colo) ti.push('сервер ' + t.colo + (t.city ? ' (' + t.city + ')' : '')); if (t.seen) ti.push('сайты видят: ' + country(t.seen)); if (t.loss !== undefined && t.loss !== '') ti.push('потери ' + t.loss + '%' + (t.rtt ? ', ' + t.rtt + ' мс' : '')); if (t.warp && t.warp !== 'off') ti.push('warp=' + t.warp); box.appendChild(row('Проверка', t.ok ? badge(t.torn ? 'zm-warn' : 'zm-ok', E('span', {}, [ 'выход ', hide ? zm.secret(t.ip || '?') : (t.ip || '?'), ti.length ? ' · ' + ti.join(' · ') : '' ])) : badge('zm-bad', 'через туннель ничего не открылось'))); if (t.ok && t.torn) box.appendChild(E('p', { 'class': 'zm-hint' }, 'Связь обрывается через пару секунд — так DPI рвёт туннель. ' + (f.warp ? 'Подберите точку входа заново.' : 'Попробуйте другую точку входа или маску I1.'))); } var acts = [ E('button', { 'class': 'cbi-button cbi-button-action', 'click': function(ev) { ev.target.disabled = true; zm.awgAction('test', f.name).then(function(r) { testRes[f.name] = r || {}; zm.toast(r && r.ok ? (r.torn ? f.name + ': связь есть, но обрывается' : f.name + ': туннель работает') : f.name + ': через туннель ничего не открылось', r && r.ok ? (r.torn ? 'warning' : 'info') : 'error'); refresh(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); refresh(); }); } }, 'Проверить') ]; if (!steer) { acts.push(E('button', { 'class': 'cbi-button', 'click': function() { quick('restart', f.name, f.name + ' перезапущен'); } }, 'Перезапустить')); acts.push(f.enabled ? E('button', { 'class': 'cbi-button', 'click': function() { quick('down', f.name, f.name + ' выключен'); } }, 'Выключить') : E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { quick('up', f.name, f.name + ' включён'); } }, 'Включить')); acts.push(E('button', { 'class': 'cbi-button', 'click': function() { open[f.name] = open[f.name] === 'ep' ? null : 'ep'; renderIfaces(); } }, 'Точка входа')); } if (!(steer && (data.steer_own || f.name === 'zmwarp4'))) acts.push(E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { if (!confirm('Новые ключи WARP для ' + f.name + '?\n\n' + (f.warp ? 'Точка входа и маскировка сохранятся.' : 'Туннель переключится с вашего сервера на WARP.'))) return; job('regen', f.name, 'Новые ключи WARP для ' + f.name); } }, 'Новый WARP')); acts.push(E('button', { 'class': 'cbi-button', 'click': function() { if (open[f.name] === 'conf') { open[f.name] = null; renderIfaces(); return; } zm.awgAction('export', f.name).then(function(r) { if (r.error) { zm.toast(r.error, 'error'); return; } open[f.name] = 'conf'; open[f.name + ':conf'] = r.content || ''; delete open[f.name + ':orig']; renderIfaces(); }); } }, open[f.name] === 'conf' ? 'Скрыть конфиг' : 'Изменить конфиг')); if (!steer) acts.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить интерфейс ' + f.name + '?\n\nЕго зона firewall, созданная здесь, тоже удалится.')) return; quick('delete', f.name, f.name + ' удалён'); } }, 'Удалить')); box.appendChild(E('div', { 'class': 'zm-actions' }, acts)); if (steer) box.appendChild(E('p', { 'class': 'zm-hint' }, f.name === 'zmwarp4' ? 'Свой WARP для Steer' + (data.steer_own ? '' : ', сейчас выключен') + '. Конфиг меняется здесь или в Steer.' : data.steer_own ? 'Туннель Steer. Выключен, пока работает свой WARP.' : 'Туннель Steer. Ключи и конфиг меняются здесь, остальное — в Steer.')); if (steer && f.warp && f.enabled && f.name !== 'zmwarp4' && !data.steer_own && !live(f)) { box.appendChild(E('p', { 'class': 'zm-hint' }, [ 'У каждого туннеля Steer свои ключи, и даже без трафика он раз в 25 секунд связывается с Cloudflare — у рабочего туннеля рукопожатие не старше 3 минут. Здесь его нет: точка входа перестала отвечать.' + (data.steer_active && data.steer_active !== f.name ? ' Steer сейчас идёт через ' + data.steer_active + '.' : '') ])); box.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { steerFix(f); } }, 'Подобрать точку входа') ])); } if (open[f.name] === 'ep') box.appendChild(epEditor(f)); if (open[f.name] === 'conf') { var ta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:220px' }); ta.value = open[f.name + ':orig'] != null ? open[f.name + ':orig'] : open[f.name + ':conf']; if (open[f.name + ':orig'] == null) open[f.name + ':orig'] = ta.value; box.appendChild(ta); var ibar = zm.editorBar(ta, { onCancel: function() { ta.value = open[f.name + ':orig']; open[f.name + ':conf'] = ta.value; ta.dispatchEvent(new Event('input')); }, onSave: function(t) { if (!/\[interface\]/i.test(t) || !/\[peer\]/i.test(t)) { zm.toast('Нужны секции [Interface] и [Peer]', 'warning'); return false; } if (!confirm('Заменить конфиг ' + f.name + '?\n\nЗона firewall и маршруты останутся как есть, туннель переподключится.')) return false; var k = f.name + ':orig', prev = open[k]; open[k] = t; open[f.name + ':conf'] = t; return job('replace', f.name + '|' + t, 'Применяем конфиг ' + f.name).then(function(ok) { if (ok) delete open[k]; else open[k] = prev; return ok; }); } }); if (open[f.name + ':conf'] !== ta.value) { ta.value = open[f.name + ':conf']; ta.dispatchEvent(new Event('input')); } ta.addEventListener('input', function() { open[f.name + ':conf'] = ta.value; }); var cacts = []; cacts.push(E('button', { 'class': 'cbi-button', 'click': function() { ta.select(); try { navigator.clipboard.writeText(ta.value).then(function() { zm.toast('Скопировано', 'info'); }); } catch (e) { document.execCommand('copy'); zm.toast('Скопировано', 'info'); } } }, 'Копировать')); box.appendChild(E('div', { 'class': 'zm-actions' }, cacts)); box.appendChild(E('p', { 'class': 'zm-hint' }, 'Вставьте или поправьте .conf — появится «Сохранить и применить». В нём закрытый ключ — никому не показывайте.')); box.appendChild(ibar); } return box; } function renderIfaces() { ifCard.innerHTML = ''; ifCard.appendChild(E('h3', {}, 'Интерфейсы')); var list = data.ifaces || []; if (!data.installed && !list.length) { ifCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сначала установите AmneziaWG.')); return; } if (!list.length) { ifCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Интерфейсов пока нет. Сгенерируйте WARP или вставьте свой .conf ниже.')); return; } list.forEach(function(f) { ifCard.appendChild(ifBlock(f)); }); } function renderGen() { genCard.innerHTML = ''; genCard.appendChild(E('h3', {}, 'WARP')); genCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Бесплатный туннель WARP с маскировкой. Файл WARP.conf общий с Mixomo.')); genCard.appendChild(row('WARP.conf', data.warp_conf ? badge('zm-ok', 'сгенерирован') : badge('zm-off', 'не сгенерирован'))); function run(mode, toastText) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } if (data.warp_conf && !confirm('WARP.conf уже есть — заменить его новым?')) return; job('gen', mode, toastText); } genCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { run('std|default', 'Генерируем WARP'); } }, 'Сгенерировать'), E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { run('std|auto', 'Генерируем WARP и подбираем точку входа'); } }, 'С подбором точки входа'), E('button', { 'class': 'cbi-button' + (gen.custom ? ' cbi-button-action' : ''), 'disabled': busy ? '' : null, 'click': function() { gen.custom = !gen.custom; renderGen(); } }, 'Своя точка входа') ])); if (gen.custom) { var ci = E('input', { 'class': 'cbi-input-text', 'type': 'text', 'placeholder': 'например 162.159.192.7:2408', 'value': gen.ep, 'style': 'max-width:320px; width:100%' }); ci.addEventListener('input', function() { gen.ep = ci.value.trim(); }); genCard.appendChild(E('div', { 'class': 'zm-actions' }, [ ci, E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { var ep = (gen.ep || '').trim(); if (!/^(\[[0-9A-Fa-f:]+\]|[A-Za-z0-9.-]+):[0-9]{1,5}$/.test(ep)) { zm.toast('Точка входа: адрес:порт, например 162.159.192.7:2408', 'warning'); return; } run('std|' + ep, 'Генерируем WARP с точкой ' + ep); } }, 'Сгенерировать') ])); } var checkBox = E('div', { 'class': 'zm-awg-if' }, [ E('div', { 'style': 'font-weight:600; margin-bottom:4px' }, 'С проверкой связи'), E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Роутер сам получит ключи, переберёт точки входа и маски и запишет ту связку, где трафик реально идёт. До нескольких минут.'), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': (busy || !data.installed) ? '' : null, 'click': function() { if (!data.installed) { zm.toast('Сначала установите AmneziaWG', 'warning'); return; } run('check', 'Генерируем WARP с проверкой, это несколько минут'); } }, 'Сгенерировать с проверкой'), !data.installed ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Нужен установленный AmneziaWG') : '' ]) ]); genCard.appendChild(checkBox); if (data.warp_conf) { genCard.appendChild(row('Файл', E('span', {}, data.warp_path || '/root/WARP.conf'))); if (data.warp_endpoint) genCard.appendChild(row('Точка входа', E('span', {}, data.warp_endpoint))); var acts = [ E('button', { 'class': 'cbi-button', 'click': function() { if (confEdit !== null) { confEdit = null; renderGen(); return; } zm.awgAction('conf_get', '').then(function(r) { confEdit = (r && r.content) || ''; renderGen(); }); } }, confEdit !== null ? 'Скрыть' : 'Показать и изменить'), E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { mk.src = 'warp'; mk.loaded = false; newCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); renderNew(); } }, 'Сделать интерфейс') ]; if (data.mihomo) acts.push(E('button', { 'class': 'cbi-button', 'click': function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.awgAction('mihomo', '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Добавляем WARP в Mihomo', 'warning'); follow(res.job || 'mixomo_warp_integrate', 'mihomo'); }); } }, 'Отправить в Mihomo')); genCard.appendChild(E('div', { 'class': 'zm-actions' }, acts)); if (confEdit !== null) { var ta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:260px' }); ta.value = confEdit; genCard.appendChild(ta); var gbar = zm.editorBar(ta, { saveLabel: 'Сохранить', onSave: function(v) { return zm.awgAction('conf_set', v).then(function(r) { if (r.error) { zm.toast(r.error, 'error'); return false; } zm.toast('WARP.conf сохранён', 'info'); confEdit = null; mk.loaded = false; refresh(); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } }); ta.addEventListener('input', function() { confEdit = ta.value; }); genCard.appendChild(gbar); } } } function suggestName() { var names = {}; (data.ifaces || []).forEach(function(f) { names[f.name] = true; }); var base = mk.src === 'warp' ? 'warp' : 'awg', n = 0, nm = base; while (names[nm]) { n++; nm = base + n; } return nm; } function renderNew() { newCard.innerHTML = ''; newCard.appendChild(E('h3', {}, 'Создать интерфейс')); if (!data.installed) { newCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сначала установите AmneziaWG.')); return; } newCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Из WARP.conf или своего .conf AmneziaWG / WireGuard. Появится в «Сеть → Интерфейсы».')); var srcs = [ { id: 'warp', name: 'WARP.conf' }, { id: 'own', name: 'Свой .conf' } ]; if (!data.warp_conf) srcs.shift(); if (!data.warp_conf && mk.src === 'warp') mk.src = 'own'; newCard.appendChild(row('Конфиг', seg(srcs, mk.src, function(id) { mk.src = id; mk.loaded = false; mk.name = ''; renderNew(); }))); var ta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:220px', 'placeholder': '[Interface]\nPrivateKey = …\nAddress = 10.0.0.2/32\nJc = 4\n…\n\n[Peer]\nPublicKey = …\nAllowedIPs = 0.0.0.0/0\nEndpoint = host:port' }); if (mk.src === 'warp' && !mk.loaded) { mk.loaded = true; zm.awgAction('conf_get', '').then(function(r) { mk.text = (r && r.content) || ''; ta.value = mk.text; }); } if (mk.src === 'own' && !mk.loaded) { mk.loaded = true; mk.text = ''; } ta.value = mk.text; ta.addEventListener('input', function() { mk.text = ta.value; }); newCard.appendChild(ta); if (!mk.name) mk.name = suggestName(); var ni = E('input', { 'class': 'cbi-input-text', 'type': 'text', 'value': mk.name, 'maxlength': '11', 'style': 'max-width:220px; width:100%' }); ni.addEventListener('input', function() { mk.name = ni.value.trim().toLowerCase(); }); newCard.appendChild(row('Имя интерфейса', ni)); newCard.appendChild(zm.swRow(mk.fw, 'Доступ из сети', 'Устройства смогут ходить в туннель', function() { mk.fw = !mk.fw; renderNew(); })); newCard.appendChild(zm.swRow(mk.route, 'Весь трафик', 'Весь интернет роутера — в туннель', function() { mk.route = !mk.route; renderNew(); })); newCard.appendChild(E('p', { 'class': 'zm-hint' }, mk.route ? 'Внимание: упадёт туннель — пропадёт интернет.' : 'Что пускать в туннель, выберите в Forkozz или Steer.' + (mk.fw ? '' : ' Без доступа из сети устройства туннелем не воспользуются.'))); newCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { var nm = (mk.name || '').trim(); if (!/^[a-z][a-z0-9_]{0,10}$/.test(nm)) { zm.toast('Имя: латиница в нижнем регистре, цифры и _, до 11 символов', 'warning'); return; } if (!/\[interface\]/i.test(mk.text) || !/\[peer\]/i.test(mk.text)) { zm.toast('Вставьте конфигурацию с секциями [Interface] и [Peer]', 'warning'); return; } var exists = (data.ifaces || []).some(function(f) { return f.name === nm; }); if (exists && !confirm('Интерфейс ' + nm + ' уже есть — заменить его?')) return; job('create', nm + '|' + (mk.route ? 1 : 0) + '|' + (mk.fw ? 1 : 0) + '|' + mk.text, 'Создаём интерфейс ' + nm); mk.name = ''; } }, 'Создать интерфейс') ])); } function renderAll() { renderMain(); renderIfaces(); renderGen(); renderNew(); } renderAll(); wrap.appendChild(zm.rebootSlot()); wrap.appendChild(mainCard); wrap.appendChild(logEl); wrap.appendChild(ifCard); wrap.appendChild(genCard); wrap.appendChild(newCard); if (data.running) follow('awg', data.phase || ''); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/awg.js' cat > '/www/luci-static/resources/view/zapret-manager/steer.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var PHASE_TEXT = { install: 'устанавливаем', pkgs: 'ставим движок Steer', awg: 'ставим AmneziaWG', keys: 'получаем ключи WARP', tunnel: 'поднимаем туннели', warp: 'настраиваем туннели', rules: 'применяем правила', check: 'проверяем', remove: 'удаляем', sub: 'настраиваем подписку' }; var STEER_TABS = [ { id: 'svc', label: 'Сервисы' }, { id: 'warp', label: 'WARP' }, { id: 'sub', label: 'VPN' } ]; function fmtBytes(n) { n = +n || 0; if (n < 1024) return n + ' Б'; var u = [ 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = -1; do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1); return (n >= 100 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i]; } function fmtDate(sec) { sec = parseInt(sec, 10); if (!sec || sec <= 0) return ''; var d = new Date(sec * 1000); if (isNaN(d.getTime())) return ''; return ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2) + '.' + d.getFullYear(); } function latClass(ms) { if (!(ms > 0)) return 'zm-lat-none'; if (ms < 400) return 'zm-lat-good'; if (ms < 900) return 'zm-lat-mid'; return 'zm-lat-bad'; } var BLOCKERS = { splify2: 'Установлен splify2 — туннели и списки настраиваются в нём.', steer: 'Движок Steer уже настроен вручную — Zapret Manager его не перезаписывает.', forkozz: 'Стоит Forkozz — он сам направляет сервисы в туннель. Чтобы поставить Steer, удалите Forkozz.' }; function badge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } function row(label, node) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); } function fmtAge(sec) { sec = parseInt(sec, 10); if (isNaN(sec)) return 'не было'; if (sec < 60) return 'только что'; if (sec < 3600) return Math.floor(sec / 60) + ' мин назад'; return Math.floor(sec / 3600) + ' ч назад'; } function tunnelLive(t) { var a = parseInt(t.hs_age, 10); return t.up && !isNaN(a) && a < 300; } function country(cc) { cc = String(cc || '').toUpperCase(); if (!/^[A-Z]{2}$/.test(cc)) return ''; try { return new Intl.DisplayNames([ 'ru' ], { type: 'region' }).of(cc) || cc; } catch (e) { return cc; } } function plural(n, one, few, many) { var m10 = n % 10, m100 = n % 100; if (m10 === 1 && m100 !== 11) return one; if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few; return many; } function verLt(a, b) { var x = String(a).split('.'), y = String(b).split('.'); for (var i = 0; i < 3; i++) { var p = parseInt(x[i], 10) || 0, q = parseInt(y[i], 10) || 0; if (p !== q) return p < q; } return false; } function hh(h) { return (h < 10 ? '0' : '') + h + ':00'; } return view.extend({ load: function() { zm.injectCss(); return zm.steerStatus().catch(function() { return {}; }); }, render: function(data) { data = data || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var hostsWarn = zm.hostsWarn('Steer'); var busy = false, lastAction = '', pick = null, autoBusy = false, diagRes = null, diagBusy = false; var warpPick = null, ownTa = null, ownOpen = false, ownLoaded = false; var mainCard = E('div', { 'class': 'zm-card' }); var logEl = E('pre', { 'class': 'zm-log' }); var dnsCard = E('div', {}); var listCard = E('div', { 'class': 'zm-card' }); var checkCard = E('div', { 'class': 'zm-card' }); var routeCard = E('div', { 'class': 'zm-card' }), routeBox = null; var customCard = E('div', { 'class': 'zm-card' }); var customData = null, customLoading = false, customEditor = null, customDraft = null, customDirty = false; var warpCard = E('div', { 'class': 'zm-card' }); var autoCard = E('div', { 'class': 'zm-card' }); var wfixCard = E('div', { 'class': 'zm-card' }), wfixBusy = false; var subCard = E('div', { 'class': 'zm-card' }); var subData = null, subLoading = false, subInput = '', lat = {}, probing = false, probeDone = 0, probeTotal = 0; var tab = 'svc'; try { tab = localStorage.getItem('zm.steer.tab') || 'svc'; } catch (e) {} if (!STEER_TABS.some(function(t) { return t.id === tab; })) tab = 'svc'; var tabBar = E('div', { 'class': 'zm-actions', 'style': 'margin:10px 0' }); var panes = { svc: E('div', {}), warp: E('div', {}), sub: E('div', {}) }; function refresh() { return zm.steerStatus().then(function(res) { data = res || {}; renderAll(); }); } function finish(ok, res) { busy = false; if (res) data = res; var msg = 'Готово'; if (!ok) msg = 'Не получилось — подробности в журнале'; else if (lastAction === 'install') msg = 'Steer установлен — подключите WARP или VPN'; else if (lastAction === 'warp_setup') msg = 'WARP подключён'; else if (lastAction === 'warp_own') msg = 'Свой WARP подключён'; else if (lastAction === 'warp_mode') msg = 'Автоматический WARP подключён'; else if (lastAction === 'warp_fix' || lastAction === 'warp_fixkeys') msg = 'Туннель переделан'; else if (lastAction === 'remove') msg = 'Steer удалён'; else if (lastAction === 'stop') msg = 'Steer выключен — всё идёт напрямую'; else if (lastAction === 'domlist') msg = 'Готово, новый список доменов работает'; else if (lastAction === 'lists' || lastAction === 'start') msg = 'Готово, выбор применён'; else if (/^sub_/.test(lastAction)) msg = 'Готово'; else if (lastAction === 'engine') msg = 'Движок Steer обновлён'; zm.toast(msg, ok ? 'info' : 'error'); var done = lastAction; lastAction = ''; if (ok && (done === 'warp_own' || done === 'warp_mode')) { warpPick = null; ownOpen = false; } if (ok && (done === 'sub_set' || done === 'sub_links' || done === 'sub_update')) { probeAfterLoad = true; zm.toast('Серверы обновлены — проверяем задержку до каждого', 'info'); } diagRes = null; renderAll(); loadSub(); if (ok && data.installed && !data.stopped && done !== 'stop' && done !== 'remove') runDiag(true); } var waitN = 0; function waitRouter() { if (!document.body.contains(logEl) || ++waitN > 60) return; zm.steerStatus().then(function(res) { waitN = 0; data = res || {}; if (data.running) { follow(); return; } finish(true, res); }).catch(function() { setTimeout(waitRouter, 5000); }); } function follow() { var ticks = 0; busy = true; renderAll(); zm.pollJob('steer', logEl, function(ok) { zm.steerStatus().then(function(res) { if (res && res.running) { data = res; follow(); return; } finish(ok, res); }).catch(function() { zm.toast('Роутер не отвечает — ждём', 'warning'); setTimeout(waitRouter, 5000); }); }, function() { if (++ticks % 3) return; zm.steerStatus().then(function(res) { if (res) { data = res; renderMain(); } }).catch(function() {}); }); } function act(action, arg, toastText, done) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); if (done) done(false); return; } zm.steerAction(action, arg || '').then(function(res) { if (done) done(!res.error); if (res.error) { zm.toast(res.error, 'error'); return; } if (res.saved) { zm.toast(data.installed ? 'Выбор сохранён — применится при включении' : 'Выбор сохранён — применится при установке', 'info'); pick = null; refresh(); loadSub(); return; } if (action === 'lists') pick = null; if (toastText) zm.toast(toastText, 'warning'); lastAction = action; data.running = true; setTimeout(function() { try { logEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} }, 50); data.phase = action === 'install' ? 'pkgs' : action === 'remove' ? 'remove' : /^warp_/.test(action) ? 'warp' : /^sub_/.test(action) ? 'sub' : action === 'engine' ? 'pkgs' : 'rules'; follow(); }).catch(function() { if (done) done(false); zm.toast('Роутер не ответил', 'error'); }); } function warpName() { return data.warp_mode === 'own' ? 'Свой WARP' : 'WARP'; } function selectedCount() { var n = (data.services || []).filter(function(s) { return s.on; }).length; if (!n && (parseInt(data.channels, 10) || 0) > 0) n = 1; return n; } function tunnelState() { var age = parseInt(data.warp_hs_age, 10), n = parseInt(data.channels, 10) || 0; if (!data.warp_up) return (data.stopped || !n) ? [ 'выключены', 'zm-off' ] : [ 'не подняты', 'zm-bad' ]; var t = data.tunnels || [], live = t.filter(tunnelLive).length; if (t.length > 1 && live) return [ 'работают ' + live + ' из ' + t.length + (data.warp_colo ? ' · ' + data.warp_colo : ''), live < t.length ? 'zm-warn' : 'zm-ok' ]; if (!isNaN(age) && age < 300) return [ 'работает' + (data.warp_colo ? ' · ' + data.warp_colo : ''), 'zm-ok' ]; return [ 'нет связи', 'zm-warn' ]; } function vpnBadge() { if (!data.vpn_up) return badge('zm-warn', 'подключаемся'); if (data.vpn_live === false) return badge('zm-bad', 'нет связи'); return badge('zm-ok', 'подключено'); } function statusBadge() { var n = selectedCount(); if (busy || data.running) return badge('zm-warn', PHASE_TEXT[data.phase] || 'работаем'); if (!data.installed) return badge('zm-off', 'не установлен'); if (data.stopped) return badge('zm-off', 'выключен'); if (!n) return badge('zm-off', 'сервисы не выбраны'); if (data.exit === 'none') return badge('zm-warn', 'подключите WARP или VPN'); if (data.exit === 'vpn') { if (data.steer_running && data.vpn_up && data.vpn_live === false) return badge('zm-bad', 'трафик не идёт'); if (data.steer_running && data.failopen) return badge('zm-bad', 'VPN не принят — сервисы идут напрямую'); if (data.steer_running && data.vpn_up) return badge('zm-ok', 'работает'); if (data.steer_running) return badge('zm-warn', 'подключаемся к узлу'); return badge('zm-bad', 'не работает'); } if (data.steer_running && data.failopen) return badge('zm-bad', 'туннель не принят — сервисы идут напрямую'); var t = data.tunnels || [], age = parseInt(data.warp_hs_age, 10); var live = t.length ? t.filter(tunnelLive).length > 0 : (!isNaN(age) && age < 300); if (data.steer_running && data.warp_up && live) return badge('zm-ok', 'работает'); if (data.steer_running && data.warp_up) return badge('zm-warn', 'нет связи'); return badge('zm-bad', 'не работает'); } function renderMain() { hostsWarn.set(!!(data && data.installed && data.hosts_extra)); mainCard.innerHTML = ''; mainCard.appendChild(E('h3', {}, 'Steer')); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Выбранные сервисы идут через туннель — бесплатный Cloudflare WARP или ваш VPN (подписка или VLESS). Остальной интернет — как обычно.')); if (data.blocker) { mainCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show' }, BLOCKERS[data.blocker] || data.blocker)); return; } mainCard.appendChild(row('Состояние', statusBadge())); if (data.installed) { var n = selectedCount(), ts = tunnelState(); if (data.warp_on && data.has_sub && data.ext) { var isVpn = data.exit === 'vpn'; mainCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Сервисы идут через'), E('div', { 'class': 'zm-seg' }, [ E('div', { 'class': 'zm-seg-item' + (!isVpn ? ' zm-active' : ''), 'click': function() { if (isVpn && !busy) act('sub_exit', 'warp', 'Переключаем на ' + warpName()); } }, warpName()), E('div', { 'class': 'zm-seg-item' + (isVpn ? ' zm-active' : ''), 'click': function() { if (!isVpn && !busy) act('sub_exit', 'vpn', 'Переключаем на VPN'); } }, [ String(data.sub_label || 'VPN') ]) ]), isVpn ? vpnBadge() : badge(ts[1], ts[0]) ])); } else if (data.exit === 'vpn') mainCard.appendChild(row('Сервисы идут через', E('span', {}, [ 'VPN' + (data.sub_label ? ' · ' + data.sub_label : '') + ' ', vpnBadge() ]))); else if (data.exit === 'warp') mainCard.appendChild(row('Сервисы идут через', badge(ts[1], warpName() + ' · ' + ts[0]))); else mainCard.appendChild(row('Сервисы идут через', badge('zm-warn', 'туннель не подключён — выберите вкладку WARP или VPN'))); mainCard.appendChild(row('Через туннель', E('span', {}, n ? n + ' ' + plural(n, 'сервис', 'сервиса', 'сервисов') : 'ничего не выбрано'))); if (data.doh) { var DOHN = { cloudflare: 'Cloudflare', google: 'Google', quad9: 'Quad9', xbox: 'XBOX', comss: 'Comss', dnsai: 'DNS-AI', yandex_family: 'Яндекс Семейный', yandex_safe: 'Яндекс Безопасный', geohide_ru: 'GeoHide RU', geohide_eu: 'GeoHide EU', geohide_us: 'GeoHide US', multi: 'несколько серверов', other: 'свой сервер' }; mainCard.appendChild(row('Шифрованный DNS', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('span', {}, 'DNS over HTTPS · ' + (DOHN[data.doh] || data.doh)), badge('zm-ok', 'работает') ]))); } var newer = data.latest && data.version && verLt(data.version, data.latest); mainCard.appendChild(row('Версия Steer', E('span', {}, (data.version || '—') + (newer ? ' · доступна ' + data.latest : (!data.ext && data.version ? ' · ядро нужно обновить' : ''))))); } if (busy) { mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { zm.steerAction('halt', '').then(function(res) { if (res.error) zm.toast(res.error, 'error'); else zm.toast('Останавливаем — роутер закончит текущий шаг', 'warning'); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, 'Остановить') ])); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно закрыть страницу — всё доделается на роутере.')); return; } if (!data.installed) { mainCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('install', '', 'Устанавливаем Steer — это займёт пару минут'); } }, 'Установить') ])); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ставится ядро steer — около минуты. Потом на вкладке WARP или VPN выберите, через что пускать сервисы.')); return; } var actions = []; if (data.stopped) actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('start', '', 'Включаем Steer'); } }, 'Включить')); else { actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('apply', '', 'Перезапускаем туннели и правила'); } }, 'Перезапустить')); actions.push(E('button', { 'class': 'cbi-button', 'click': function() { act('stop', '', 'Выключаем Steer'); } }, 'Выключить')); } if ((data.latest && data.version && verLt(data.version, data.latest)) || (data.version && !data.ext)) actions.push(E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { act('engine', '', 'Обновляем движок Steer'); } }, 'Обновить движок')); actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить Steer?\n\nБудут удалены Steer, туннели WARP и всё, что для них ставилось. Сервисы, которые шли через WARP, пойдут напрямую.')) return; act('remove', '', 'Удаляем Steer'); } }, 'Удалить')); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, actions)); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, '«Выключить» — всё пойдёт напрямую, настройки и выбор сервисов сохранятся.')); } function currentPick() { var m = {}; (data.services || []).forEach(function(s) { if (s.on) m[s.id] = true; }); return m; } var CATEGORY_IDS = [ 'geoblock', 'block', 'news', 'anime', 'porn', 'hodca', 'russia_inside', 'russia_outside', 'ukraine_inside' ]; var openGroups = {}; function catalogEdit() { var c = data.catalog || {}; var u = prompt('Ссылка на свой каталог списков (lists.json в формате splify2-lists, например из форка).\n\nПусто — каталог по умолчанию.', c.custom ? c.url : ''); if (u === null) return; u = u.trim(); zm.toast('Скачиваем каталог…', 'info'); zm.steerAction('catalog_src', u).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Каталог подключён', 'info'); refresh(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function catalogBlock() { var c = data.catalog || {}, box = E('div', { 'class': 'zm-cat-src' }); var state = c.off ? 'выключен — показаны только встроенные сервисы' : c.busy ? 'обновляется…' : c.version ? 'версия ' + c.version + ' · пунктов: ' + c.services + (c.error ? ' · последнее обновление не удалось' : '') : c.error ? 'не скачался — проверьте доступ к GitHub' : 'ещё не скачан'; box.appendChild(E('div', { 'class': 'zm-cat-src-info' }, [ E('div', { 'class': 'zm-cat-src-title' }, [ 'Каталог списков ', E('span', { 'class': 'zm-badge ' + (c.off ? 'zm-off' : c.error ? 'zm-warn' : c.version ? 'zm-ok' : 'zm-off') }, c.off ? 'выключен' : c.busy ? 'обновляется' : c.error ? (c.version ? 'не обновился' : 'не скачан') : c.version ? 'подключён' : 'нет') ]), E('div', { 'class': 'zm-cat-src-state' }, state), E('div', { 'class': 'zm-cat-src-url' }, [ String(c.url || '') ]) ])); var b = []; if (!c.off) b.push(E('button', { 'class': 'cbi-button', 'click': function() { zm.steerAction('catalog_refresh', '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Обновляем каталог', 'info'); setTimeout(refresh, 5000); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, 'Обновить')); if (!c.off) b.push(E('button', { 'class': 'cbi-button', 'click': catalogEdit }, 'Сменить источник')); b.push(E('button', { 'class': 'cbi-button', 'click': function() { zm.steerAction('catalog_src', c.off ? 'on' : 'off').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } refresh(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, c.off ? 'Включить каталог' : 'Выключить каталог')); box.appendChild(E('div', { 'class': 'zm-actions zm-cat-src-actions' }, b)); return box; } function renderLists() { listCard.innerHTML = ''; listCard.style.display = data.blocker ? 'none' : ''; if (data.blocker) return; listCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Нажмите на пункт, чтобы включить или выключить его, и затем «Сохранить и применить». Списки берутся из каталога списков (itdoginfo/allow-domains, b4geoip и другие) и обновляются при каждом применении.')); var cur = currentPick(), sel = pick || cur, changed = false; var list = data.services || []; list.forEach(function(s) { if (!!sel[s.id] !== !!cur[s.id]) changed = true; }); function tile(s) { return zm.svcCard({ name: s.name, key: s.id, on: !!sel[s.id], inc: !!sel.russia_inside && s.id !== 'russia_inside' && !s.group && zm.riCovers(s.id), click: function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } pick = {}; for (var k in sel) if (sel[k]) pick[k] = true; if (pick[s.id]) delete pick[s.id]; else pick[s.id] = true; renderLists(); } }); } var remote = list.filter(function(s) { return !!s.group; }); var svcs = list.filter(function(s) { return !s.group && CATEGORY_IDS.indexOf(s.id) < 0; }); var cats = list.filter(function(s) { return !s.group && CATEGORY_IDS.indexOf(s.id) >= 0; }); listCard.appendChild(E('h4', { 'style': 'margin:0' }, 'Сервисы')); listCard.appendChild(zm.svcGrid(svcs.map(tile))); if (cats.length) { listCard.appendChild(E('h4', { 'style': 'margin:18px 0 0' }, 'Категории')); listCard.appendChild(zm.svcGrid(cats.map(tile))); if (sel.russia_inside) listCard.appendChild(zm.riNote(list, sel)); else listCard.appendChild(E('p', { 'class': 'zm-hint' }, '«Всё сразу» — полный список Russia inside: все категории и сервисы одним набором. Он большой, на слабых роутерах лучше включать отдельные пункты.')); } var groups = []; remote.forEach(function(s) { if (groups.indexOf(s.group) < 0) groups.push(s.group); }); if (groups.length) { listCard.appendChild(E('h4', { 'style': 'margin:20px 0 4px' }, 'Дополнительные списки')); listCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 10px' }, 'Готовые наборы доменов и подсетей из каталога. Разверните источник и отметьте нужное.')); } groups.forEach(function(g) { var items = remote.filter(function(s) { return s.group === g; }); var on = items.filter(function(s) { return sel[s.id]; }).length; var m = /^(.*?)\s*\((.*)\)\s*$/.exec(g); var gTitle = m ? m[1] : g, gSub = m ? m[2] : ''; listCard.appendChild(zm.svcGroup({ name: gTitle, sub: gSub, key: g, open: g in openGroups ? openGroups[g] : on > 0, badge: E('span', { 'class': 'zm-badge ' + (on ? 'zm-ok' : 'zm-off') }, on ? 'выбрано ' + on + ' из ' + items.length : String(items.length)), body: zm.svcGrid(items.map(tile)), toggle: function(o) { openGroups[g] = o; } })); }); listCard.appendChild(catalogBlock()); var canRun = data.installed && !data.stopped && selectedCount() > 0 && data.exit !== 'none'; listCard.appendChild(E('div', { 'class': 'zm-lists-now' }, [ E('div', { 'class': 'zm-lists-now-text' }, [ E('b', {}, 'Списки сервисов'), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, canRun ? 'Скачиваются заново при каждом применении. Можно обновить сейчас — в окне вывода будет видно, какой список скачался, что применилось и что всё работает.' : 'Скачаются, когда Steer включён, выбраны сервисы и подключён WARP или VPN.') ]), E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': !canRun || busy || changed ? '' : null, 'title': changed ? 'Сначала сохраните изменения' : '', 'click': function() { act('apply', '', 'Скачиваем и применяем списки'); } }, 'Обновить списки сейчас') ])); listCard.appendChild(zm.swRow(!!data.lists_via, 'Скачивать списки через VPN', 'Списки и каталог будут качаться через туннель WARP или свой конфиг, а если он не ответит — напрямую. Помогает, если GitHub у провайдера не открывается. В режиме VPN-подписки списки качаются напрямую.', function() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var on = !data.lists_via; zm.steerAction('lists_via', on ? 'on' : 'off').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } data.lists_via = on; zm.toast(on ? 'Списки будут качаться через туннель' : 'Списки будут качаться напрямую', 'info'); renderLists(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); })); var bar = zm.saveBar({ saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить', onCancel: function() { pick = null; renderLists(); }, onSave: function() { var ids = list.filter(function(s) { return sel[s.id]; }).map(function(s) { return s.id; }); act('lists', ids.join(','), 'Применяем выбор — скачиваем списки'); } }); bar.set(changed, false); listCard.appendChild(bar); } function loadCustom() { customLoading = true; renderCustom(); zm.steerAction('list_get', 'custom').then(function(res) { customLoading = false; if (res.error) { customData = null; renderCustom(); zm.toast(res.error, 'error'); return; } customData = res; customDraft = null; customDirty = false; renderCustom(); }).catch(function() { customLoading = false; renderCustom(); }); } function saveCustom(action, arg, okText) { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.steerAction(action, arg).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } customDraft = null; customDirty = false; if (res.saved) { zm.toast(okText + (res.count ? ' (' + res.count + ')' : ''), 'info'); loadCustom(); refresh(); return; } zm.toast(okText + ' — применяем правила', 'info'); lastAction = 'domlist'; data.running = true; data.phase = 'rules'; follow(); loadCustom(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function renderCustom() { if (customEditor && customDirty) customDraft = customEditor.value; if (customEditor && !customDirty) customDraft = null; customCard.innerHTML = ''; var svc = (data.services || []).filter(function(s) { return s.id === 'custom'; })[0]; customCard.style.display = data.blocker || !svc ? 'none' : ''; if (data.blocker || !svc) return; customCard.appendChild(E('h3', {}, 'Свой список')); customCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Домены и IP-адреса из этого списка пойдут через Steer. После сохранения список сразу включается в выбор сервисов, «Очистить» — убирает его.')); if (customLoading && !customData) { customCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); return; } var n = customData ? (parseInt(customData.count, 10) || 0) : 0; customCard.appendChild(row('Состояние', !n ? badge('zm-off', 'список пуст') : !svc.on ? badge('zm-warn', 'не выбран') : !data.installed ? badge('zm-warn', 'пойдёт через Steer после установки') : data.stopped ? badge('zm-off', 'Steer выключен') : data.exit === 'vpn' ? badge('zm-ok', 'идёт через VPN') : data.exit === 'warp' ? badge('zm-ok', 'идёт через ' + warpName()) : badge('zm-warn', 'подключите WARP или VPN'))); customCard.appendChild(row('В списке', E('span', {}, customData && customData.domains != null ? 'доменов: ' + customData.domains + ' · IP и подсетей: ' + customData.subnets : String(n)))); customEditor = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:200px', 'placeholder': 'example.com\nsite.org\n91.108.4.0/22\n149.154.167.99' }); customEditor.value = (customData && customData.content) || ''; customCard.appendChild(customEditor); var cbar = zm.editorBar(customEditor, { saveLabel: data.installed && !data.stopped ? 'Сохранить и применить' : 'Сохранить', onSave: function(v) { if (!v.trim()) { zm.toast('Добавьте хотя бы один домен или IP, либо нажмите «Очистить»', 'warning'); return false; } saveCustom('list_set', 'custom|' + v, 'Свой список сохранён'); return false; } }); customEditor.addEventListener('input', function() { customDirty = cbar.dirty(); customDraft = customEditor.value; }); if (customDirty && customDraft !== null) { customEditor.value = customDraft; customEditor.dispatchEvent(new Event('input')); } if (n) customCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Очистить свой список?\n\nДомены из него пойдут напрямую.')) return; saveCustom('list_reset', 'custom', 'Свой список очищен'); } }, 'Очистить') ])); customCard.appendChild(E('p', { 'class': 'zm-hint' }, 'По одному на строку: домен (поддомены включаются сами — example.com это и www.example.com), IP-адрес (1.2.3.4) или подсеть (91.108.4.0/22). Остальное при сохранении отбрасывается.')); customCard.appendChild(cbar); } function runDiag(quiet) { if (diagBusy) return; diagBusy = true; renderCheck(); zm.steerAction('diag', '').then(function(res) { diagBusy = false; diagRes = res || {}; renderCheck(); zm.steerStatus().then(function(r) { if (r) { data = r; renderMain(); } }).catch(function() {}); if (!quiet) zm.toast('Проверка закончена', 'info'); }).catch(function() { diagBusy = false; renderCheck(); }); } function renderCheck() { checkCard.innerHTML = ''; checkCard.style.display = data.installed && !data.blocker ? '' : 'none'; if (!data.installed || data.blocker) return; checkCard.appendChild(E('h3', {}, 'Проверка')); checkCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Роутер проверит каждый туннель и спросит сам Steer, всё ли на месте.')); if (diagRes) { var items = []; var tn = diagRes.tunnels || []; if (diagRes.vpn === 'on') items.push([ 'ok', diagRes.vpn_ip ? [ 'Трафик идёт через подписку (выход ', zm.secret(diagRes.vpn_ip), (diagRes.vpn_loc ? ', ' + diagRes.vpn_loc : '') + ')' ] : 'Трафик идёт через подписку', '' ]); else if (diagRes.vpn === 'off') items.push([ 'fail', 'Трафик через подписку не идёт', 'проверьте задержку узлов на вкладке «Подписка» или выберите другой узел' ]); else if (tn.length) tn.forEach(function(t) { var who = tn.length > 1 ? 'Туннель ' + t.n + ': ' : ''; var ownW = data.warp_mode === 'own'; if (t.warp === 'on') items.push([ 'ok', who + 'трафик идёт через ' + warpName() + (t.colo ? ' (сервер ' + t.colo + (t.city ? ', ' + t.city : '') + ')' : '') + (t.seen ? ' · сайты видят: ' + country(t.seen) : ''), '' ]); else if (t.warp === 'notls') items.push([ 'warn', who + 'соединение есть, но HTTPS через туннель не проходит', ownW ? 'замените конфиг на вкладке WARP' : 'нажмите «Сменить точки входа»' ]); else items.push([ tn.length > 1 && diagRes.warp === 'on' ? 'warn' : 'fail', who + 'трафик через ' + warpName() + ' не идёт', ownW ? 'нажмите «Перезапустить туннель» или замените конфиг на вкладке WARP' : 'нажмите «Перезапустить туннели» или «Сменить точки входа»' ]); }); else if (diagRes.warp === 'on') items.push([ 'ok', 'Трафик идёт через WARP' + (diagRes.colo ? ' (сервер ' + diagRes.colo + ')' : ''), '' ]); else if (diagRes.warp === 'off') items.push([ 'fail', 'Трафик через WARP не идёт', 'нажмите «Перезапустить туннели» или «Сменить точки входа»' ]); var d = diagRes.diag; if (d && d.checks) d.checks.forEach(function(c) { if (c.verdict === 'ok' || c.verdict === 'warn' || c.verdict === 'fail') items.push([ c.verdict, c.what, c.why ]); }); var bad = items.filter(function(i) { return i[0] === 'fail'; }).length, warn = items.filter(function(i) { return i[0] === 'warn'; }).length; checkCard.appendChild(row('Итог', bad ? badge('zm-bad', 'есть поломка') : warn ? badge('zm-warn', 'работает, есть замечания') : badge('zm-ok', 'всё в порядке'))); var CLS = { ok: 'zm-ok', warn: 'zm-warn', fail: 'zm-bad' }, TXT = { ok: 'ок', warn: 'внимание', fail: 'ошибка' }; items.forEach(function(i) { checkCard.appendChild(E('div', { 'class': 'zm-row' }, [ badge(CLS[i[0]], TXT[i[0]]), E('span', {}, [].concat(i[1], i[2] ? ' — ' + i[2] : '')) ])); }); } checkCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': diagBusy || busy ? '' : null, 'click': function() { runDiag(false); } }, diagBusy ? 'Проверяем…' : 'Проверить') ])); } function renderWarp() { warpCard.innerHTML = ''; warpCard.style.display = data.blocker ? 'none' : ''; if (data.blocker) return; var tl = data.tunnels || []; var own = data.warp_on && data.warp_mode === 'own'; warpCard.appendChild(E('h3', {}, own ? 'Свой WARP' : tl.length > 1 ? 'Туннели WARP' : 'Туннель WARP')); if (!data.installed) { warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сначала установите Steer.')); return; } var sel = warpPick || (data.warp_mode === 'own' ? 'own' : 'auto'); warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Режим'), E('div', { 'class': 'zm-seg' }, [ E('div', { 'class': 'zm-seg-item' + (sel === 'auto' ? ' zm-active' : ''), 'click': function() { if (busy || sel === 'auto') return; if (own) { if (!confirm('Перейти на автоматический WARP?\n\nВключатся прежние автоматические туннели (если их ещё не было — роутер получит ключи у Cloudflare и подберёт три туннеля за несколько минут). Свой туннель выключится, но сохранится.')) return; act('warp_mode', 'auto', 'Переходим на автоматический WARP — это займёт несколько минут'); return; } warpPick = null; renderWarp(); } }, 'Автоматически'), E('div', { 'class': 'zm-seg-item' + (sel === 'own' ? ' zm-active' : ''), 'click': function() { if (busy || sel === 'own') return; warpPick = own ? null : 'own'; renderWarp(); } }, 'Свой конфиг') ]) ])); function ownEditor(btnText, toastText) { if (!ownTa) ownTa = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'min-height:220px', 'placeholder': '[Interface]\nPrivateKey = …\nAddress = 172.16.0.2/32\n\n[Peer]\nPublicKey = …\nEndpoint = 162.159.192.1:2408' }); warpCard.appendChild(ownTa); if (data.warp_own_saved && !ownLoaded && !ownTa.value.trim()) { ownLoaded = true; zm.steerAction('warp_own_get', '').then(function(r) { if (r && r.content && !ownTa.value.trim()) ownTa.value = r.content.replace(/\s+$/, ''); }).catch(function() { ownLoaded = false; }); } var btns = [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { var v = ownTa.value.trim(); if (!v && !data.warp_own_saved) { zm.toast('Вставьте конфиг WARP', 'warning'); ownTa.focus(); return; } if (v && (!/^\s*\[interface\]/im.test(v) || !/^\s*\[peer\]/im.test(v))) { zm.toast('Это не конфиг WARP: нужны секции [Interface] и [Peer]', 'error'); return; } act('warp_own', v, toastText); } }, btnText) ]; if (warpPick === 'own' || ownOpen) btns.push(E('button', { 'class': 'cbi-button', 'click': function() { warpPick = null; ownOpen = false; renderWarp(); } }, 'Отмена')); warpCard.appendChild(E('div', { 'class': 'zm-actions' }, btns)); warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Подойдёт конфиг WireGuard или AmneziaWG: ключи, маскировка и точка входа берутся из него как есть.' + (data.warp_own_saved ? ' В поле — ваш сохранённый конфиг: поправьте его или вставьте новый. Он хранится отдельно и переживёт даже переустановку Steer.' : ''))); } if (sel === 'own' && !own) { warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Один туннель по вашему конфигу. Роутер не будет получать ключи и искать точки входа сам.' + (data.warp_on ? ' Автоматические туннели выключатся, но сохранятся — вернуться к ним можно в один клик.' : ''))); ownEditor(data.warp_on ? 'Перейти на свой конфиг' : 'Подключить', 'Подключаем свой WARP'); return; } if (!data.warp_on) { warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Бесплатный туннель Cloudflare WARP с маскировкой AmneziaWG. Поставится AmneziaWG, роутер получит ключи и подберёт три туннеля в разных колониях — это займёт несколько минут.')); warpCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { act('warp_setup', '', 'Подключаем WARP — это займёт несколько минут'); } }, 'Подключить WARP') ])); return; } if (own) { if (data.exit === 'vpn') warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас сервисы идут через подписку — свой WARP не используется.')); var t0 = tl[0] || {}, live0 = tunnelLive(t0), info0 = []; var ep0 = t0.host ? zm.secret(t0.host + ':' + t0.port) : null; if (t0.colo && t0.colo !== '?') info0.push('сервер ' + t0.colo + (t0.city ? ' (' + t0.city + ')' : '')); if (t0.seen) info0.push('сайты видят: ' + country(t0.seen)); if (t0.up) info0.push('связь ' + fmtAge(t0.hs_age)); if (t0.up) info0.push('↓ ' + zm.fmtSize(+t0.rx || 0) + ' / ↑ ' + zm.fmtSize(+t0.tx || 0)); warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Туннель'), badge(live0 ? 'zm-ok' : t0.up ? 'zm-warn' : 'zm-bad', live0 ? 'работает' : t0.up ? 'нет связи' : 'не поднят'), E('span', {}, ep0 ? [ ep0, info0.length ? ' · ' + info0.join(' · ') : '' ] : info0.join(' · ')) ])); if (ownOpen) { ownEditor('Сохранить и подключить', 'Меняем конфиг WARP'); return; } warpCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { act('warp_restart', '', 'Перезапускаем туннель'); } }, 'Перезапустить туннель'), E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { ownOpen = true; renderWarp(); if (ownTa) ownTa.focus(); } }, 'Заменить конфиг') ])); warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Не работает — «Перезапустить туннель». Не помогло — замените конфиг.')); return; } if (data.exit === 'vpn') warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас сервисы идут через подписку — туннели WARP не используются.')); if (tl.length > 1) warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Трафик идёт через самый быстрый живой туннель' + (data.warp_active && data.exit !== 'vpn' ? ' — сейчас это ' + (function() { var a = tl.filter(function(t) { return t.if === data.warp_active; })[0]; return a ? 'WARP ' + a.n : data.warp_active; })() : '') + '; упал один — Steer сам переключится на другой.')); var deadN = tl.filter(function(t) { return !tunnelLive(t); }).length; var partial = deadN > 0 && deadN < tl.length; if (tl.length) { tl.forEach(function(t) { var live = tunnelLive(t); var fix = partial && !live ? E('span', { 'style': 'display:inline-flex; gap:6px; margin-left:auto' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': busy ? '' : null, 'click': function() { act('warp_fix', String(t.n), 'Переделываем WARP ' + t.n); } }, 'Переделать'), E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { if (!confirm('Получить новые ключи для WARP ' + t.n + '?\n\nОстальные туннели не тронутся.')) return; act('warp_fixkeys', String(t.n), 'Новые ключи для WARP ' + t.n); } }, 'Новые ключи') ]) : ''; var st = badge(live ? 'zm-ok' : t.up ? 'zm-warn' : 'zm-bad', live ? 'работает' : t.up ? 'нет связи' : 'не поднят'); var cur = data.warp_active && data.exit !== 'vpn' && tl.length > 1 ? (data.warp_active === t.if ? badge('zm-ok', 'трафик идёт здесь') : live ? badge('zm-off', 'запасной') : '') : ''; var info = []; if (t.colo) info.push('сервер ' + t.colo + (t.city ? ' (' + t.city + ')' : '')); if (t.seen) info.push('сайты видят: ' + country(t.seen)); if (t.host) info.push(t.host + ':' + t.port); if (t.up) info.push('связь ' + fmtAge(t.hs_age)); if (t.up) info.push('↓ ' + zm.fmtSize(+t.rx || 0) + ' / ↑ ' + zm.fmtSize(+t.tx || 0)); warpCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'WARP ' + t.n), st, cur, E('span', {}, info.join(' · ')), fix ])); }); } else { var ts = tunnelState(); warpCard.appendChild(row('Состояние', badge(ts[1], ts[0].split(' · ')[0]))); warpCard.appendChild(row('Точка входа', E('span', {}, data.warp_host ? data.warp_host + ':' + data.warp_port : '—'))); warpCard.appendChild(row('Сервер Cloudflare', E('span', {}, data.warp_colo || '—'))); warpCard.appendChild(row('Последняя связь', E('span', {}, data.warp_up ? fmtAge(data.warp_hs_age) : '—'))); warpCard.appendChild(row('Получено / отправлено', E('span', {}, zm.fmtSize(+data.warp_rx || 0) + ' / ' + zm.fmtSize(+data.warp_tx || 0)))); } if (partial) { warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Не работает ' + (deadN === 1 ? 'один туннель' : 'часть туннелей') + ' — переделайте только ' + (deadN === 1 ? 'его' : 'их') + ': «Переделать» подберёт новую точку входа, «Новые ключи» — новый аккаунт WARP. Работающие туннели не тронутся.')); return; } warpCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { act('warp_restart', '', 'Перезапускаем туннели'); } }, 'Перезапустить туннели'), E('button', { 'class': 'cbi-button', 'click': function() { act('warp_endpoint', '', 'Ищем точки входа'); } }, 'Сменить точки входа'), E('button', { 'class': 'cbi-button', 'click': function() { if (!confirm('Пересоздать WARP?\n\nБудут получены новые ключи, туннели переподключатся. Помогает, если Cloudflare перестал пускать старые ключи.')) return; act('warp_recreate', '', 'Пересоздаём WARP'); } }, 'Новые ключи') ])); warpCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Не работает — «Перезапустить туннели». Медленно — «Сменить точки входа». Совсем не помогает — «Новые ключи».')); } function renderAuto() { autoCard.innerHTML = ''; autoCard.style.display = data.installed && !data.blocker && (data.warp_on || data.has_sub) ? '' : 'none'; if (!data.installed || data.blocker || !(data.warp_on || data.has_sub)) return; autoCard.appendChild(E('h3', {}, 'Автоперезапуск')); autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Туннели и Steer перезапускаются по расписанию — помогает, если обход со временем «подвисает».')); var cur = data.autorestart || ''; var am = cur === '' ? 'off' : (cur.indexOf('every:') === 0 ? 'every' + cur.split(':')[1] : 'daily'); var curHour = am === 'daily' ? parseInt(cur.split(':')[1], 10) : 4; if (isNaN(curHour) || curHour < 0 || curHour > 23) curHour = 4; var opts = []; for (var h = 0; h < 24; h++) opts.push(E('option', { 'value': String(h), 'selected': h === curHour ? 'selected' : null }, hh(h))); var hourSel = E('select', { 'class': 'cbi-input-select zm-hour-select' }, opts); var dailyRow = E('div', { 'class': 'zm-actions', 'style': am === 'daily' ? '' : 'display:none' }, [ E('span', { 'class': 'zm-label' }, 'Время перезапуска'), hourSel, E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { doAuto('daily:' + hourSel.value); } }, am === 'daily' ? 'Сохранить время' : 'Включить') ]); function tile(id, label, onclick) { return E('div', { 'class': 'zm-tile' + (am === id ? ' zm-active' : ''), 'click': onclick }, label); } autoCard.appendChild(E('div', { 'class': 'zm-grid' }, [ tile('off', 'Выключен', function() { if (am !== 'off') doAuto('off'); }), tile('every2', 'Каждые 2 часа', function() { if (am !== 'every2') doAuto('every:2'); }), tile('every6', 'Каждые 6 часов', function() { if (am !== 'every6') doAuto('every:6'); }), tile('every12', 'Каждые 12 часов', function() { if (am !== 'every12') doAuto('every:12'); }), tile('daily', am === 'daily' ? 'Ежедневно в ' + hh(curHour) : 'Ежедневно в заданное время', function() { dailyRow.style.display = ''; hourSel.focus(); }) ])); autoCard.appendChild(dailyRow); } function doAuto(value) { if (autoBusy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } autoBusy = true; zm.steerAction('autorestart', value).then(function(res) { autoBusy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(value === 'off' ? 'Автоперезапуск выключен' : 'Автоперезапуск настроен', 'info'); refresh(); }).catch(function() { autoBusy = false; zm.toast('Роутер не ответил', 'error'); }); } function renderWfix() { wfixCard.innerHTML = ''; var show = data.installed && !data.blocker && data.warp_on && data.warp_mode !== 'own'; wfixCard.style.display = show ? '' : 'none'; if (!show) return; var w = data.wfix || {}, mode = w.mode || 'off', on = mode !== 'off'; var now = parseInt(w.now, 10) || Math.floor(Date.now() / 1000); var lim = (parseInt(mode, 10) || 0) * 60, max = parseInt(w.max, 10) || 4; var lastCheck = parseInt(w.last_check, 10) || 0, fixing = String(w.fixing || ''); function dur(sec) { var m = Math.max(1, Math.round(sec / 60)); return m >= 60 ? Math.floor(m / 60) + ' ч' + (m % 60 ? ' ' + (m % 60) + ' мин' : '') : m + ' мин'; } function clock(t) { var d = new Date(t * 1000); return ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2); } function tickAt(t) { return Math.ceil(Math.max(t, now + 1) / 600) * 600; } wfixCard.appendChild(E('h3', {}, 'Автоподбор мёртвых туннелей')); wfixCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Раз в 10 минут проверяет туннели и неработающему подбирает новую точку входа. Работающие не трогает, без интернета не запускается.')); var deadBy = {}, stuck = false, rows = []; (w.dead || []).forEach(function(d) { deadBy[String(d.n)] = d; }); (data.tunnels || []).forEach(function(t) { var n = String(t.n), d = deadBy[n], hsa = parseInt(t.hs_age, 10); var live = tunnelLive(t) && (!d || (lastCheck && hsa < now - lastCheck)); if (live && fixing !== n) return; var el; if (fixing === n) el = badge('zm-warn', 'подбираем новую точку'); else { var since = d ? parseInt(d.since, 10) || now : (!isNaN(hsa) ? now - hsa : 0); var tries = d ? parseInt(d.tries, 10) || 0 : 0, last = d ? parseInt(d.last, 10) || 0 : 0; var kids = [ badge(tries >= max ? 'zm-bad' : 'zm-warn', 'не работает' + (since ? ' ' + dur(now - since) : '')) ]; var note = ''; if (tries >= max) { stuck = true; note = max + ' попытки не помогли'; } else if (on) note = 'подбор в ' + clock(tickAt(Math.max(since ? since + lim : now, tries ? last + lim : 0))) + (tries ? ' · попытка ' + (tries + 1) + ' из ' + max : ''); if (note) kids.push(E('span', { 'style': 'opacity:.7' }, note)); el = E('span', { 'style': 'display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap' }, kids); } rows.push(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label', 'style': 'min-width:60px' }, 'WARP ' + n), el ])); }); if (rows.length) rows.forEach(function(r) { wfixCard.appendChild(r); }); else if (on) wfixCard.appendChild(E('div', { 'class': 'zm-row' }, [ badge('zm-ok', 'все туннели работают') ])); if (stuck) wfixCard.appendChild(E('div', { 'class': 'zm-actions', 'style': 'margin:4px 0 0' }, [ E('button', { 'class': 'cbi-button', 'click': function() { zm.steerAction('wfix_reset', '').then(function() { zm.toast('Попытки сброшены', 'info'); refresh(); }); } }, 'Попробовать снова') ])); function tile(id, label) { return E('div', { 'class': 'zm-tile' + (mode === id ? ' zm-active' : ''), 'click': function() { if (mode !== id) doWfix(id); } }, label); } wfixCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:12px 0 8px' }, 'Подбирать, если туннель не работает:')); wfixCard.appendChild(E('div', { 'class': 'zm-grid' }, [ tile('off', 'Выключен'), tile('30', '30 минут'), tile('60', '1 час'), tile('180', '3 часа') ])); var log = (w.log || []).slice(-3).reverse(); if (log.length) wfixCard.appendChild(E('div', { 'class': 'zm-hint', 'style': 'margin:12px 0 0' }, log.map(function(l) { var t = parseInt(l.t, 10) || 0, d = new Date(t * 1000); return E('div', {}, [ ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2) + ' ' + clock(t) + ' — ' + l.text ]); }))); } function doWfix(value) { if (wfixBusy) return; wfixBusy = true; zm.steerAction('wfix', value).then(function(res) { wfixBusy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(value === 'off' ? 'Автоподбор выключен' : 'Автоподбор включён — проверяем туннели', 'info'); refresh(); if (value !== 'off') setTimeout(refresh, 8000); }).catch(function() { wfixBusy = false; zm.toast('Роутер не ответил', 'error'); }); } function renderDns() { dnsCard.innerHTML = ''; dnsCard.style.display = 'none'; if (!data.dns_conflict || busy) return; dnsCard.style.display = ''; dnsCard.appendChild(E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'DoH мешает Steer'), E('p', { 'class': 'zm-hint' }, (data.doh_mode === 'on' ? 'На странице «DNS over HTTPS» выбран перехват «Всегда DoH».' : 'DNS over HTTPS снова перехватывает запросы устройств (например, после правки в LuCI).') + ' Steer не видит, какие сайты открывают устройства, и выбранные сервисы идут мимо туннеля. «Исправить» переключит перехват на «Авто»: он перейдёт к Steer, а DoH продолжит шифровать DNS.'), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { zm.steerAction('dns_fix', '').then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Готово: DoH и Steer работают вместе', 'info'); refresh(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, 'Исправить') ]) ])); } var probeAfterLoad = false; var sortPing = false; try { sortPing = localStorage.getItem('zm.steer.sort') === 'ping'; } catch (e) {} function loadSub() { if (data.blocker) return; subLoading = true; zm.steerAction('sub_status', '').then(function(res) { subLoading = false; if (probeAfterLoad && res && !res.error) { probeAfterLoad = false; if (res.has && res.ext && res.list && (res.list.nodes || []).length) setTimeout(function() { lat = {}; probeAll(); }, 300); } if (res && !res.error) { var old = subData && subData.list && subData.list.nodes ? subData.list.nodes.map(function(n) { return n.name; }).join('|') : null; subData = res; var now = res.list && res.list.nodes ? res.list.nodes.map(function(n) { return n.name; }).join('|') : null; if (old !== now) lat = {}; } renderSub(); }).catch(function() { subLoading = false; renderSub(); }); } function subAct(action, arg, toastText) { act(action, arg, toastText); } function subNodesAll() { return (subData && subData.list && subData.list.nodes) || []; } function subHidden() { var h = {}; ((subData && subData.hidden) || []).forEach(function(i) { h[i] = true; }); return h; } var hideMode = false; var linksOpen = false, linksBusy = false, linksVal = ''; var linksEd = zm.linksEditor({ re: /^vless:\/\/\S+$/i, kinds: 'vless://', placeholder: 'vless://…', empty: 'Серверов в списке нет — добавьте хотя бы одну ссылку vless://.', busy: function() { return busy || linksBusy; }, onChange: function(v) { linksVal = v; renderLinksBar(); } }); var linksBar = E('div', { 'class': 'zm-actions' }); function renderLinksBar() { linksBar.innerHTML = ''; var n = linksVal ? linksVal.split('\n').length : 0; linksBar.appendChild(E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy || linksBusy ? '' : null, 'click': function() { if (!linksEd.flush()) return; if (!linksVal.trim()) { zm.toast('Оставьте хотя бы одну ссылку vless://', 'warning'); return; } linksBusy = true; act('sub_links', linksVal, 'Сохраняем список серверов', function(ok) { linksBusy = false; if (ok) { linksOpen = false; } renderSub(); }); } }, 'Сохранить серверы')); linksBar.appendChild(E('button', { 'class': 'cbi-button', 'click': function() { linksOpen = false; renderSub(); } }, 'Отмена')); linksBar.appendChild(E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'В списке: ' + n)); } function openLinks() { if (busy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.steerAction('sub_links_get', '').then(function(r) { if (r.error) { zm.toast(r.error, 'error'); return; } linksVal = String(r.links || '').replace(/\s+$/, ''); linksEd.set(linksVal); linksOpen = true; renderLinksBar(); renderSub(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function subActP(action, arg, text) { return new Promise(function(res) { act(action, arg, text, res); }); } var hidePick = zm.hidePicker({ noun: 'узлы', busy: function() { return busy; }, onMode: function(on) { hideMode = on; renderSub(); }, onSave: function(names, markers) { return subActP('sub_hide', JSON.stringify({ names: names, markers: markers }), 'Применяем список узлов'); }, onReset: function() { return subActP('sub_hide', JSON.stringify({ reset: true }), 'Возвращаем все узлы'); } }); function probeAll() { var hm = subHidden(), nodes = subNodesAll().filter(function(n) { return !hm[n.index]; }); if (probing || !nodes.length) return; probing = true; probeDone = 0; probeTotal = nodes.length; lat = {}; nodes.forEach(function(n) { lat[n.index] = { busy: true }; }); renderSub(); var queue = nodes.map(function(n) { return n.index; }); function next() { if (!queue.length) return Promise.resolve(); var i = queue.shift(); return zm.steerAction('sub_probe', String(i)).then(function(res) { var r = res && res.results && res.results[0]; lat[i] = r ? { ok: !!r.ok, ms: r.ttfb_ms > 0 ? r.ttfb_ms : r.handshake_ms, why: r.why } : { ok: false, why: (res && res.error) || '' }; }).catch(function() { lat[i] = { ok: false }; }).then(function() { probeDone++; renderSub(); return next(); }); } Promise.all([ next(), next(), next() ]).then(function() { probing = false; var ok = Object.keys(lat).filter(function(k) { return lat[k].ok; }).length; zm.toast('Проверка закончена: отвечают ' + ok + ' из ' + probeTotal, ok ? 'info' : 'warning'); renderSub(); }); } function nodeCard(opts) { var cls = 'zm-node' + (opts.active ? ' zm-active' : '') + (opts.dead ? ' zm-node-dead' : ''); return E('div', { 'class': cls, 'title': opts.title || '', 'click': opts.click }, [ E('div', { 'class': 'zm-node-name' }, [ String(opts.name || '') ]), E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ String(opts.foot || '') ]), E('span', { 'class': 'zm-lat ' + (opts.latCls || 'zm-lat-none') }, opts.lat || '') ]) ]); } function renderSub() { subCard.innerHTML = ''; subCard.style.display = data.blocker ? 'none' : ''; if (data.blocker) return; subCard.appendChild(E('h3', {}, 'VPN: подписка или VLESS')); if (!data.installed) { subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Выбранные сервисы могут идти через ваш сервер VLESS Reality. Сначала установите Steer.')); return; } if (!subData) { subCard.appendChild(E('p', { 'class': 'zm-hint' }, subLoading ? 'Загружаем…' : 'Нет данных')); return; } if (!subData.has) { subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Выбранные сервисы пойдут через ваш сервер VLESS Reality. Вставьте ссылку на подписку или ссылки vless:// — по одной на строку.')); var ta = E('textarea', { 'class': 'zm-config-editor zm-sub-input', 'spellcheck': 'false', 'rows': '3', 'placeholder': 'https://… или vless://…' }); ta.value = subInput; ta.addEventListener('input', function() { subInput = ta.value; }); subCard.appendChild(ta); subCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { var v = (subInput || '').trim(); if (!/^https?:\/\//i.test(v) && !/vless:\/\//i.test(v)) { zm.toast('Нужна ссылка https://… или vless://…', 'warning'); return; } subInput = ''; subAct('sub_set', v, 'Подключаем подписку'); } }, 'Добавить'), !subData.ext ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Для подписки ядро steer само поставит модуль VLESS') : '' ])); return; } var q = subData.quota || {}, used = (+q.up || 0) + (+q.down || 0), total = +q.total || 0; var facts = []; if (q.up !== '' || q.down !== '' || q.total !== '') { var pct = total > 0 ? Math.min(100, Math.round(used * 100 / total)) : 0; facts.push(E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Трафик'), E('b', {}, fmtBytes(used) + ' / ' + (total > 0 ? fmtBytes(total) : '∞')), total > 0 ? E('div', { 'class': 'zm-quota' + (pct >= 90 ? ' zm-quota-high' : '') }, [ E('i', { 'style': 'width:' + pct + '%' }) ]) : '' ])); } if (fmtDate(q.expire)) facts.push(E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Действует до'), E('b', {}, fmtDate(q.expire)) ])); var allNodes = (subData.list && subData.list.nodes) || [], hidMap = subHidden(); var nodes = allNodes.filter(function(n) { return !hidMap[n.index]; }); facts.push(E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Узлов'), E('b', {}, String(nodes.length) + (nodes.length !== allNodes.length ? ' из ' + allNodes.length : '')) ])); if (subData.updated) facts.push(E('div', { 'class': 'zm-sub-fact' }, [ E('span', {}, 'Обновлена'), E('b', {}, fmtDate(subData.updated)) ])); subCard.appendChild(E('div', { 'class': 'zm-sub-meta' }, [ E('div', { 'class': 'zm-sub-title' }, [ String(subData.title || 'Подписка') ]), E('div', { 'class': 'zm-sub-facts' }, facts) ])); if (subData.fell && !subData.node) subCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'note' }, [ E('span', {}, [ 'Сервер «', String(subData.fell), '» перестал отвечать — Steer сам переключился на автовыбор рабочего сервера. Чтобы вернуть его, нажмите на него в списке.' ]) ])); var vpn = (data.exit || subData.exit) === 'vpn'; if (vpn) { var v = subData.vpn || {}, pr = v.probe || {}, st; if (v.up) st = badge('zm-ok', 'подключено'); else if (pr.state === 'probing') st = badge('zm-warn', 'ищем рабочий узел · ' + pr.node + ' из ' + pr.total); else if (pr.state === 'failed') st = badge('zm-bad', 'ни один узел не ответил'); else if (pr.state === 'no_such_node') st = badge('zm-bad', 'выбранного узла больше нет'); else st = badge(data.stopped ? 'zm-off' : 'zm-warn', data.stopped ? 'Steer выключен' : 'подключаемся'); subCard.appendChild(row('Туннель', st)); } var acts = []; if (subData.kind === 'url') acts.push(E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { subAct('sub_update', '', 'Обновляем подписку'); } }, 'Обновить')); if (subData.kind === 'links') acts.push(E('button', { 'class': 'cbi-button', 'disabled': busy ? '' : null, 'click': function() { if (linksOpen) { linksOpen = false; renderSub(); } else openLinks(); } }, linksOpen ? 'Скрыть серверы' : 'Изменить серверы')); acts.push(E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': (probing || busy || !nodes.length) ? '' : null, 'click': probeAll }, probing ? 'Проверяем ' + probeDone + ' из ' + probeTotal : 'Проверить задержку')); acts.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'disabled': busy ? '' : null, 'click': function() { if (!confirm('Удалить подписку?' + (data.warp_on ? '\n\nВыбранные сервисы пойдут через WARP.' : '\n\nТуннеля не останется — сервисы пойдут напрямую.'))) return; subAct('sub_remove', '', 'Удаляем подписку'); } }, 'Удалить')); if (!hideMode) subCard.appendChild(E('div', { 'class': 'zm-actions' }, acts)); if (!hideMode && !(linksOpen && subData.kind === 'links') && nodes.length > 1) subCard.appendChild(zm.swRow(sortPing, 'Сортировать по пингу', 'Серверы идут от быстрых к медленным, неотвечающие — в конце.', function() { sortPing = !sortPing; try { localStorage.setItem('zm.steer.sort', sortPing ? 'ping' : 'list'); } catch (e) {} var tested = nodes.some(function(n) { return lat[n.index] && !lat[n.index].busy; }); renderSub(); if (sortPing && !tested && !probing && !busy) probeAll(); })); if (linksOpen && subData.kind === 'links' && !hideMode) { subCard.appendChild(E('div', { 'class': 'zm-hide-box', 'style': 'border-style:solid; border-color:rgba(127,127,127,.3); background:transparent' }, [ E('div', { 'style': 'font-weight:600' }, 'Свои серверы VLESS'), E('p', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Здесь ваши ссылки vless://: можно изменить любую, убрать лишние или добавить новые — по одной или сразу несколькими строками. Steer работает с VLESS Reality (tcp, grpc, xhttp).'), linksEd, linksBar ])); return; } var grid = E('div', { 'class': 'zm-nodes' }); grid.appendChild(nodeCard({ name: 'Авто', foot: 'первый рабочий узел', active: !subData.node, click: function() { if (subData.node && !busy) subAct('sub_node', '', 'Выбираем узел автоматически'); } })); function pingRank(n) { var l = lat[n.index]; if (!l || l.busy) return 1e8; if (!l.ok) return 1e9; return l.ms > 0 ? l.ms : 1e7; } var shown = nodes.map(function(n, i) { return { n: n, i: i }; }); if (sortPing && !probing) shown.sort(function(a, b) { return pingRank(a.n) - pingRank(b.n) || a.i - b.i; }); shown.forEach(function(sn) { var n = sn.n, ni = sn.i; var l = lat[n.index], txt = '', cls = 'zm-lat-none', dead = false; if (l && l.busy) txt = '…'; else if (l && l.ok) { txt = (l.ms > 0 ? l.ms : '?') + ' мс'; cls = latClass(l.ms); } else if (l) { txt = 'нет ответа'; cls = 'zm-lat-bad'; dead = true; } var foot = [ n.type, n.security !== 'none' ? n.security : '', n.vision ? 'vision' : '' ].filter(function(x) { return x; }).join(' · '); grid.appendChild(nodeCard({ name: n.name || 'Узел ' + (ni + 1), foot: foot, lat: txt, latCls: cls, dead: dead, active: subData.node === n.name, title: l && l.why ? l.why : '', click: function() { if (subData.node !== n.name && !busy) subAct('sub_node', n.name, 'Выбираем узел ' + n.name); } })); }); if (!hideMode) subCard.appendChild(grid); var hn = subData.hide_names || []; hidePick.update(allNodes.filter(function(n) { return !!n.name; }).map(function(n, ni) { var byName = hn.indexOf(n.name || '') >= 0; return { name: n.name || '', foot: [ n.type, n.security !== 'none' ? n.security : '' ].filter(Boolean).join(' · '), hidden: byName, locked: !!hidMap[n.index] && !byName }; }), { markers: subData.exclude || '', hideNames: hn }); subCard.appendChild(hidePick); if (!hideMode && !subData.node && nodes.length > 16 && nodes.length < allNodes.length) subCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Когда часть узлов скрыта, Steer перебирает не больше 16 видимых — первые по списку подписки. Остальные доступны для ручного выбора.' ])); var sk = subData.list && subData.list.skipped_reasons || []; if (subData.list && subData.list.skipped > 0) subCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Пропущено узлов: ' + subData.list.skipped + (sk.length ? ' — ' + sk.map(function(r) { return r.reason; }).slice(0, 2).join('; ') : '') + '. Steer умеет VLESS Reality (tcp, grpc, xhttp).' ])); if (!vpn && data.exit === 'warp') subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас сервисы идут через ' + warpName() + ' — переключить можно в карточке Steer вверху. Выбор узла сохранится.')); if (subData.kind === 'url') { var AUTO = [ { id: 'off', name: 'Не обновлять' }, { id: '3', name: 'Каждые 3 часа' }, { id: '6', name: 'Каждые 6 часов' }, { id: '12', name: 'Каждые 12 часов' }, { id: '24', name: 'Раз в сутки' } ]; var curAuto = subData.auto || 'off'; subCard.appendChild(E('h4', { 'style': 'margin:16px 0 8px' }, 'Обновлять подписку')); subCard.appendChild(E('div', { 'class': 'zm-grid' }, AUTO.map(function(a) { return E('div', { 'class': 'zm-tile' + (curAuto === a.id ? ' zm-active' : ''), 'click': function() { if (curAuto === a.id) return; zm.steerAction('sub_auto', a.id).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(a.id === 'off' ? 'Автообновление подписки выключено' : 'Подписка будет обновляться: ' + a.name.toLowerCase(), 'info'); loadSub(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } }, a.name); }))); } } function routeVerdict(r) { var tl = data.tunnels || []; var lines = []; if (r.addr) lines.push([ 'Адрес', r.fake ? [ r.addr, ' (подменный — домен есть в списках Steer)' ] : [ r.addr, ' (настоящий — домена нет в списках Steer)' ] ]); if (r.channel) lines.push([ 'Правило', r.channel ]); if (r.verdict === 'off') return { tone: 'off', cls: 'zm-off', label: 'Steer выключен', title: 'запрос пойдёт напрямую', note: 'Запустите Steer, чтобы сервисы шли через WARP или VPN.' }; if (r.verdict === 'vpn') { lines.push([ 'Выход', 'VPN' + (data.sub_label ? ' · ' + data.sub_label : '') ]); lines.push([ 'Сервер', r.node ? r.node : 'выбирается автоматически (первый рабочий)' ]); if (r.dev) lines.push([ 'Интерфейс', r.dev ]); return { tone: 'ok', cls: 'zm-ok', label: 'через VPN', title: 'идёт через подписку', lines: lines, raw: r.text }; } if (r.verdict === 'warp') { var act = data.warp_active && tl.length > 1 ? tl.filter(function(t) { return t.if === data.warp_active; })[0] : null; lines.push([ 'Выход', warpName() + (act ? ' · сейчас работает туннель ' + act.n : '') ]); if (r.dev) lines.push([ 'Интерфейс', act ? act.if : r.dev ]); return { tone: 'ok', cls: 'zm-ok', label: 'через ' + warpName(), title: 'идёт через туннель', lines: lines, raw: r.text }; } if (r.verdict === 'other') { lines.push([ 'Выход', r.out + (r.dev ? ' · ' + r.dev : '') ]); return { tone: 'ok', cls: 'zm-ok', label: 'через ' + (r.out || 'туннель'), title: 'идёт через выход из своих настроек', lines: lines, raw: r.text }; } if (r.verdict === 'direct') return { tone: 'warn', cls: 'zm-warn', label: 'напрямую', title: 'Steer этот адрес не трогает', lines: lines, note: 'Если сайт должен идти через ' + (data.exit === 'vpn' ? 'VPN' : warpName()) + ' — включите его сервис в списке или добавьте домен в «Свой список».', raw: r.text }; return { tone: 'bad', cls: 'zm-bad', label: 'не удалось определить', title: 'смотрите ответ роутера', lines: lines, raw: r.text }; } function renderRoute() { var on = data.installed && !data.blocker; routeCard.style.display = on ? '' : 'none'; if (!on) return; if (!routeBox) routeBox = zm.routeCheck({ call: function(v) { return zm.steerAction('explain', v); }, verdict: routeVerdict }); if (routeBox.parentNode === routeCard) return; routeCard.innerHTML = ''; routeCard.appendChild(E('h3', {}, 'Куда пойдёт запрос')); routeCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Введите сайт или IP-адрес — Steer покажет, пойдёт он через WARP/VPN или напрямую.')); routeCard.appendChild(routeBox); } function renderTabs() { tabBar.innerHTML = ''; var tabsOn = !data.blocker && !!data.installed; tabBar.style.display = tabsOn ? '' : 'none'; STEER_TABS.forEach(function(t) { tabBar.appendChild(E('button', { 'class': 'cbi-button' + (t.id === tab ? ' cbi-button-positive' : ''), 'click': function() { tab = t.id; try { localStorage.setItem('zm.steer.tab', tab); } catch (e) {} renderTabs(); if (tab === 'sub') loadSub(); } }, t.label)); }); Object.keys(panes).forEach(function(k) { panes[k].style.display = tabsOn && k === tab ? '' : 'none'; }); } function renderAll() { renderTabs(); renderSub(); renderMain(); renderDns(); renderLists(); renderCustom(); renderCheck(); renderRoute(); renderWarp(); renderAuto(); renderWfix(); } renderAll(); [ listCard, customCard, checkCard, routeCard ].forEach(function(n) { panes.svc.appendChild(n); }); [ warpCard, wfixCard, autoCard ].forEach(function(n) { panes.warp.appendChild(n); }); panes.sub.appendChild(subCard); [ zm.rebootSlot(), mainCard, hostsWarn, logEl, dnsCard, tabBar, panes.svc, panes.warp, panes.sub ].forEach(function(n) { wrap.appendChild(n); }); if (!data.blocker) { loadCustom(); loadSub(); } if (data.running) { lastAction = data.phase === 'remove' ? 'remove' : [ 'install', 'pkgs', 'awg', 'keys', 'tunnel' ].indexOf(data.phase) >= 0 ? 'install' : 'apply'; follow(); } else if (data.installed && !data.stopped && (parseInt(data.channels, 10) || 0) > 0) runDiag(true); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/steer.js' mkdir -p /usr/share/zm-redbtn cat > '/usr/share/zm-redbtn/services.conf' << 'ZM_INSTALLER_EOF' youtube|YouTube|svc_youtube.lst||www.youtube.com,youtubei.googleapis.com,manifest.googlevideo.com|youtu.be,i.ytimg.com,i9.ytimg.com,yt3.ggpht.com,yt4.ggpht.com,jnn-pa.googleapis.com,signaler-pa.youtube.com,yt3.googleusercontent.com,rr4---sn-4g5e6nze.googlevideo.com,rr4---sn-5go7yner.googlevideo.com,rr5---sn-n8v7knez.googlevideo.com,rr2---sn-q4fl6ndl.googlevideo.com,rr1---sn-q4fl6n6y.googlevideo.com,rr14---sn-n8v7kn7r.googlevideo.com,rr4---sn-jvhnu5g-c35d.googlevideo.com,rr1---sn-gvnuxaxjvh-jx3z.googlevideo.com,rr12---sn-gvnuxaxjvh-bvwz.googlevideo.com,rr1---sn-ug5onuxaxjvh-n8v6.googlevideo.com||youtube discord|Discord|svc_discord.lst|discord.lst|discord.com,gateway.discord.gg,updates.discord.com|cdn.discordapp.com,media.discordapp.net||discord instagram|Instagram|svc_meta.lst|meta.lst|www.instagram.com|i.instagram.com,graph.instagram.com,scontent.cdninstagram.com||meta whatsapp|WhatsApp|svc_meta.lst|whatsapp.lst|web.whatsapp.com|static.whatsapp.net,mmg.whatsapp.net||meta x|X (Twitter)|svc_twitter.lst|twitter_x.lst|x.com|twitter.com,abs.twimg.com,video.twimg.com||twitter github|GitHub|own_github.lst||github.com,raw.githubusercontent.com,objects.githubusercontent.com|codeload.github.com,api.github.com,ghcr.io|| telegram|Telegram|svc_telegram.lst|telegram.lst|web.telegram.org|t.me,core.telegram.org,telegra.ph||telegram tiktok|TikTok||||||tiktok hdrezka|HDRezka||||||hdrezka google_meet|Google Meet||||||google_meet google_ai|Google AI (Gemini)||||||google_ai google_play|Google Play||||||google_play roblox|Roblox||||||roblox cloudflare|Cloudflare||||||cloudflare cloudfront|Amazon CloudFront||||||cloudfront digitalocean|DigitalOcean||||||digitalocean hetzner|Hetzner||||||hetzner ovh|OVH||||||ovh geoblock|Геоблок||||||geoblock block|Заблокированные сайты||||||block news|Новости||||||news anime|Аниме||||||anime porn|Сайты 18+||||||porn hodca|Хостинги и CDN||||||hodca russia_inside|Всё сразу (Russia inside)||||||russia_inside russia_outside|Сайты РФ из-за рубежа||||||russia_outside ukraine_inside|Блокировки Украины||||||ukraine_inside custom|Свой список|||||| ZM_INSTALLER_EOF chmod 0644 /usr/share/zm-redbtn/services.conf if [ -d /usr/share/zm-redbtn/lists ]; then mkdir -p /opt/zapret-manager-luci/lists for f in /usr/share/zm-redbtn/lists/*.lst; do [ -s "$f" ] || continue [ -e "/opt/zapret-manager-luci/lists/${f##*/}" ] || cp -p "$f" /opt/zapret-manager-luci/lists/ 2>/dev/null || true done rm -rf /usr/share/zm-redbtn/lists fi mkdir -p /www/luci-static/resources/view/zapret-manager chmod 0755 /www/luci-static/resources/view/zapret-manager cat > '/www/luci-static/resources/view/zapret-manager/hosts.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var LABELS = { nalog: [ 'nalog.ru', 'Налоговая' ], ntc: [ 'ntc.party', 'Форум про обход блокировок' ], instagram: [ 'Instagram', 'Instagram и Facebook' ], librusec: [ 'lib.rus.ec', 'Библиотека' ], ai: [ 'AI-сервисы', 'ChatGPT, Claude, Gemini' ], twitch: [ 'Twitch', 'Стримы' ], telegram: [ 'Telegram Web', 'Веб-версия' ], spotify: [ 'Spotify', 'Музыка' ], spotifyext: [ 'Spotify extended', 'Расширенный список' ], rutor: [ 'rutor.info', 'Торренты' ], rutracker: [ 'rutracker.org', 'Торренты' ], scell: [ 'Supercell', 'Clash, Brawl Stars' ], githubraw: [ 'GitHub Raw', 'githubusercontent.com' ], github: [ 'GitHub', 'Код и релизы' ], tapeop: [ 'tapeop.dev', 'Сайт' ] }; function lbl(id) { return (LABELS[id] || [ id ])[0]; } return view.extend({ load: function() { zm.injectCss(); return zm.hostsStatus(); }, render: function(data) { var wrap = E('div', { 'class': 'zm-wrap' }); var grid = E('div', { 'class': 'zm-svc-grid' }); var busy = false, pick = null, lastItems = data.items || []; var hostsLog = E('pre', { 'class': 'zm-log' }); function curOn(items) { var m = {}; (items || []).forEach(function(it) { if (it.enabled) m[it.id] = true; }); return m; } function changes() { var cur = curOn(lastItems), out = []; if (!pick) return out; lastItems.forEach(function(it) { if (!!pick[it.id] !== !!cur[it.id]) out.push(it.id); }); return out; } var bar = zm.saveBar({ onCancel: function() { pick = null; renderGrid(lastItems); }, onSave: applyPick }); function renderGrid(items) { lastItems = items || []; grid.innerHTML = ''; var sel = pick || curOn(lastItems); lastItems.forEach(function(it) { var l = LABELS[it.id] || [ it.id, '' ]; grid.appendChild(zm.svcCard({ name: l[0], key: it.id, on: !!sel[it.id], title: l[1] ? l[0] + ' — ' + l[1] : l[0], click: function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } pick = {}; for (var k in sel) if (sel[k]) pick[k] = true; if (pick[it.id]) delete pick[it.id]; else pick[it.id] = true; renderGrid(lastItems); } })); }); var n = changes().length; bar.set(n > 0 || busy, busy, n ? 'Есть несохранённые изменения: ' + n : ''); } function applyPick() { var list = changes(); if (!list.length || busy) return; busy = true; bar.set(true, true); var log = [ '==> Применяем изменения в /etc/hosts' ]; hostsLog.classList.add('zm-show'); zm.renderLog(hostsLog, log.join('\n')); var i = 0, failed = 0; (function next() { if (i >= list.length) { log.push(failed ? '==> ОШИБКА: не всё применилось — смотрите строки выше' : '==> Готово — /etc/hosts обновлён, dnsmasq перечитал его'); zm.renderLog(hostsLog, log.join('\n')); zm.toast(failed ? 'Не всё применилось' : 'Изменения применены', failed ? 'error' : 'info'); busy = false; pick = null; zm.hostsStatus().then(function(r) { data = r; renderGrid(r.items); renderGeoGrid(); }).catch(function() { renderGrid(lastItems); }); return; } var id = list[i++], want = !!pick[id]; log.push(' → ' + (want ? 'Добавляем' : 'Убираем') + ' адреса ' + lbl(id)); zm.renderLog(hostsLog, log.join('\n')); zm.hostsToggle(id).then(function(res) { if (res.error) { failed++; log.push(' ✗ ' + lbl(id) + ': ' + res.error); } else log.push(' ✓ ' + lbl(id) + (res.enabled ? ' включён' : ' выключен')); next(); }).catch(function() { failed++; log.push(' ✗ ' + lbl(id) + ': роутер не ответил'); next(); }); })(); } renderGrid(data.items); var card = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Домены в /etc/hosts'), E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Отметьте сервисы и нажмите «Сохранить и применить» — их адреса пропишутся в /etc/hosts.'), grid, hostsLog, bar ]); wrap.appendChild(card); var geoLogEl = E('pre', { 'class': 'zm-log' }); var geoGrid = E('div', { 'class': 'zm-grid' }); function renderGeoGrid() { geoGrid.innerHTML = ''; geoGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.geohide === 'ru' ? ' zm-active' : ''), 'click': function() { replaceGeohide('ru'); } }, 'GeoHide RU')); geoGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.geohide === 'eu' ? ' zm-active' : ''), 'click': function() { replaceGeohide('eu'); } }, 'GeoHide EU')); geoGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.geohide === 'us' ? ' zm-active' : ''), 'click': function() { replaceGeohide('us'); } }, 'GeoHide US')); } renderGeoGrid(); var geoCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Заменить hosts на GeoHide'), E('p', { 'class': 'zm-hint' }, 'Внимание: это ПОЛНОСТЬЮ заменит файл /etc/hosts на список от GeoHide DNS — все блоки выше и любые ваши собственные записи будут удалены.'), geoGrid, geoLogEl ]); wrap.appendChild(geoCard); var resetLogEl = E('pre', { 'class': 'zm-log' }); var resetBtn = E('button', { 'class': 'cbi-button cbi-button-remove', 'click': resetHosts }, 'Восстановить hosts'); var resetCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Восстановить hosts'), E('p', { 'class': 'zm-hint' }, 'Вернёт /etc/hosts к чистому виду — уберёт и блоки выше, и GeoHide, и всё, что было добавлено вручную.'), E('div', { 'class': 'zm-actions' }, [ resetBtn ]), resetLogEl ]); wrap.appendChild(resetCard); function refreshAll() { zm.hostsStatus().then(function(res) { data = res; renderGrid(res.items); renderGeoGrid(); }); } function replaceGeohide(region) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Заменяем hosts на GeoHide ' + region.toUpperCase() + '', 'warning'); geoLogEl.classList.add('zm-show'); zm.renderLog(geoLogEl, '==> Скачиваем и заменяем /etc/hosts'); zm.hostsReplaceGeohide(region).then(function(res) { busy = false; if (res.error) { zm.renderLog(geoLogEl, '==> ОШИБКА: ' + res.error); zm.toast(res.error, 'error'); return; } zm.renderLog(geoLogEl, '==> Готово — hosts заменён на GeoHide ' + region.toUpperCase() + '.'); zm.toast('hosts заменён на GeoHide ' + region.toUpperCase(), 'info'); refreshAll(); }).catch(function() { busy = false; zm.renderLog(geoLogEl, '==> ОШИБКА: роутер не ответил — проверьте hosts и попробуйте ещё раз'); zm.toast('Роутер не ответил', 'error'); refreshAll(); }); } function resetHosts() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Восстанавливаем hosts', 'warning'); resetLogEl.classList.add('zm-show'); zm.renderLog(resetLogEl, '==> Восстанавливаем hosts'); zm.hostsReset().then(function(res) { busy = false; if (res.error) { zm.renderLog(resetLogEl, '==> ОШИБКА: ' + res.error); zm.toast(res.error, 'error'); return; } zm.renderLog(resetLogEl, '==> Готово — hosts восстановлен.'); zm.toast('hosts восстановлен', 'info'); refreshAll(); }).catch(function() { busy = false; zm.renderLog(resetLogEl, '==> ОШИБКА: роутер не ответил — проверьте hosts и попробуйте ещё раз'); zm.toast('Роутер не ответил', 'error'); refreshAll(); }); } var editorCard = E('div', { 'class': 'zm-card' }); var editorOpen = false; var hostsEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'display:none' }); var hostsBar = zm.editorBar(hostsEl, { onSave: function(v) { zm.toast('Сохраняем /etc/hosts и перезапускаем dnsmasq', 'warning'); return zm.hostsFileSet(v).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return false; } zm.toast('hosts сохранён, dnsmasq перезапущен', 'info'); refreshAll(); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } }); var bigInfo = null; function refreshHostsFile() { zm.hostsFileGet().then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } var was = !!bigInfo; bigInfo = res.too_big ? res : null; if (bigInfo || was) renderEditorCard(); if (!bigInfo) hostsBar.reset(res.content || ''); }); } function renderEditorCard() { editorCard.innerHTML = ''; editorCard.appendChild(E('h3', {}, 'Редактор /etc/hosts')); if (!editorOpen) { editorCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Прямое редактирование всего файла /etc/hosts.')); editorCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { editorOpen = true; refreshHostsFile(); renderEditorCard(); } }, 'Открыть редактор hosts') ])); return; } if (bigInfo) { hostsEl.style.display = 'none'; editorCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-block-banner' }, [ E('span', {}, 'Файл hosts большой: ' + bigInfo.lines + ' строк, ' + Math.round(bigInfo.size / 1024) + ' КБ (например, список GeoHide). Чтобы не перегружать роутер и не уронить вход в панель, редактор для такого файла отключён. Вернуть обычный hosts можно кнопкой «Восстановить hosts» выше.') ])); editorCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { editorOpen = false; renderEditorCard(); } }, 'Свернуть') ])); return; } editorCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сохранение перезапускает dnsmasq. Резервная копия предыдущей версии остаётся в /etc/hosts.bak.')); hostsEl.style.display = ''; editorCard.appendChild(hostsEl); editorCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (hostsBar.dirty() && !confirm('Отменить несохранённые изменения и перечитать файл?')) return; refreshHostsFile(); zm.toast('Содержимое перечитано с диска', 'info'); } }, 'Перечитать файл'), E('button', { 'class': 'cbi-button', 'click': function() { if (hostsBar.dirty() && !confirm('Есть несохранённые изменения. Свернуть без сохранения?')) return; editorOpen = false; hostsBar.reset(); renderEditorCard(); } }, 'Свернуть') ])); editorCard.appendChild(hostsBar); } renderEditorCard(); wrap.appendChild(editorCard); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/hosts.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/mixomo.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var PRESETS = [ { id: 'ih1', label: 'Internet Helper' }, { id: 'itdog', label: 'ITDog' }, { id: 'ih2', label: 'Internet Helper (старый)' } ]; var TABS = [ { id: 'main', label: 'Mixomo' }, { id: 'config', label: 'Конфигурация Mihomo' }, { id: 'magitrickle', label: 'MagiTrickle' }, { id: 'warp', label: 'WARP' } ]; return view.extend({ load: function() { zm.injectCss(); return Promise.all([ zm.mixomoStatus(), zm.mixomoWarpStatus().catch(function() { return {}; }) ]); }, render: function(all) { var view = this; var data = all[0]; var warpData = all[1] || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var busy = false; var activeTab = 'main'; 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() { tabBar.innerHTML = ''; TABS.forEach(function(t) { tabBar.appendChild(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)); }); } var mainLogEl = E('pre', { 'class': 'zm-log' }); var statusCard = E('div', { 'class': 'zm-card' }); var panelCard = E('div', { 'class': 'zm-card' }); var subCard = E('div', { 'class': 'zm-card' }); var autoCard = E('div', { 'class': 'zm-card' }); function verRow(label, ver, latest) { var text = ver || '—'; if (ver && latest && ver !== latest) text += ' (доступно ' + latest + ')'; return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), E('span', {}, text) ]); } function applyInstalled(installed) { tabBar.style.display = installed ? '' : 'none'; [ panelCard, subCard, autoCard ].forEach(function(c) { c.style.display = installed ? '' : 'none'; }); if (!installed && activeTab !== 'main') { activeTab = 'main'; TABS.forEach(function(t2) { panels[t2.id].style.display = t2.id === activeTab ? '' : 'none'; }); renderTabBar(); } } function renderStatus(d) { statusCard.innerHTML = ''; var installed = d.mihomo === 'installed'; applyInstalled(installed); if (!installed && (d.forkozz || d.bytetube) && !busy) { zm.blockCard(statusCard, 'Mixomo', 'Связка из трёх программ: Mihomo (прокси-ядро) + hev-socks5-tunnel (мост в туннель) + MagiTrickle (направляет в туннель только выбранные сайты).', d.forkozz ? 'Стоит Forkozz — он сам направляет сервисы в туннель. Чтобы поставить Mixomo, удалите Forkozz.' : 'Стоит ByeTube — он тоже использует hev-socks5-tunnel, вместе они мешают друг другу. Чтобы поставить Mixomo, удалите ByeTube.'); return; } var allRunning = d.mihomo_running === true && d.hev_running === true && d.magitrickle_running === true; var hasUpdate = (d.mihomo_version && d.mihomo_latest && d.mihomo_version !== d.mihomo_latest) || (d.magitrickle_version && d.magitrickle_latest && d.magitrickle_version !== d.magitrickle_latest); var actions = []; if (installed) { actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить Mixomo полностью?\n\nБудут удалены Mihomo, MagiTrickle, hev-socks5-tunnel, их настройки, подписка, WARP.conf, интерфейс, зона файрвола и задания cron.')) return; doAction('remove'); } }, 'Удалить')); actions.push(E('button', { 'class': 'cbi-button', 'click': function() { doAction(allRunning ? 'stop' : 'start'); } }, allRunning ? 'Остановить' : 'Запустить')); actions.push(E('button', { 'class': 'cbi-button', 'click': function() { doAction('restart'); } }, 'Перезапустить')); actions.push(E('button', { 'class': hasUpdate ? 'cbi-button cbi-button-positive' : 'cbi-button', 'click': function() { doAction('update'); } }, hasUpdate ? 'Обновить (есть новые версии)' : 'Переустановить/обновить')); if (d.mihomo_running) { actions.push(E('a', { 'class': 'cbi-button', 'href': 'http://' + window.location.hostname + ':9090/ui', 'target': '_blank', 'rel': 'noreferrer' }, 'Войти в панель Mihomo')); } } else { actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': d.forkozz || d.bytetube ? '' : null, 'click': function() { doAction('install'); } }, 'Установить')); } statusCard.appendChild(E('h3', {}, 'Mixomo')); statusCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Связка из трёх программ: Mihomo (прокси-ядро) + hev-socks5-tunnel (мост в туннель) + MagiTrickle (направляет в туннель только выбранные сайты).')); statusCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Mihomo'), installed ? zm.badge(d.mihomo_running === true, 'запущен', 'остановлен') : zm.badge(false, '', 'не установлен') ])); if (installed) { statusCard.appendChild(verRow('Версия Mihomo', d.mihomo_version, d.mihomo_latest)); statusCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'hev-socks5-tunnel'), zm.badge(d.hev === 'installed' && d.hev_running === true, d.hev === 'installed' ? 'запущен' : '', d.hev === 'installed' ? 'остановлен' : 'не установлен') ])); if (d.hev_version) { statusCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Версия hev-socks5-tunnel'), E('span', {}, [ String(d.hev_version) ]) ])); } statusCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'MagiTrickle'), zm.badge(d.magitrickle === 'installed' && d.magitrickle_running === true, d.magitrickle === 'installed' ? 'запущен' : '', d.magitrickle === 'installed' ? 'остановлен' : 'не установлен') ])); if (d.magitrickle_version) statusCard.appendChild(verRow('Версия MagiTrickle', d.magitrickle_version, d.magitrickle_latest)); statusCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Подписка'), zm.badge(d.subscription === true, 'настроена', 'не настроена') ])); } statusCard.appendChild(E('div', { 'class': 'zm-actions' }, actions)); } var uiBusy = false; function renderPanel(d) { panelCard.innerHTML = ''; panelCard.appendChild(E('h3', {}, 'Веб-панель Mihomo')); panelCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Собственный веб-интерфейс Mihomo (статистика, выбор прокси вручную). По умолчанию ставится MetaCubeXD.')); if (d.mihomo !== 'installed') { panelCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите Mixomo, чтобы выбрать панель.')); return; } panelCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Сейчас'), E('span', {}, d.ui_panel === 'zashboard' ? 'Zashboard' : d.ui_panel === 'metacubexd' ? 'MetaCubeXD' : 'не выбрана') ])); panelCard.appendChild(E('div', { 'class': 'zm-grid' }, [ E('div', { 'class': 'zm-tile' + (d.ui_panel === 'zashboard' ? ' zm-active' : ''), 'click': function() { doUi('zashboard'); } }, 'Zashboard'), E('div', { 'class': 'zm-tile' + (d.ui_panel === 'metacubexd' ? ' zm-active' : ''), 'click': function() { doUi('metacubexd'); } }, 'MetaCubeXD') ])); } function doUi(which) { if (uiBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } uiBusy = true; zm.toast('Устанавливаем панель', 'warning'); zm.mixomoUiAction(which).then(function(res) { if (res.error) { uiBusy = false; zm.toast(res.error, 'error'); return; } zm.pollJob('mixomo_ui_install', mainLogEl, function(ok) { uiBusy = false; zm.toast(ok ? 'Панель установлена' : 'Ошибка установки', ok ? 'info' : 'error'); zm.mixomoStatus().then(renderPanel); }); }).catch(function() { uiBusy = false; }); } function doAction(action) { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var job = action === 'remove' ? 'mixomo_remove' : 'mixomo_install'; var isBg = action === 'install' || action === 'update' || action === 'remove'; busy = true; zm.toast( action === 'remove' ? 'Удаляем Mixomo' : action === 'update' ? 'Обновляем Mixomo (три программы, может занять пару минут)' : action === 'install' ? 'Устанавливаем Mixomo (три программы, может занять пару минут)' : action === 'start' ? 'Запускаем Mixomo' : action === 'stop' ? 'Останавливаем Mixomo' : 'Перезапускаем Mixomo', 'warning' ); zm.mixomoAction(action).then(function(res) { if (res.error) { busy = false; zm.toast(res.error, 'error'); return; } if (isBg && res.started) { zm.pollJob(job, mainLogEl, function(ok) { busy = false; zm.toast(ok ? 'Готово' : 'Ошибка', ok ? 'info' : 'error'); refreshAll(); }); } else { busy = false; refreshAll(); zm.toast('Готово', 'info'); } }).catch(function() { busy = false; zm.toast('Роутер не ответил', 'error'); }); } function refreshAll() { zm.mixomoStatus().then(function(d) { renderStatus(d); renderPanel(d); renderPresets(d); renderEmbed(d); renderAuto(d); renderMtStatus(d); renderExcl(d); }); } var subInput = E('input', { 'type': 'text', 'placeholder': 'https://...', 'class': 'cbi-input-text' }); var subBusy = false; subCard.appendChild(E('h3', {}, 'Подписка')); subCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Вставьте ссылку на VPN-подписку — если в конфигурации уже настроен провайдер подписки, обновится только ссылка; если нет, будет создана готовая конфигурация с раздельной маршрутизацией YouTube и остального трафика через подписку.')); subCard.appendChild(E('div', { 'class': 'zm-actions' }, [ subInput, E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { if (subBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var url = subInput.value.trim(); if (!/^https?:\/\//i.test(url)) { zm.toast('Ссылка должна начинаться с http:// или https://', 'error'); return; } subBusy = true; zm.toast('Применяем подписку', 'warning'); zm.mixomoApplySubscription(url).then(function(res) { subBusy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(res.mode === 'updated' ? 'Ссылка на подписку обновлена' : 'Подписка применена, создана новая конфигурация', 'info'); refreshAll(); setTimeout(loadMxSrv, 4000); refreshConfig(); }).catch(function() { subBusy = false; zm.toast('Роутер не ответил', 'error'); }); } }, 'Применить подписку') ])); var mxSrvEl = E('div', {}), mxSrv = null, mxSrvBusy = false, mxHideMode = false; var mxHide = zm.hidePicker({ noun: 'серверы', exact: true, words: { hide: 'Исключить', hidden: 'Исключено', on: 'исключён', off: 'остаётся', edit: 'Изменить список исключённых', reset: 'Вернуть все серверы', desc: 'Эти серверы исключены из подписки — Mihomo их не загружает.', hint: 'Отметьте серверы, которые нужно исключить из подписки, и нажмите «Сохранить». Остальные серверы работают как раньше.' }, onMode: function(on) { mxHideMode = on; renderMxSrv(); }, onSave: function(names, markers) { return mxHideSave(JSON.stringify({ names: names, markers: markers })); }, onReset: function() { return mxHideSave(JSON.stringify({ reset: true })); } }); function mxHideSave(v) { return zm.mixomoFilterSet(v).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return false; } zm.toast(res.providers === false ? 'Сохранено — применится, когда в конфигурации появится подписка (proxy-providers)' : 'Список серверов применён' + (res.restarted ? ', Mihomo перезапущен' : ''), 'info'); setTimeout(loadMxSrv, 2500); refreshConfig(); return true; }, function() { zm.toast('Роутер не ответил', 'error'); return false; }); } function mxNodes() { var out = [], seen = {}, prov = (mxSrv && mxSrv.data && mxSrv.data.providers) || {}; Object.keys(prov).forEach(function(k) { var p = prov[k] || {}; if (k === 'default' || p.vehicleType === 'Compatible') return; (p.proxies || []).forEach(function(x) { if (!x || !x.name || seen[x.name]) return; seen[x.name] = true; var h = x.history && x.history.length ? x.history[x.history.length - 1].delay : null; out.push({ name: x.name, type: String(x.type || '').toLowerCase(), delay: h }); }); }); return out; } function loadMxSrv() { if (mxSrvBusy) return; mxSrvBusy = true; renderMxSrv(); zm.mixomoProxies().then(function(r) { mxSrvBusy = false; mxSrv = r || {}; renderMxSrv(); }, function() { mxSrvBusy = false; mxSrv = { error: 'роутер не ответил' }; renderMxSrv(); }); } function renderMxSrv() { mxSrvEl.innerHTML = ''; mxSrvEl.appendChild(E('h4', { 'style': 'margin:16px 0 8px' }, 'Исключение серверов из подписки')); mxSrvEl.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 8px' }, [ 'Здесь можно убрать ненужные серверы из подписки — панель добавит их в exclude-filter, и Mihomo не будет их загружать. Это не выбор сервера: выбирать, через какой сервер подключаться, — в панели Mihomo.' ])); if (!mxSrv) { mxSrvEl.appendChild(E('p', { 'class': 'zm-hint' }, mxSrvBusy ? 'Загружаем…' : 'Список ещё не загружен.')); return; } var nodes = mxNodes(), hn = mxSrv.hide_names || [], seen = {}; var items = nodes.map(function(n) { seen[n.name] = true; return { name: n.name, foot: n.type, hidden: hn.indexOf(n.name) >= 0 }; }); (mxSrv.all_names || []).forEach(function(nm) { if (!nm || seen[nm]) return; seen[nm] = true; var byName = hn.indexOf(nm) >= 0; items.push({ name: nm, foot: 'не загружается', hidden: byName, locked: !byName }); }); mxHide.update(items, { markers: mxSrv.exclude || '', hideNames: hn, noEdit: !nodes.length, error: mxSrv.error ? 'Список серверов недоступен: ' + mxSrv.error : (!nodes.length ? 'Mihomo не отдал список серверов — проверьте подписку в конфигурации' : '') }); if (!mxHideMode && nodes.length) { mxSrvEl.appendChild(E('div', { 'class': 'zm-nodes' }, nodes.map(function(n) { return E('div', { 'class': 'zm-node', 'style': 'cursor:default' }, [ E('div', { 'class': 'zm-node-name' }, [ String(n.name) ]), E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ n.type ]), E('span', { 'class': 'zm-lat ' + (n.delay > 0 ? (n.delay < 300 ? 'zm-lat-good' : n.delay < 800 ? 'zm-lat-mid' : 'zm-lat-bad') : 'zm-lat-none') }, n.delay > 0 ? n.delay + ' мс' : '') ]) ]); }))); mxSrvEl.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': mxSrvBusy ? '' : null, 'click': loadMxSrv }, mxSrvBusy ? 'Загружаем…' : 'Обновить список') ])); mxSrvEl.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Серверов загружено: ' + nodes.length + '. Это список для справки — нажатие на сервер ничего не выбирает.' ])); if (mxSrv.own_filter) mxSrvEl.appendChild(E('p', { 'class': 'zm-hint' }, [ 'В конфигурации есть свой фильтр exclude-filter: «' + mxSrv.own_filter + '» — панель его сохраняет и добавляет к нему свои исключения.' ])); } else if (!mxHideMode) { mxSrvEl.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': mxSrvBusy ? '' : null, 'click': loadMxSrv }, mxSrvBusy ? 'Загружаем…' : 'Обновить список') ])); } mxSrvEl.appendChild(mxHide); } subCard.appendChild(mxSrvEl); function renderExcl(d) { if (d && d.mihomo === 'installed' && !mxSrv && !mxSrvBusy) loadMxSrv(); } var autoBusy = false; function renderAuto(d) { autoCard.innerHTML = ''; autoCard.appendChild(E('h3', {}, 'Автоперезапуск Mihomo')); autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Периодический перезапуск по расписанию — полезно для VPN-подписок, у которых иногда «зависает» соединение.')); var cur = d.autorestart || ''; var mode = cur === '' ? 'off' : (cur.indexOf('every:') === 0 ? 'every' + cur.split(':')[1] : 'daily'); var curHour = mode === 'daily' ? parseInt(cur.split(':')[1], 10) : 4; if (isNaN(curHour) || curHour < 0 || curHour > 23) curHour = 4; function hh(h) { return (h < 10 ? '0' : '') + h + ':00'; } var opts = []; for (var h = 0; h < 24; h++) opts.push(E('option', { 'value': String(h), 'selected': h === curHour ? 'selected' : null }, hh(h))); var hourSel = E('select', { 'class': 'cbi-input-select zm-hour-select' }, opts); var dailyRow = E('div', { 'class': 'zm-actions', 'style': mode === 'daily' ? '' : 'display:none' }, [ E('span', { 'class': 'zm-label' }, 'Время перезапуска'), hourSel, E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { doAuto('daily', hourSel.value); } }, mode === 'daily' ? 'Сохранить время' : 'Включить') ]); function tile(id, label, onclick) { return E('div', { 'class': 'zm-tile' + (mode === id ? ' zm-active' : ''), 'click': onclick }, label); } autoCard.appendChild(E('div', { 'class': 'zm-grid' }, [ tile('off', 'Выключен', function() { if (mode !== 'off') doAuto('off', ''); }), tile('every2', 'Каждые 2 часа', function() { if (mode !== 'every2') doAuto('every', '2'); }), tile('every6', 'Каждые 6 часов', function() { if (mode !== 'every6') doAuto('every', '6'); }), tile('daily', mode === 'daily' ? 'Ежедневно в ' + hh(curHour) : 'Ежедневно в заданное время', function() { dailyRow.style.display = ''; hourSel.focus(); }) ])); autoCard.appendChild(dailyRow); } function doAuto(mode, value) { if (autoBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } autoBusy = true; zm.toast('Настраиваем автоперезапуск', 'warning'); zm.mixomoAutorestartSet(mode, value).then(function(res) { autoBusy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Автоперезапуск настроен', 'info'); zm.mixomoStatus().then(renderAuto); }).catch(function() { autoBusy = false; }); } panels.main.appendChild(statusCard); panels.main.appendChild(mainLogEl); panels.main.appendChild(panelCard); panels.main.appendChild(subCard); panels.main.appendChild(autoCard); var configEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false' }); var configCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Редактор конфигурации Mihomo'), E('p', { 'class': 'zm-hint' }, 'Прямое редактирование /etc/mihomo/config.yaml. Проверка — как в оригинальном установщике: сохранение перезапускает Mihomo, и если он не поднимается с новой конфигурацией, изменения автоматически откатываются, а рабочая версия остаётся нетронутой.') ]); configCard.appendChild(configEl); var configBar = zm.editorBar(configEl, { onSave: function(v) { zm.toast('Сохраняем конфигурацию, проверяем и перезапускаем Mihomo', 'warning'); return zm.mixomoConfigSet(v).then(function(res) { if (res.error) { zm.toast(res.error, 'error', 12000); return false; } zm.toast('Проверка пройдена, Mihomo перезапущен с новой конфигурацией', 'info'); refreshAll(); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } }); function refreshConfig() { zm.mixomoConfigGet().then(function(res) { if (res.error) { configBar.reset(''); configEl.placeholder = res.error; return; } configBar.reset(res.content || ''); }); } configCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (configBar.dirty() && !confirm('Отменить несохранённые изменения и перечитать файл?')) return; refreshConfig(); zm.toast('Конфигурация перечитана с диска', 'info'); } }, 'Перечитать файл') ])); configCard.appendChild(configBar); panels.config.appendChild(configCard); var mtStatusCard = E('div', { 'class': 'zm-card' }); var presetCard = E('div', { 'class': 'zm-card' }); var embedCard = E('div', { 'class': 'zm-card' }); function renderMtStatus(d) { mtStatusCard.innerHTML = ''; mtStatusCard.appendChild(E('h3', {}, 'MagiTrickle')); mtStatusCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Направляет в туннель Mixomo только выбранные сайты и адреса — остальной трафик идёт напрямую через провайдера. Устанавливается вместе с Mixomo на вкладке «Mixomo».')); mtStatusCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Статус'), d.magitrickle === 'installed' ? zm.badge(d.magitrickle_running === true, 'запущен', 'остановлен') : zm.badge(false, '', 'не установлен') ])); if (d.magitrickle_version) mtStatusCard.appendChild(verRow('Версия', d.magitrickle_version, d.magitrickle_latest)); } var presetBusy = false; function renderPresets(d) { presetCard.innerHTML = ''; presetCard.appendChild(E('h3', {}, 'Готовые списки доменов')); presetCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Быстрая замена текущего списка на один из готовых наборов — зелёным отмечен список, применённый сейчас. Для собственного точного набора сайтов используйте окно ниже.')); if (d.magitrickle !== 'installed') { presetCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите Mixomo на вкладке «Mixomo», чтобы выбрать список.')); return; } presetCard.appendChild(E('div', { 'class': 'zm-grid' }, PRESETS.map(function(p) { return E('div', { 'class': 'zm-tile' + (d.magitrickle_list === p.id ? ' zm-active' : ''), 'click': function() { if (presetBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } presetBusy = true; zm.toast('Применяем список: ' + p.label, 'warning'); zm.mixomoMagitrickleListSet(p.id).then(function(res) { presetBusy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Список применён: ' + p.label, 'info'); waitForListAppliedThenRefresh(p.id); }).catch(function() { presetBusy = false; }); } }, p.label); }))); } var mtFrame = null; function refreshMtEmbed() { if (mtFrame) { mtFrame.src = mtFrame.src.split('?')[0] + '?_r=' + Date.now(); } } function waitForListAppliedThenRefresh(presetId) { var attempts = 0, maxAttempts = 10, inFlight = false; var timer = setInterval(function() { if (inFlight) return; attempts++; if (attempts > maxAttempts + 2) { clearInterval(timer); return; } inFlight = true; zm.mixomoStatus().then(function(d) { inFlight = false; if (!d || d.error) return; if (d.magitrickle_list === presetId || attempts >= maxAttempts) { clearInterval(timer); renderPresets(d); setTimeout(refreshMtEmbed, 2500); } }).catch(function() { inFlight = false; }); }, 1500); } function renderEmbed(d) { embedCard.innerHTML = ''; embedCard.appendChild(E('h3', {}, 'Окно MagiTrickle')); if (d.magitrickle !== 'installed') { embedCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите Mixomo, чтобы открыть управление группами и подписками MagiTrickle.')); mtFrame = null; return; } var host = window.location.hostname; var url = 'http://' + host + ':8080'; if (window.location.protocol === 'https:') { mtFrame = null; embedCard.appendChild(E('p', { 'class': 'zm-hint' }, 'HTTPS-соединение блокирует встроенное окно MagiTrickle. Откройте его в новой вкладке для управления «Группами» и «Подписками» — после применения готового списка выше просто обновите ту вкладку.')); embedCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('a', { 'class': 'cbi-button cbi-button-positive', 'href': url, 'target': '_blank', 'rel': 'noreferrer' }, 'Открыть MagiTrickle') ])); } else { mtFrame = E('iframe', { 'src': url, 'style': 'width:100%; height:640px; border:1px solid var(--border, rgba(127,127,127,.25)); border-radius:10px' }); embedCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': refreshMtEmbed }, 'Обновить окно MagiTrickle') ])); embedCard.appendChild(mtFrame); } } panels.magitrickle.appendChild(mtStatusCard); panels.magitrickle.appendChild(presetCard); panels.magitrickle.appendChild(embedCard); var warpLogEl = E('pre', { 'class': 'zm-log' }); var warpStatusCard = E('div', { 'class': 'zm-card' }); var warpConfCard = E('div', { 'class': 'zm-card' }); var warpBusy = false; var warpIntegrateBusy = false; function renderWarpStatus(w) { warpStatusCard.innerHTML = ''; warpStatusCard.appendChild(E('h3', {}, 'WARP')); warpStatusCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Генерирует бесплатный ключ Cloudflare WARP и сохраняет его в /root/WARP.conf (тот же файл, что на вкладке AmneziaWG). Дальше файл можно интегрировать в Mihomo как ещё один прокси-выход. «С подбором точки входа»: если установлен AmneziaWG, роутер проверит точки входа настоящим туннелем и выберет зарубежный узел Cloudflare — через российский (DME и др.) часть сайтов не открывается; без AmneziaWG подбор приблизительный.')); warpStatusCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'WARP.conf'), zm.badge(w.exists === true, 'сгенерирован', 'не сгенерирован') ])); warpStatusCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { doWarpGenerate('fixed'); } }, 'Сгенерировать WARP'), E('button', { 'class': 'cbi-button', 'click': function() { doWarpGenerate('auto'); } }, 'С подбором точки входа'), E('button', { 'class': 'cbi-button', 'click': function() { if (warpIntegrateBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (w.exists !== true) { zm.toast('Сначала сгенерируйте WARP.conf', 'error'); return; } warpIntegrateBusy = true; zm.toast('Интегрируем WARP в Mihomo', 'warning'); zm.mixomoWarpIntegrateAction().then(function(res) { if (res.error) { warpIntegrateBusy = false; zm.toast(res.error, 'error'); return; } zm.pollJob('mixomo_warp_integrate', warpLogEl, function(ok) { warpIntegrateBusy = false; zm.toast(ok ? 'WARP добавлен в Mihomo как прокси' : 'Ошибка интеграции', ok ? 'info' : 'error'); if (ok) refreshConfig(); }); }).catch(function() { warpIntegrateBusy = false; }); } }, 'Интегрировать в Mihomo') ])); } function doWarpGenerate(mode) { if (warpBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } warpBusy = true; zm.toast(mode === 'auto' ? 'Генерируем WARP и подбираем точку входа — это может занять несколько минут' : 'Генерируем WARP', 'warning'); zm.mixomoWarpAction(mode).then(function(res) { if (res.error) { warpBusy = false; zm.toast(res.error, 'error'); return; } zm.pollJob('mixomo_warp', warpLogEl, function(ok) { warpBusy = false; zm.toast(ok ? 'WARP сгенерирован' : 'Не удалось сгенерировать WARP', ok ? 'info' : 'error'); zm.mixomoWarpStatus().then(function(w) { renderWarpStatus(w); renderWarpConf(w); }); }); }).catch(function() { warpBusy = false; }); } var warpConfigEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false' }); var warpBar = zm.editorBar(warpConfigEl, { saveLabel: 'Сохранить', onSave: function(v) { zm.toast('Сохраняем WARP.conf', 'warning'); return zm.mixomoWarpConfigSet(v).then(function(res) { if (res.error) { zm.toast(res.error, 'error', 10000); return false; } zm.toast('WARP.conf сохранён', 'info'); zm.mixomoWarpStatus().then(function(w) { renderWarpStatus(w); renderWarpConf(w); }); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } }); function renderWarpConf(w) { warpBar.reset(w.content || ''); } warpConfCard.appendChild(E('h3', {}, 'Редактор WARP.conf')); warpConfCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно вставить сюда свой готовый WARP.conf (например, купленный отдельно ключ) вместо генерации выше — при сохранении проверяются обязательные поля ([Interface]/[Peer], PrivateKey/PublicKey).')); warpConfCard.appendChild(warpConfigEl); warpConfCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (warpBar.dirty() && !confirm('Отменить несохранённые изменения и перечитать файл?')) return; zm.mixomoWarpStatus().then(function(w) { renderWarpConf(w); }); zm.toast('Содержимое перечитано с диска', 'info'); } }, 'Перечитать файл') ])); warpConfCard.appendChild(warpBar); panels.warp.appendChild(warpStatusCard); panels.warp.appendChild(warpLogEl); panels.warp.appendChild(warpConfCard); renderTabBar(); renderStatus(data); renderPanel(data); renderPresets(data); renderEmbed(data); renderAuto(data); renderMtStatus(data); renderExcl(data); renderWarpStatus(warpData); renderWarpConf(warpData); refreshConfig(); wrap.appendChild(tabBar); TABS.forEach(function(t) { wrap.appendChild(panels[t.id]); }); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/mixomo.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/strategy.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var TABS = [ { id: 'strategy', label: 'Стратегии' }, { id: 'test', label: 'Тест стратегий' }, { id: 'youtube', label: 'YouTube' }, { id: 'game', label: 'Игры' }, { id: 'discord', label: 'Discord' }, { id: 'lists', label: 'Редактор списков' }, { id: 'exclusions', label: 'Исключение устройств' } ]; var TEST_MODE_LABELS = { v: 'v1–v10', flowseal: 'Flowseal', v_flowseal: 'v + Flowseal', youtube: 'YouTube', current: 'Текущая', domain: 'По домену' }; var TEST_RESULT_MODES = ['current', 'domain', 'v', 'flowseal', 'v_flowseal', 'youtube']; var GAME_FAKES = [ 'stun.bin', 'stun2.bin', 'quic_initial_4pda_to.bin', 'quic_initial_tencent_com.bin', 'tls_clienthello_sochi_park.bin', 'quic_initial_www_google_com.bin', 'quic_initial_steamcommunity_com.bin', 'quic_initial_5ka_ru.bin', 'quic_initial_rutube_ru.bin' ]; return view.extend({ load: function() { zm.injectCss(); return Promise.all([ zm.status(), zm.strategyListV(), zm.testStatus(), zm.strategyListYoutube(), zm.gameStatus(), zm.discordStatus(), 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:10px 0' }); var panels = {}; var tabHooks = {}; var refreshers = {}; function refreshAll() { Object.keys(refreshers).forEach(function(k) { try { refreshers[k](); } catch (e) {} }); } TABS.forEach(function(t) { panels[t.id] = E('div', { 'style': t.id === activeTab ? '' : 'display:none' }); }); function renderTabBar() { tabBar.innerHTML = ''; TABS.forEach(function(t) { tabBar.appendChild(E('button', { 'class': 'cbi-button' + (t.id === activeTab ? ' cbi-button-positive' : ''), 'click': function() { activeTab = t.id; TABS.forEach(function(t2) { panels[t2.id].style.display = t2.id === activeTab ? '' : 'none'; }); renderTabBar(); if (tabHooks[t.id]) tabHooks[t.id](); } }, t.label)); }); } (function buildStrategyPanel() { var status = statusData, vList = vListData; var lastFlowseal = null; 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', 'disabled': (d.zapret2 === 'installed' && !d.expert) ? '' : null, 'click': function() { doZapretAction('install'); } }, 'Установить и настроить')); } var zBlock = d.zapret !== 'installed' && d.zapret2 === 'installed' && !d.expert; var fields = [ E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px' }, [ 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', { 'style': 'display:inline-flex; align-items:center; gap:8px' }, [ E('span', { 'class': 'zm-label' }, 'Версия'), E('span', {}, [ String(d.zapret_version) ]) ])); if (d.strategy) fields.push(E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px' }, [ E('span', { 'class': 'zm-label' }, 'Стратегия'), E('span', {}, [ String(d.strategy) ]) ])); if (zBlock) { zCardWrap.appendChild(zm.blockCard(E('div', { 'class': 'zm-card', 'style': 'margin-bottom:10px' }), 'Zapret', 'Обход DPI-блокировок: стратегии, YouTube, Discord, игры и списки.', 'Стоит Zapret2 — он несовместим с Zapret. Чтобы поставить Zapret, удалите Zapret2.')); return; } 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 (res && res.error) { zBusy = false; zm.toast(res.error, 'error'); return; } 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(); refreshAll(); }); 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 zm-current-top' }); panels.strategy.appendChild(currentBanner); var vGrid = E('div', { 'class': 'zm-grid' }); var vCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Стратегии v1 – v10'), vGrid ]); var fGrid = E('div', { 'class': 'zm-grid' }); var fCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Стратегии Flowseal'), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Обновляем список Flowseal', 'warning'); fGrid.innerHTML = 'Загрузка списка'; zm.strategyListFlowseal('refresh').then(function(res) { if (res.started) { zm.pollJob('flowseal_download', logEl, function(ok) { busy = false; zm.toast(ok ? 'Список Flowseal обновлён' : 'Не удалось обновить список', ok ? 'info' : 'error'); zm.strategyListFlowseal().then(renderFlowseal); }); } else { busy = false; renderFlowseal(res); zm.toast('Список Flowseal обновлён', 'info'); } }).catch(function() { busy = false; }); } }, 'Обновить список') ]), fGrid ]); function renderBanner() { currentBanner.className = 'zm-current-banner zm-current-top' + (status.strategy ? '' : ' zm-current-empty'); currentBanner.innerHTML = ''; if (status.strategy) { currentBanner.appendChild(E('span', {}, 'Сейчас применено: ')); currentBanner.appendChild(E('b', {}, [ String(status.strategy) ])); } else { currentBanner.appendChild(E('span', {}, 'Стратегия ещё не выбрана')); } } function renderV() { vGrid.innerHTML = ''; (vList.items || []).forEach(function(it) { var words = (' ' + (status.strategy || '') + ' '); vGrid.appendChild(E('div', { 'class': 'zm-tile' + (!status.flowseal && words.indexOf(' ' + it.id + ' ') !== -1 ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Применяем стратегию ' + it.id + '', 'warning'); zm.strategySetV(it.id).then(function(res) { busy = false; if (!zm.notifyStrategyResult(res, it.id)) return; refreshAll(); }).catch(function() { busy = false; }); } }, [ String(it.id) ])); }); } function renderFlowseal(res) { lastFlowseal = res; fGrid.innerHTML = ''; (res.items || []).forEach(function(it) { fGrid.appendChild(E('div', { 'class': 'zm-tile' + (status.flowseal === it.id ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Применяем стратегию ' + it.label + '', 'warning'); zm.strategySetFlowseal(it.id).then(function(r2) { busy = false; if (!zm.notifyStrategyResult(r2, it.id)) return; refreshAll(); }).catch(function() { busy = false; }); } }, [ String(it.label) ])); }); if (!res.items || !res.items.length) fGrid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список пуст — нажмите «Обновить список».')); } function refreshState() { zm.status().then(function(s) { status = s; renderBanner(); renderV(); if (lastFlowseal) renderFlowseal(lastFlowseal); }); } tabHooks.strategy = refreshState; refreshers.strategy = refreshState; renderBanner(); renderV(); panels.strategy.appendChild(vCard); panels.strategy.appendChild(fCard); panels.strategy.appendChild(logEl); zm.strategyListFlowseal().then(function(res) { if (!res.started) { renderFlowseal(res); return; } busy = true; fGrid.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); zm.pollJob('flowseal_download', logEl, function(ok) { busy = false; if (!ok) { fGrid.innerHTML = ''; fGrid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список не загрузился — нажмите «Обновить список».')); return; } zm.strategyListFlowseal().then(renderFlowseal); }); }); var nfqwsCard = E('div', { 'class': 'zm-card' }); var nfqwsOpen = false; var nfqwsEl = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'style': 'display:none' }); var nfqwsBar = zm.editorBar(nfqwsEl, { onSave: function(v) { zm.toast('Сохраняем стратегию и перезапускаем Zapret', 'warning'); return zm.nfqwsOptSet(v).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return false; } nfqNo = /#\s*nochange/i.test(v); renderNfqNo(); zm.toast('Сохранено, Zapret перезапущен', 'info'); refreshAll(); return true; }).catch(function() { zm.toast('Роутер не ответил', 'error'); return false; }); } }); var nfqNo = false, nfqNoBusy = false, nfqNoBox = E('div', { 'class': 'zm-list-nc' }); function renderNfqNo() { nfqNoBox.innerHTML = ''; nfqNoBox.appendChild(zm.swRow(nfqNo, 'Не изменять стратегию', 'Панель не будет менять стратегию: выбор стратегий, YouTube, Discord, игры, тест и переустановка её не тронут. В блок NFQWS_OPT добавляется строка #nochange.', function() { if (nfqNoBusy) return; if (nfqwsOpen && nfqwsBar.dirty()) { zm.toast('Сначала сохраните или отмените изменения в стратегии', 'warning'); return; } nfqNoBusy = true; renderNfqNo(); var was = nfqNo; zm.zapretListRestore('nochange=strategy:' + (was ? 'off' : 'on')).then(function(r) { nfqNoBusy = false; if (r && typeof r.nochange === 'boolean') nfqNo = r.nochange; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderNfqNo(); return; } zm.toast(was ? 'Панель снова может менять стратегию' : 'Стратегия защищена: панель её не изменит', 'info'); renderNfqNo(); if (nfqwsOpen) refreshNfqwsOpt(); }).catch(function() { nfqNoBusy = false; zm.toast('Роутер не ответил', 'error'); renderNfqNo(); }); }, nfqNoBusy)); } function refreshNfqwsOpt() { return zm.nfqwsOptGet().then(function(res) { if (res.error) { if (nfqwsOpen) zm.toast(res.error, 'error'); return; } nfqNo = /#\s*nochange/i.test(res.content || ''); renderNfqNo(); if (nfqwsOpen) nfqwsBar.reset(res.content || ''); }); } function renderNfqwsCard() { nfqwsCard.innerHTML = ''; nfqwsCard.appendChild(E('h3', {}, 'Редактировать текущую стратегию')); if (!nfqwsOpen) { nfqwsCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ручная правка блока NFQWS_OPT — для опытных пользователей.')); nfqwsCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { nfqwsOpen = true; refreshNfqwsOpt(); renderNfqwsCard(); } }, 'Открыть редактор стратегии') ])); nfqwsCard.appendChild(nfqNoBox); return; } nfqwsCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Блок NFQWS_OPT из /etc/config/zapret. «Сохранить и применить» перезапустит Zapret. Чтобы панель не меняла эту стратегию (смена стратегий, YouTube, Discord, игры, тест, переустановка), включите «Не изменять стратегию».')); nfqwsEl.style.display = ''; nfqwsCard.appendChild(nfqwsEl); nfqwsCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (nfqwsBar.dirty() && !confirm('Отменить несохранённые изменения и перечитать файл?')) return; refreshNfqwsOpt(); zm.toast('Содержимое перечитано с диска', 'info'); } }, 'Перечитать файл'), E('button', { 'class': 'cbi-button', 'click': function() { if (nfqwsBar.dirty() && !confirm('Есть несохранённые изменения. Свернуть без сохранения?')) return; nfqwsOpen = false; nfqwsBar.reset(); renderNfqwsCard(); } }, 'Свернуть') ])); nfqwsCard.appendChild(nfqNoBox); nfqwsCard.appendChild(nfqwsBar); } renderNfqNo(); renderNfqwsCard(); if (zapretInstalled) refreshNfqwsOpt().catch(function() {}); refreshers.nfqNo = function() { if (!nfqwsOpen || !nfqwsBar.dirty()) refreshNfqwsOpt().catch(function() {}); }; panels.strategy.appendChild(nfqwsCard); })(); (function buildTestPanel() { var logEl = E('pre', { 'class': 'zm-log' }); var busy = testData.running === true; var curMode = testData.mode || ''; var status = testData; var launchCard = E('div', { 'class': 'zm-card' }); var resCard = E('div', { 'class': 'zm-card' }); var resMode = null, resData = {}, openRows = {}, mainPick = /^(v|flowseal|v_flowseal)$/.test(testData.mode || '') ? testData.mode : 'v_flowseal'; var domInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': 'x.com discord.com rutracker.org', 'style': 'flex:1 1 220px; min-width:0' }); domInput.value = testData.domains || ''; domInput.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') startDomain(); }); var TILES = [ { id: 'current', title: 'Текущая стратегия', sub: 'Проверяет, что стоит сейчас, ничего не меняя: заблокированные сайты и YouTube.', buttons: [ { mode: 'current', label: 'Проверить' } ] }, { id: 'domain', title: 'По домену', sub: 'Все стратегии v и Flowseal на ваших сайтах. Через пробел, до 30 штук.', domain: true }, { id: 'main', title: 'Стратегии v и Flowseal', sub: 'Выберите, что перебирать, и нажмите «Проверить». Проверка идёт по списку заблокированных сайтов.', choice: [ { id: 'v', label: 'v1–v10' }, { id: 'flowseal', label: 'Flowseal' }, { id: 'v_flowseal', label: 'v + Flowseal' } ] }, { id: 'youtube', title: 'YouTube', sub: 'Перебирает стратегии Yv на адресах YouTube.', buttons: [ { mode: 'youtube', label: 'Проверить' } ] } ]; function renderLaunch() { launchCard.innerHTML = ''; launchCard.appendChild(E('h3', {}, 'Тест стратегий')); launchCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Тест идёт в фоне — можно уйти со страницы. После теста настройки Zapret возвращаются как были.')); if (busy) { launchCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show zm-tt-run' }, [ E('span', {}, [ 'Идёт тест: ' + (TEST_MODE_LABELS[curMode] || curMode || '…') + '…' ]), E('button', { 'class': 'cbi-button cbi-button-remove', 'click': doStop }, 'Остановить') ])); } launchCard.appendChild(E('div', { 'class': 'zm-tt-grid' }, TILES.map(function(t) { var ctl; if (t.domain) { ctl = E('div', { 'class': 'zm-tt-ctl' }, [ domInput, E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': startDomain }, 'Проверить') ]); domInput.disabled = busy; } else if (t.choice) { ctl = E('div', { 'class': 'zm-tt-ctl' }, [ E('div', { 'class': 'zm-seg' }, t.choice.map(function(c) { return E('div', { 'class': 'zm-seg-item' + (c.id === mainPick ? ' zm-active' : ''), 'click': function() { if (!busy && c.id !== mainPick) { mainPick = c.id; renderLaunch(); } } }, c.label); })), E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': busy ? '' : null, 'click': function() { doStart(mainPick); } }, 'Проверить') ]); } else { ctl = E('div', { 'class': 'zm-tt-ctl' }, t.buttons.map(function(b, i) { return E('button', { 'class': 'cbi-button' + (i === t.buttons.length - 1 ? ' cbi-button-positive' : ''), 'disabled': busy ? '' : null, 'click': function() { doStart(b.mode); } }, b.label); })); } var run = busy && (curMode === t.id || (t.id === 'main' && /^(v|flowseal|v_flowseal)$/.test(curMode))); return E('div', { 'class': 'zm-tt-tile' + (run ? ' zm-tt-active' : '') }, [ E('div', { 'class': 'zm-tt-head' }, [ E('b', {}, t.title), run ? zm.badge(true, 'идёт', '') : ((t.id === 'main' ? (status.has_results_v || status.has_results_flowseal || status.has_results_v_flowseal) : status['has_results_' + t.id]) ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'есть результат') : E([])) ]), E('p', { 'class': 'zm-hint' }, t.sub), ctl ]); }))); } function startDomain() { var v = domInput.value.trim(); if (!v) { zm.toast('Введите хотя бы один домен', 'warning'); domInput.focus(); return; } doStart('domain:' + v.replace(/[\s,;]+/g, ' ')); } function doStart(mode) { if (busy) { zm.toast('Дождитесь окончания текущего теста', 'warning'); return; } busy = true; curMode = /^domain:/.test(mode) ? 'domain' : mode; renderLaunch(); zm.toast('Запускаем тест…', 'warning'); zm.testAction('start', mode).then(function(res) { if (res && res.error) { busy = false; curMode = ''; zm.toast(res.error, 'error'); renderLaunch(); return; } startPolling(); }).catch(function() { busy = false; curMode = ''; renderLaunch(); zm.toast('Роутер не ответил', 'error'); }); } function doStop() { zm.toast('Останавливаем тест…', 'warning'); zm.testAction('stop').then(function(res) { if (res && res.error) zm.toast(res.error, 'error'); }); } function startPolling() { logEl.classList.add('zm-show'); zm.pollJob('strategy_test', logEl, function(ok) { var done = curMode; busy = false; curMode = ''; zm.toast(ok ? 'Тест завершён' : 'Тест завершился с ошибкой — смотрите журнал', ok ? 'info' : 'error'); zm.testStatus().then(function(res) { status = res; renderLaunch(); delete resData[done]; loadResults(status['has_results_' + done] ? done : resMode); }); }); } function parse(res) { var out = { ts: '', rows: [], control: null }; var doms = []; (res.detail || '').split('\n').forEach(function(l) { if (!l) return; var p = l.split('|'); if (p[0] === 'T') out.ts = p[1]; else if (p[0] === 'D') doms = (p[1] || '').split(' ').filter(Boolean); else if (p[0] === 'S') { var fails = (p[4] || '').split(' ').filter(Boolean), fm = {}; fails.forEach(function(f) { fm[f] = true; }); var row = { name: p[1], ok: +p[2], tot: +p[3], doms: doms.map(function(d) { return { name: d.replace(/_/g, ' '), ok: !fm[d] }; }) }; if (/^Контрольный тест/.test(row.name)) out.control = row; else out.rows.push(row); } }); if (!out.rows.length && res.lines) { res.lines.split('\n').forEach(function(l) { var m = l.match(/^(.*?)\s*→\s*(\d+)\/(\d+)\s*$/); if (!m) return; var row = { name: m[1], ok: +m[2], tot: +m[3], doms: [] }; if (/^Контрольный тест/.test(row.name)) out.control = row; else out.rows.push(row); }); } return out; } function loadResults(mode) { var avail = TEST_RESULT_MODES.filter(function(m) { return status['has_results_' + m]; }); if (!mode || avail.indexOf(mode) < 0) mode = avail[0] || null; resMode = mode; if (!mode) { renderResults(); return; } if (resData[mode]) { renderResults(); return; } renderResults(true); zm.testResults(mode).then(function(res) { resData[mode] = parse(res || {}); openRows = {}; if (mode === 'current' || mode === 'domain') resData[mode].rows.forEach(function(r, i) { if (mode === 'current' || i === 0) openRows[mode + ':' + r.name] = true; }); renderResults(); }).catch(function() { renderResults(); }); } function scoreCls(r, ctrl) { if (r.tot > 0 && r.ok === r.tot) return 'zm-tt-good'; if (ctrl && r.ok > ctrl.ok) return 'zm-tt-mid'; if (!ctrl && r.ok * 2 >= r.tot) return 'zm-tt-mid'; return 'zm-tt-bad'; } function applyStrategy(mode, name) { var call; if (/^v([1-9]|10)$/.test(name)) call = function() { return zm.strategySetV(name); }; else if (mode === 'youtube') call = function() { return zm.strategySetYoutube(name); }; else call = function() { return zm.strategySetFlowseal(name); }; if (!confirm('Применить стратегию ' + name + '?')) return; zm.toast('Применяем ' + name + '…', 'warning'); call().then(function(r) { if (r && r.error) { zm.toast(r.error, 'error'); return; } zm.toast('Стратегия ' + name + ' применена', 'info'); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function chips(list) { var bad = list.filter(function(d) { return !d.ok; }), good = list.filter(function(d) { return d.ok; }); var box = E('div', { 'class': 'zm-tt-doms' }); if (bad.length) box.appendChild(E('div', { 'class': 'zm-tt-domgrp' }, [ E('div', { 'class': 'zm-tt-domhead zm-tt-bad' }, [ 'Не открылись · ' + bad.length ]), E('div', { 'class': 'zm-chips' }, bad.map(function(d) { return E('span', { 'class': 'zm-chip zm-chip-bad' }, [ E('span', { 'class': 'zm-dot' }), d.name ]); })) ])); if (good.length) box.appendChild(E('div', { 'class': 'zm-tt-domgrp' }, [ E('div', { 'class': 'zm-tt-domhead zm-tt-good' }, [ 'Открылись · ' + good.length ]), E('div', { 'class': 'zm-chips' }, good.map(function(d) { return E('span', { 'class': 'zm-chip zm-chip-ok' }, [ E('span', { 'class': 'zm-dot' }), d.name ]); })) ])); return box; } function rowEl(mode, r, rank, ctrl, isCtrl) { var key = mode + ':' + r.name, open = !!openRows[key], pct = r.tot ? Math.round(r.ok * 100 / r.tot) : 0; var cls = isCtrl ? 'zm-tt-ctrl' : scoreCls(r, ctrl); var canApply = !isCtrl && mode !== 'current'; var head = E('div', { 'class': 'zm-tt-row-head', 'click': function() { if (!r.doms.length) return; openRows[key] = !openRows[key]; renderResults(); } }, [ E('span', { 'class': 'zm-tt-rank' }, [ isCtrl ? '—' : String(rank) ]), E('span', { 'class': 'zm-tt-name', 'title': r.name }, [ isCtrl ? 'Без Zapret' : r.name ]), E('span', { 'class': 'zm-tt-bar' }, [ E('span', { 'class': 'zm-tt-fill ' + cls, 'style': 'width:' + pct + '%' }) ]), E('span', { 'class': 'zm-tt-score ' + cls }, [ r.ok + ' / ' + r.tot ]), canApply ? E('button', { 'class': 'cbi-button zm-tt-apply', 'click': function(ev) { ev.stopPropagation(); applyStrategy(mode, r.name); } }, 'Применить') : E('span', { 'class': 'zm-tt-apply-ph' }), E('span', { 'class': 'zm-tt-chev' + (open ? ' zm-tt-open' : '') + (r.doms.length ? '' : ' zm-tt-nochev') }, '›') ]); var body = open && r.doms.length ? chips(r.doms) : null; if (body && canApply) body.appendChild(E('div', { 'class': 'zm-tt-apply-m' }, [ E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { applyStrategy(mode, r.name); } }, [ 'Применить ' + r.name ]) ])); return E('div', { 'class': 'zm-tt-row' + (open ? ' zm-tt-row-open' : '') + (isCtrl ? ' zm-tt-row-ctrl' : '') }, [ head, body || E([]) ]); } function renderResults(loading) { resCard.innerHTML = ''; resCard.appendChild(E('h3', {}, 'Результаты')); var avail = TEST_RESULT_MODES.filter(function(m) { return status['has_results_' + m]; }); if (!avail.length) { resCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Пока пусто — запустите любой тест выше.')); return; } resCard.appendChild(E('div', { 'class': 'zm-tt-tabs' }, [ E('div', { 'class': 'zm-seg' }, avail.map(function(m) { return E('div', { 'class': 'zm-seg-item' + (m === resMode ? ' zm-active' : ''), 'click': function() { if (m !== resMode) loadResults(m); } }, TEST_MODE_LABELS[m] || m); })), E('button', { 'class': 'cbi-button', 'click': function() { if (!confirm('Удалить результаты «' + (TEST_MODE_LABELS[resMode] || resMode) + '»?')) return; zm.testAction('clear', resMode).then(function() { status['has_results_' + resMode] = false; delete resData[resMode]; renderLaunch(); loadResults(null); }); } }, 'Удалить') ])); var d = resData[resMode]; if (loading || !d) { resCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем…')); return; } var rows = d.rows.slice(); if (resMode !== 'current') rows.sort(function(a, b) { return b.ok - a.ok; }); var total = rows.length ? rows[0].tot : (d.control ? d.control.tot : 0); var meta = []; if (d.ts) meta.push('Проверено ' + d.ts); if (resMode !== 'current') meta.push(rows.length + ' ' + (rows.length % 10 === 1 && rows.length % 100 !== 11 ? 'стратегия' : rows.length % 10 >= 2 && rows.length % 10 <= 4 && (rows.length % 100 < 12 || rows.length % 100 > 14) ? 'стратегии' : 'стратегий')); if (total) meta.push(total + ' адр.'); if (resMode === 'domain' && status.domains) meta.push(status.domains); resCard.appendChild(E('p', { 'class': 'zm-hint zm-tt-meta' }, [ meta.join(' · ') ])); var list = E('div', { 'class': 'zm-tt-list' }); if (d.control) list.appendChild(rowEl(resMode, d.control, 0, null, true)); rows.forEach(function(r, i) { list.appendChild(rowEl(resMode, r, i + 1, d.control, false)); }); resCard.appendChild(list); resCard.appendChild(E('p', { 'class': 'zm-hint' }, resMode === 'current' ? 'Нажмите на строку, чтобы скрыть или показать сайты.' : 'Нажмите на строку — покажет, какие сайты открылись. Зелёный — всё открылось, жёлтый — лучше, чем без Zapret, красный — не лучше.')); } renderLaunch(); loadResults(TEST_RESULT_MODES.filter(function(m) { return status['has_results_' + m]; })[0]); if (busy) startPolling(); panels.test.appendChild(launchCard); panels.test.appendChild(logEl); panels.test.appendChild(resCard); })(); (function buildYoutubePanel() { var status = statusData, initial = ytInitialData; var logEl = E('pre', { 'class': 'zm-log' }); var grid = E('div', { 'class': 'zm-grid' }); var lastList = null; var current = ''; var busy = false; function currentYv(s) { var words = (s.strategy || '').split(' '); for (var i = 0; i < words.length; i++) { if (/^Yv[0-9]+$/.test(words[i])) return words[i]; } return ''; } function renderGrid(res) { lastList = res; grid.innerHTML = ''; grid.appendChild(E('div', { 'class': 'zm-tile' + (yvOff && !current ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (yvOff && !current) return; busy = true; zm.toast('Выключаем YouTube-стратегию', 'warning'); zm.strategySetYoutube('off').then(function(r2) { busy = false; if (r2.error) { zm.toast(r2.error, 'error'); return; } zm.toast(r2.removed ? 'YouTube-стратегия выключена' : 'YouTube-стратегии и так не было — теперь она не будет добавляться', 'info'); refreshAll(); }).catch(function() { busy = false; }); } }, yvOff && !current ? 'Выключена' : 'Выключить')); (res.items || []).forEach(function(it) { grid.appendChild(E('div', { 'class': 'zm-tile' + (current === it.id ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (current === it.id) return; busy = true; zm.toast('Применяем стратегию ' + it.id + '', 'warning'); zm.strategySetYoutube(it.id).then(function(r2) { busy = false; if (!zm.notifyStrategyResult(r2, it.id)) return; refreshAll(); }).catch(function() { busy = false; }); } }, [ String(it.id) ])); }); if (!res.items || !res.items.length) grid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список пуст — нажмите «Обновить список».')); } var quicOn = !!status.quic_yt, quicBlocked = false; var quicCard = E('div', { 'class': 'zm-card' }); function renderQuic() { quicCard.innerHTML = ''; quicCard.appendChild(E('h3', {}, 'QUIC для YouTube')); quicCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Браузеры и приложение YouTube часто грузят видео по QUIC (UDP 443), а не по TCP. Этот блок обходит блокировку и для QUIC. Работает вместе с выбранной стратегией.')); quicCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Статус'), zm.badge(quicOn, 'включён', 'выключен') ])); if (quicBlocked) quicCard.appendChild(E('p', { 'class': 'zm-hint' }, quicOn ? 'QUIC сейчас заблокирован на странице «Система» — блок не работает, пока блокировка включена.' : 'QUIC сейчас заблокирован на странице «Система» — YouTube и так идёт по TCP, блок не нужен.')); quicCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': quicOn ? 'cbi-button cbi-button-remove' : 'cbi-button cbi-button-positive', 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast(quicOn ? 'Выключаем QUIC для YouTube' : 'Включаем QUIC для YouTube', 'warning'); zm.youtubeQuicSet(quicOn ? 'off' : 'on').then(function(res) { busy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(res.quic ? 'QUIC для YouTube включён' : 'QUIC для YouTube выключен', 'info'); refreshAll(); }).catch(function() { busy = false; }); } }, quicOn ? 'Выключить' : 'Включить') ])); } function refreshState() { zm.status().then(function(s) { current = currentYv(s); yvOff = !!s.yv_off; quicOn = !!s.quic_yt; if (lastList) renderGrid(lastList); renderQuic(); }); zm.systemStatus().then(function(ss) { quicBlocked = !!(ss && ss.quic_blocked); renderQuic(); }).catch(function() {}); } refreshers.youtube = refreshState; tabHooks.youtube = refreshState; var yvOff = !!status.yv_off; current = currentYv(status); renderQuic(); var card = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Стратегии для YouTube'), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Обновляем список YouTube-стратегий', 'warning'); grid.innerHTML = 'Загрузка списка'; zm.strategyListYoutube('refresh').then(function(res) { if (res.started) { zm.pollJob('youtube_download', logEl, function(ok) { busy = false; zm.toast(ok ? 'Список YouTube-стратегий обновлён' : 'Не удалось обновить список', ok ? 'info' : 'error'); zm.strategyListYoutube().then(renderGrid); }); } else { busy = false; renderGrid(res); zm.toast('Список YouTube-стратегий обновлён', 'info'); } }).catch(function() { busy = false; }); } }, 'Обновить список') ]), grid, E('p', { 'class': 'zm-hint' }, 'Меняет только YouTube-часть текущей стратегии. «Выключить» убирает её совсем — например, если YouTube идёт через ByeTube или Steer.') ]); panels.youtube.appendChild(card); panels.youtube.appendChild(logEl); panels.youtube.appendChild(quicCard); refreshState(); if (!initial.started) { renderGrid(initial); } else { busy = true; grid.appendChild(E('p', { 'class': 'zm-hint' }, 'Загружаем список…')); zm.pollJob('youtube_download', logEl, function(ok) { busy = false; if (!ok) { grid.innerHTML = ''; grid.appendChild(E('p', { 'class': 'zm-hint' }, 'Список не загрузился — нажмите «Обновить список».')); return; } zm.strategyListYoutube().then(renderGrid); }); } })(); (function buildGamePanel() { var data = gameData; var gvGrid = E('div', { 'class': 'zm-grid' }); var xtremeRow = E('div', {}); var fakeGrid = E('div', { 'class': 'zm-grid' }); var busy = false; function renderGv() { gvGrid.innerHTML = ''; var off = !data.active; gvGrid.appendChild(E('div', { 'class': 'zm-tile' + (off ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (off) return; busy = true; zm.toast('Выключаем игровую стратегию', 'warning'); zm.gameSet('off').then(function(res) { busy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Игровая стратегия выключена', 'info'); refreshAll(); }).catch(function() { busy = false; }); } }, off ? 'Выключена' : 'Выключить')); [1, 2, 3, 4].forEach(function(n) { gvGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.active && data.current === ('Gv' + n) ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (data.active && data.current === ('Gv' + n)) return; busy = true; zm.toast('Применяем игровую стратегию Gv' + n + '', 'warning'); zm.gameSet(String(n)).then(function(res) { busy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(res.game === 'none' ? 'Игровая стратегия снята' : res.game + ' применена', 'info'); refreshAll(); }).catch(function() { busy = false; }); } }, 'Gv' + n)); }); } function renderXtreme() { var xtreme = data.xtreme === true; xtremeRow.innerHTML = ''; xtremeRow.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Статус'), zm.badge(xtreme, 'включён', 'выключен') ])); xtremeRow.appendChild(E('p', { 'class': 'zm-hint' }, 'Может мешать работе других приложений. Включайте только для проверки игр.')); xtremeRow.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': xtreme ? 'cbi-button cbi-button-remove' : 'cbi-button cbi-button-positive', 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast(xtreme ? 'Выключаем Xtreme' : 'Включаем Xtreme', 'warning'); zm.gameToggleXtreme().then(function(res) { busy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(res.xtreme ? 'Xtreme включён' : 'Xtreme выключен', 'info'); refreshAll(); }).catch(function() { busy = false; }); } }, xtreme ? 'Выключить Xtreme' : 'Включить Xtreme') ])); } function renderFake() { fakeGrid.innerHTML = ''; GAME_FAKES.forEach(function(f) { fakeGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.fake === f ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Меняем fake-файл на ' + f + '', 'warning'); zm.gameSetFake(f).then(function(res) { busy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(f + ' установлен', 'info'); refreshAll(); }).catch(function() { busy = false; }); } }, f)); }); } function refreshState() { zm.gameStatus().then(function(res) { data = res; renderGv(); renderXtreme(); renderFake(); }); } refreshers.game = refreshState; tabHooks.game = refreshState; renderGv(); renderXtreme(); renderFake(); var gvCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Игровая стратегия'), gvGrid, E('p', { 'class': 'zm-hint' }, '«Выключить» убирает игровую часть из Zapret совсем, вместе с её портами.') ]); var xtremeCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Xtreme режим'), xtremeRow ]); var fakeCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Fake-файл для игровой стратегии'), fakeGrid, E('p', { 'class': 'zm-hint' }, 'Работает, только когда выбрана игровая стратегия.') ]); panels.game.appendChild(gvCard); panels.game.appendChild(xtremeCard); panels.game.appendChild(fakeCard); })(); (function buildDiscordPanel() { var data = discordData; var dvGrid = E('div', { 'class': 'zm-grid' }); var fakeGrid = E('div', { 'class': 'zm-grid' }); var busy = false; function renderDv() { dvGrid.innerHTML = ''; var off = !data.active; dvGrid.appendChild(E('div', { 'class': 'zm-tile' + (off ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (off) return; busy = true; zm.toast('Выключаем стратегию Discord', 'warning'); zm.discordSetDv('off').then(function(res) { busy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(res.removed ? 'Стратегия Discord выключена' : 'Стратегии Discord и так не было — теперь она не будет добавляться', 'info'); refreshAll(); }).catch(function() { busy = false; }); } }, off ? 'Выключена' : 'Выключить')); (data.available || []).forEach(function(dv) { dvGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.active && data.current === dv ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (data.active && data.current === dv) return; busy = true; zm.toast('Применяем стратегию ' + dv + '', 'warning'); zm.discordSetDv(dv.replace('Dv', '')).then(function(res) { busy = false; if (!zm.notifyStrategyResult(res, dv)) return; refreshAll(); }).catch(function() { busy = false; }); } }, dv)); }); } function renderFake() { fakeGrid.innerHTML = ''; GAME_FAKES.forEach(function(f) { fakeGrid.appendChild(E('div', { 'class': 'zm-tile' + (data.active && data.current_fake === f ? ' zm-active' : ''), 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Меняем fake-файл на ' + f + '', 'warning'); zm.discordSetFake(f).then(function(res) { busy = false; if (!zm.notifyStrategyResult(res, f)) return; refreshAll(); }).catch(function() { busy = false; }); } }, f)); }); } function refreshState() { zm.discordStatus().then(function(res) { data = res; renderDv(); renderFake(); }); } refreshers.discord = refreshState; tabHooks.discord = refreshState; renderDv(); renderFake(); var dvCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Стратегия для Discord'), dvGrid, E('p', { 'class': 'zm-hint' }, 'Стратегия Discord — это два блока: голос (discord,stun) и discord.media. «Выключить» убирает оба, и при смене основной стратегии они не вернутся. Любая Dv включает их снова.') ]); var fakeCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Fake-файл для discord,stun'), fakeGrid, E('p', { 'class': 'zm-hint' }, 'Работает, только когда включена стратегия Discord.') ]); panels.discord.appendChild(dvCard); panels.discord.appendChild(fakeCard); })(); (function buildListsPanel() { var LISTS = [ { id: 'exclude', title: 'Список исключений', hint: 'Сайты, которые Zapret не трогает никогда. По одному домену в строке, поддомены входят сами.', restore: true }, { id: 'user', title: 'Список пользовательских хостов', hint: 'Свои сайты, к которым Zapret применяет стратегию. По одному домену в строке.' }, { id: 'google', title: 'Список Google', hint: 'Домены YouTube и Google для YouTube-стратегии и QUIC. Панель сама дописывает сюда нужные домены при выборе стратегии.' } ]; var busy = false; var intro = E('p', { 'class': 'zm-hint zm-lists-intro' }, 'Нажмите на список, чтобы развернуть его. «Сохранить и применить» записывает файл и перезапускает Zapret. Строка #nochange в списке запрещает панели менять его при обновлениях и восстановлении.'); panels.lists.appendChild(intro); function plural(n, a, b, c) { var m = n % 10, h = n % 100; return m === 1 && h !== 11 ? a : (m >= 2 && m <= 4 && (h < 12 || h > 14) ? b : c); } function countText(n) { return n + ' ' + plural(n, 'запись', 'записи', 'записей'); } function countOf(text) { return String(text || '').split('\n').filter(function(l) { l = l.trim(); return l && l.charAt(0) !== '#'; }).length; } LISTS.forEach(function(L) { var st = { open: false, loaded: false, saved: '', info: null }; var ta = E('textarea', { 'class': 'zm-config-editor zm-list-editor', 'spellcheck': 'false', 'wrap': 'off', 'placeholder': 'example.com' }); var countEl = E('span', { 'class': 'zm-badge zm-off zm-list-count' }, '…'); var dirtyEl = E('span', { 'class': 'zm-list-dirty', 'style': 'display:none', 'title': 'Есть несохранённые изменения' }, 'не сохранено'); var chev = E('span', { 'class': 'zm-list-chev', 'aria-hidden': 'true' }, '›'); var body = E('div', { 'class': 'zm-list-body', 'style': 'display:none' }); var btnSave = E('button', { 'class': 'cbi-button cbi-button-positive' }, 'Сохранить и применить'); var btnReload = E('button', { 'class': 'cbi-button' }, 'Перечитать файл'); var lbar = zm.saveBar({ onCancel: function() { ta.value = st.saved; syncCount(); }, onSave: function() { btnSave.click(); } }); var btnRestore = L.restore ? E('button', { 'class': 'cbi-button' }, 'Восстановить исходный') : null; function dirty() { return st.loaded && ta.value !== st.saved; } function syncCount() { var n = st.loaded ? countOf(ta.value) : (st.info ? st.info.count : 0); countEl.textContent = st.info && !st.info.exists && !dirty() ? 'нет файла' : countText(n); countEl.className = 'zm-badge zm-list-count ' + (n ? 'zm-ok' : 'zm-off'); if (typeof renderAutoBox === 'function') renderAutoBox(); if (typeof renderNc === 'function' && ncBox) renderNc(); dirtyEl.style.display = dirty() ? '' : 'none'; btnSave.disabled = !st.loaded; lbar.set(dirty() || (busy && st.open && lbar.isDirty()), busy && lbar.isDirty()); } function load(quiet) { return zm.zapretListGet(L.id).then(function(r) { if (r.error) { zm.toast(r.error, 'error'); syncCount(); return; } st.saved = r.content || ''; if (st.saved && st.saved.slice(-1) !== '\n') st.saved += '\n'; ta.value = st.saved; st.loaded = true; st.info = r.info || st.info; syncCount(); if (!quiet) zm.toast(L.title + ': перечитан из файла', 'info'); }); } function toggle() { if (st.open && dirty() && !confirm('В «' + L.title + '» есть несохранённые изменения. Свернуть и отменить их?')) return; st.open = !st.open; if (!st.open && st.loaded) { ta.value = st.saved; syncCount(); } card.classList.toggle('zm-list-open', st.open); body.style.display = st.open ? '' : 'none'; head.setAttribute('aria-expanded', st.open ? 'true' : 'false'); if (st.open) (st.loaded ? Promise.resolve() : load(true)).then(function() { ta.focus(); }); } ta.addEventListener('input', syncCount); btnSave.addEventListener('click', function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; btnSave.disabled = true; syncCount(); zm.toast('Сохраняем «' + L.title + '» и перезапускаем Zapret', 'warning'); zm.zapretListSet(L.id, ta.value).then(function(r) { busy = false; if (r.error) { zm.toast(r.error, 'error'); syncCount(); return; } st.info = r.info || st.info; return load(true).then(function() { syncCount(); zm.toast(L.title + ' сохранён, Zapret перезапущен', 'info'); }); }).catch(function() { busy = false; syncCount(); zm.toast('Роутер не ответил', 'error'); }); }); btnReload.addEventListener('click', function() { if (dirty() && !confirm('Отменить несохранённые изменения и перечитать «' + L.title + '» из файла?')) return; load(false); }); if (btnRestore) btnRestore.addEventListener('click', function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (!confirm('Заменить список исключений исходным из репозитория Zapret Manager? Ваши правки в нём пропадут.')) return; busy = true; zm.toast('Скачиваем исходный список исключений', 'warning'); zm.zapretListRestore(L.id).then(function(r) { busy = false; if (r.error) { zm.toast(r.error, 'error'); syncCount(); return; } st.saved = r.content || ''; if (st.saved && st.saved.slice(-1) !== '\n') st.saved += '\n'; ta.value = st.saved; st.loaded = true; st.info = r.info || st.info; syncCount(); zm.toast('Список исключений восстановлен, Zapret перезапущен', 'info'); }).catch(function() { busy = false; syncCount(); zm.toast('Роутер не ответил', 'error'); }); }); var head = E('div', { 'class': 'zm-list-head', 'role': 'button', 'tabindex': '0', 'aria-expanded': 'false', 'click': toggle, 'keydown': function(ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggle(); } } }, [ chev, E('div', { 'class': 'zm-list-titles' }, [ E('div', { 'class': 'zm-list-title' }, L.title), E('div', { 'class': 'zm-list-path' }, '/opt/zapret/ipset/' + ({ exclude: 'zapret-hosts-user-exclude.txt', user: 'zapret-hosts-user.txt', google: 'zapret-hosts-google.txt' })[L.id]) ]), dirtyEl, countEl ]); body.appendChild(E('p', { 'class': 'zm-hint zm-list-hint' }, L.hint)); body.appendChild(ta); body.appendChild(E('div', { 'class': 'zm-actions zm-list-actions' }, [ btnReload, btnRestore ])); var ncBox = E('div', { 'class': 'zm-list-nc' }), ncBusy = false; function ncOn() { return st.loaded ? /#\s*nochange/i.test(st.saved) : !!(st.info && st.info.nochange); } function renderNc() { var on = ncOn(); ncBox.innerHTML = ''; ncBox.appendChild(zm.swRow(on, 'Не изменять список', 'Панель не будет трогать этот список при установке, обновлении, смене стратегии' + (L.restore ? ', тесте и автообновлении' : ' и тесте') + '. В файл добавляется строка #nochange.', function() { if (ncBusy || busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } if (dirty()) { zm.toast('Сначала сохраните или отмените изменения в списке', 'warning'); return; } ncBusy = true; renderNc(); zm.zapretListRestore('nochange=' + L.id + ':' + (on ? 'off' : 'on')).then(function(r) { ncBusy = false; if (r && r.info) st.info = r.info; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderNc(); return; } zm.toast(on ? L.title + ': панель снова может его обновлять' : L.title + ': панель больше не будет его менять', 'info'); return load(true); }).catch(function() { ncBusy = false; zm.toast('Роутер не ответил', 'error'); renderNc(); }); }, ncBusy)); } body.appendChild(ncBox); var autoBox = L.restore ? E('div', { 'class': 'zm-list-auto' }) : null, autoBusy = false; function renderAutoBox() { if (!autoBox) return; var cur = (st.info && st.info.auto) || 'off', locked = ncOn(); autoBox.innerHTML = ''; autoBox.appendChild(E('div', { 'class': 'zm-row zm-list-auto-row' }, [ E('span', { 'class': 'zm-label' }, 'Обновлять список'), E('div', { 'class': 'zm-seg' + (autoBusy ? ' zm-seg-busy' : '') }, [ [ 'off', 'Выкл' ], [ 'h2', '2 ч' ], [ 'h4', '4 ч' ], [ 'h6', '6 ч' ], [ 'h12', '12 ч' ] ].map(function(o) { return E('div', { 'class': 'zm-seg-item' + (o[0] === cur ? ' zm-active' : ''), 'click': function() { if (o[0] === cur || autoBusy) return; autoBusy = true; renderAutoBox(); zm.zapretListRestore('auto=' + o[0]).then(function(r) { autoBusy = false; if (!r || r.error) { zm.toast((r && r.error) || 'Роутер не ответил', 'error'); renderAutoBox(); return; } st.info = r.info || st.info; zm.toast(o[0] === 'off' ? 'Автообновление списка исключений выключено' : 'Список исключений будет обновляться каждые ' + o[1], 'info'); renderAutoBox(); }).catch(function() { autoBusy = false; zm.toast('Роутер не ответил', 'error'); renderAutoBox(); }); } }, o[1]); })) ])); autoBox.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:4px 0 0' }, locked && cur !== 'off' ? 'Включено «Не изменять список» — автообновление пропускается.' : 'Панель сама скачивает свежий список из репозитория Zapret Manager и перезапускает Zapret, если он изменился. Свои правки при этом заменяются; чтобы список не трогали, включите «Не изменять список».')); } if (autoBox) { body.appendChild(autoBox); renderAutoBox(); } body.appendChild(lbar); var card = E('div', { 'class': 'zm-card zm-list-card' }, [ head, body ]); panels.lists.appendChild(card); L.refresh = function() { if (st.loaded && !dirty()) return load(true); if (!st.loaded) return zm.zapretListsStatus().then(function(r) { (r.lists || []).forEach(function(i) { if (i.id === L.id) { st.info = i; syncCount(); } }); }); }; syncCount(); }); function refreshAllLists() { zm.zapretListsStatus().then(function(r) { if (r.error) return; LISTS.forEach(function(L) { L.refresh && L.refresh(); }); }); } refreshAllLists(); tabHooks.lists = refreshAllLists; })(); (function buildExclusionsPanel() { var data = exclusionsData; if (data.error) { panels.exclusions.appendChild(E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Исключение устройств'), E('p', { 'class': 'zm-hint' }, data.error) ])); return; } var grid = E('div', { 'class': 'zm-svc-grid' }); var busy = false; function renderGrid(devices) { grid.innerHTML = ''; (devices || []).forEach(function(d) { grid.appendChild(zm.svcCard({ name: d.name || d.ip, sub: d.name ? d.ip : 'без имени', key: d.ip, on: !!d.excluded, 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Переключаем исключение для ' + d.ip + '', 'warning'); zm.exclusionsToggle(d.ip).then(function(res) { busy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(d.ip + (res.excluded ? ' исключён' : ' больше не исключён'), 'info'); zm.exclusionsStatus().then(function(r) { renderGrid(r.devices); }); }).catch(function() { busy = false; }); } })); }); if (!devices || !devices.length) grid.appendChild(E('p', { 'class': 'zm-hint', 'style': 'grid-column: 1 / -1; margin: 0' }, 'Устройства не найдены — нажмите «Обновить список».')); } renderGrid(data.devices); var manualInput = E('input', { 'type': 'text', 'placeholder': '192.168.1.100', 'class': 'cbi-input-text' }); var card = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Исключение устройств из Zapret'), grid, E('p', { 'class': 'zm-hint' }, 'Включённые устройства ходят мимо Zapret.'), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Обновляем список устройств', 'warning'); zm.exclusionsStatus().then(function(res) { busy = false; renderGrid(res.devices); zm.toast('Список устройств обновлён', 'info'); }).catch(function() { busy = false; }); } }, 'Обновить список'), manualInput, E('button', { 'class': 'cbi-button', 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } var ip = manualInput.value.trim(); if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) { zm.toast('Некорректный IPv4 адрес', 'error'); return; } busy = true; zm.toast('Добавляем ' + ip + ' в исключения', 'warning'); zm.exclusionsToggle(ip).then(function(res) { busy = false; if (res.error) { zm.toast(res.error, 'error'); return; } manualInput.value = ''; zm.toast(ip + (res.excluded ? ' добавлен в исключения' : ' убран из исключений'), 'info'); zm.exclusionsStatus().then(function(r) { renderGrid(r.devices); }); }).catch(function() { busy = false; }); } }, 'Добавить вручную'), E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (busy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busy = true; zm.toast('Очищаем все исключения', 'warning'); zm.exclusionsClear().then(function() { busy = false; zm.toast('Все исключения очищены', 'info'); zm.exclusionsStatus().then(function(res) { renderGrid(res.devices); }); }).catch(function() { busy = false; }); } }, 'Очистить все') ]) ]); panels.exclusions.appendChild(card); })(); 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', 'disabled': (d.zapret === 'installed' && !d.expert) ? '' : null, 'click': function() { view.doAction2('install'); } }, 'Установить')); } var z2Block = d.zapret2 !== 'installed' && d.zapret === 'installed' && !d.expert; if (z2Block) { cards.appendChild(zm.blockCard(E('div', { 'class': 'zm-card' }), 'Zapret2', 'Только для архитектуры aarch64_cortex-a53. Несовместим с основным Zapret.', 'Стоит Zapret — он несовместим с Zapret2. Чтобы поставить Zapret2, удалите Zapret.')); return; } 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 (res && res.error) { view.busy2 = false; zm.toast(res.error, 'error'); return; } 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' .zm-wrap { display: flex; flex-direction: column; gap: 16px; max-width: 1100px; } .zm-header { display: flex; align-items: baseline; gap: 10px; margin-bottom: -4px; flex-wrap: wrap; } .zm-header h2 { margin: 0; font-size: 22px; font-weight: 700; } .zm-header-by { font-size: 13px; opacity: .55; } .zm-header-links { display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; } .zm-header-links a { font-size: 12.5px; font-weight: 600; text-decoration: none; color: #229ed9; background: rgba(34,158,217,.1); border: 1px solid rgba(34,158,217,.25); border-radius: 999px; padding: 5px 13px; transition: background .15s; } .zm-header-links a:hover { background: rgba(34,158,217,.18); } .zm-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; } .zm-cards.zm-cards-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .zm-cards.zm-cards-2 > .zm-card { display: flex; flex-direction: column; } .zm-cards.zm-cards-2 > .zm-card > .zm-actions:last-child { margin-top: auto; padding-top: 6px; margin-bottom: 0; } @media (max-width: 720px) { .zm-cards.zm-cards-2 { grid-template-columns: minmax(0, 1fr); } } .zm-card { min-width: 0; box-sizing: border-box; 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-secret { filter: blur(5px); cursor: pointer; user-select: none; -webkit-user-select: none; border-radius: 4px; transition: filter .2s; } .zm-secret:hover { filter: blur(4px); } .zm-secret.zm-secret-open { filter: none; user-select: text; -webkit-user-select: text; } .zm-meters { display: flex; flex-direction: column; gap: 13px; padding-top: 7px; } body:not(.zmw-body) .zm-meter-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; font-size: 13px; margin-bottom: 6px; } body:not(.zmw-body) .zm-meter-label { opacity: .65; } body:not(.zmw-body) .zm-meter-val { font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; } body:not(.zmw-body) .zm-meter-bar { height: 6px; border-radius: 6px; background: rgba(110,118,129,.18); overflow: hidden; } body:not(.zmw-body) .zm-meter-bar i { display: block; height: 100%; border-radius: 6px; background: linear-gradient(90deg, #0969da, #3b8eea); transition: width .6s cubic-bezier(.2,.8,.2,1); } body:not(.zmw-body) .zm-meter-mid .zm-meter-bar i { background: linear-gradient(90deg, #f59e0b, #fbbf24); } body:not(.zmw-body) .zm-meter-hi .zm-meter-bar i { background: linear-gradient(90deg, #ef4444, #f87171); } html.zm-theme-dark body:not(.zmw-body) .zm-meter-bar { background: rgba(255,255,255,.1); } .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-credits-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; } @media (max-width: 720px) { .zm-credits-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 460px) { .zm-credits-grid { grid-template-columns: 1fr; } } .zm-credit-tile { min-width: 0; box-sizing: border-box; border: 1px solid rgba(0,0,0,.1); border-radius: 10px; padding: 12px 14px; text-align: center; background: rgba(0,0,0,.02); } html.zm-theme-dark .zm-credit-tile { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.03); } .zm-credit-tile.zm-credit-self { border-color: rgba(26,127,55,.35); background: rgba(26,127,55,.06); } .zm-credit-product { font-weight: 700; font-size: 13.5px; margin-bottom: 2px; overflow-wrap: anywhere; } .zm-credit-author { font-size: 12px; opacity: .75; margin-bottom: 6px; } .zm-credit-tile a { font-size: 12px; word-break: break-all; overflow-wrap: anywhere; } .zm-credit-tile.zm-credit-all { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } .zm-credit-tile.zm-credit-all .zm-credit-author { margin-bottom: 0; } .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: none; } .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: none; } .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 { scrollbar-width: auto; scrollbar-color: auto; } .zm-log::-webkit-scrollbar { width: 14px; } .zm-log::-webkit-scrollbar-track { background: transparent; margin: 8px 0; } .zm-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,.2); border-radius: 10px; border: 4px solid transparent; background-clip: padding-box; min-height: 40px; } .zm-log::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.34); background-clip: padding-box; } @supports not selector(::-webkit-scrollbar) { .zm-log { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.25) transparent; } } .zm-config-editor { scrollbar-width: auto; scrollbar-color: auto; } .zm-config-editor::-webkit-scrollbar { width: 14px; height: 14px; } .zm-config-editor::-webkit-scrollbar-button { display: none; } .zm-config-editor::-webkit-scrollbar-track, .zm-config-editor::-webkit-scrollbar-corner, .zm-config-editor::-webkit-resizer { background: transparent; } .zm-config-editor::-webkit-scrollbar-track { margin: 8px; } .zm-config-editor::-webkit-scrollbar-thumb { background: rgba(255,255,255,.2); border-radius: 10px; border: 4px solid transparent; background-clip: padding-box; min-height: 40px; } .zm-config-editor::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.34); background-clip: padding-box; } @supports not selector(::-webkit-scrollbar) { .zm-config-editor { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.25) transparent; } } .zm-log:empty::before { content: "Ожидание вывода..."; opacity: .4; } .zm-log-bar { position: sticky; top: 16px; height: 0; z-index: 3; } .zm-log-min { position: absolute; right: -8px; top: -8px; width: 30px; height: 24px; padding: 0; margin: 0; border: 1px solid rgba(255,255,255,.14); border-radius: 7px; background: rgba(255,255,255,.06); color: #c9d1d9; font: 700 13px/1 ui-monospace, Consolas, monospace; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s; } .zm-log-min:hover { background: rgba(255,255,255,.16); color: #fff; } .zm-log.zm-log-collapsed { min-height: 0; max-height: none; overflow: hidden; padding-right: 48px; } .zm-log.zm-log-collapsed > div:not(.zm-log-bar):not(:last-child) { display: none; } .zm-log.zm-log-collapsed > div:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .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-lists-intro { margin: 0 0 12px; } .zm-list-card { padding: 0; margin-bottom: 12px; overflow: hidden; } .zm-list-head { display: flex; align-items: center; gap: 14px; padding: 14px 20px; cursor: pointer; user-select: none; outline: none; transition: background .15s; } @media (hover: hover) { .zm-list-head:hover { background: rgba(127,127,127,.06); } } .zm-list-head:focus-visible { box-shadow: inset 0 0 0 2px rgba(34,158,217,.55); } .zm-list-chev { flex: 0 0 22px; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: rgba(127,127,127,.12); font-size: 16px; font-weight: 700; line-height: 1; transition: transform .2s; } .zm-list-open .zm-list-chev { transform: rotate(90deg); } .zm-list-titles { flex: 1 1 auto; min-width: 0; } .zm-list-title { font-size: 15px; font-weight: 600; line-height: 1.3; } .zm-list-path { margin-top: 3px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; opacity: .6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-list-count { flex: 0 0 auto; min-width: 92px; justify-content: center; } .zm-list-body { padding: 4px 20px 18px; border-top: 1px solid rgba(127,127,127,.15); } .zm-list-hint { margin: 12px 0 10px; } textarea.zm-list-editor { display: block; width: 100%; box-sizing: border-box; min-height: 320px; resize: vertical; white-space: pre; overflow-wrap: normal; overflow-x: auto; tab-size: 4; } .zm-list-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; } .zm-list-actions .cbi-button { margin: 0; } .zm-list-dirty { flex: 0 0 auto; font-size: 12px; white-space: nowrap; font-weight: 600; color: #9a6700; display: inline-flex; align-items: center; gap: 6px; } .zm-list-dirty::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } html.zm-theme-dark .zm-list-dirty { color: #d29922; } .zm-cat { border: 1px solid rgba(127,127,127,.18); border-radius: 12px; margin-top: 10px; overflow: hidden; } .zm-cat .zm-cat-head { padding: 12px 16px; } .zm-cat-sub { margin-top: 2px; font-size: 12px; opacity: .6; } .zm-cat-count { flex: 0 0 auto; } .zm-cat-body { padding: 12px 16px 16px; border-top: 1px solid rgba(127,127,127,.15); } .zm-cat-tools { display: flex; align-items: center; justify-content: flex-end; margin: -4px -6px 8px; } .zm-cat-links { display: flex; gap: 4px; margin-left: auto; } .zm-linkbtn { background: none; border: 0; padding: 6px 10px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600; color: #2563eb; cursor: pointer; } .zm-linkbtn:hover { background: rgba(37,99,235,.08); } .zm-linkbtn[disabled] { opacity: .35; cursor: default; background: none; } html.zm-theme-dark .zm-linkbtn { color: #6ea8fe; } .zm-cat-grid { margin: 0; } .zm-cat-src { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px; padding: 14px 16px; border: 1px dashed rgba(127,127,127,.3); border-radius: 12px; } .zm-cat-src-info { flex: 1 1 260px; min-width: 0; } .zm-cat-src-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; } .zm-cat-src-state { margin-top: 4px; font-size: 13px; opacity: .8; } .zm-cat-src-url { margin-top: 2px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; opacity: .55; overflow-wrap: anywhere; } .zm-cat-src-actions { margin: 0 !important; flex: 0 0 auto; } @media (max-width: 600px) { .zm-cat-tools { justify-content: flex-start; } .zm-cat-src-actions { flex: 1 1 100%; } } @media (max-width: 600px) { .zm-list-head { padding: 12px 14px; gap: 10px; } .zm-list-body { padding: 4px 14px 14px; } .zm-list-count { min-width: 0; } .zm-list-actions .cbi-button { flex: 1 1 100%; } .zm-list-dirty { font-size: 0; gap: 0; } } @media (max-width: 600px) { .zm-config-editor { font-size: 16px; min-height: 320px; } } .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-refresh-banner { display: flex; align-items: center; justify-content: space-between; gap: 14px; background: rgba(191,135,0,.12); border: 2px solid rgba(191,135,0,.35); color: #9a6700; border-radius: 12px; padding: 16px 20px; font-size: 15px; font-weight: 500; margin-top: 12px; } .zm-refresh-banner button { flex-shrink: 0; } #zm-toast-container { position: fixed; top: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 14px; max-width: 520px; overflow: hidden; pointer-events: none; } #zm-toast-container > .zm-toast { pointer-events: auto; flex-shrink: 0; } .zm-toast { display: flex; align-items: flex-start; gap: 14px; background: #1c2128; color: #e6edf3; padding: 24px 28px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,.4); font-size: 18px; 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; width: 10px; height: 10px; border-radius: 50%; background: currentColor; align-self: center; } .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; } html.zm-theme-dark .zm-toast { background: #ffffff; color: #1f2328; box-shadow: 0 12px 44px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.1); } html.zm-theme-dark .zm-toast-info .zm-toast-icon { color: #1a7f37; } html.zm-theme-dark .zm-toast-error .zm-toast-icon { color: #cf222e; } html.zm-theme-dark .zm-toast-warning .zm-toast-icon { color: #9a6700; } .zm-tg-link-card { background: rgba(26,127,55,.06); border: 1px solid rgba(26,127,55,.25); border-radius: 12px; padding: 16px 18px; margin-bottom: 12px; } .zm-tg-link-card h4 { margin: 0 0 8px 0; font-size: 14px; font-weight: 700; } .zm-tg-link-hint { font-size: 12.5px; opacity: .7; margin-bottom: 10px; } .zm-tg-link-box { background: #0d1117; color: #7ee787; font-family: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace; font-size: 14.5px; line-height: 1.6; border-radius: 8px; padding: 12px 14px; white-space: pre-wrap; word-break: break-all; overflow-wrap: anywhere; margin-bottom: 8px; } .zm-tg-link-row { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; } .zm-tg-link-row .zm-tg-link-box { flex: 1 1 220px; min-width: 0; } .zm-tg-qr-btn { white-space: nowrap; } .zm-tg-qr-box { margin-top: 10px; text-align: center; } .zm-tg-qr-box img { max-width: 100%; height: auto; border-radius: 8px; background: #fff; padding: 8px; box-shadow: 0 0 0 1px rgba(0,0,0,.08); box-sizing: border-box; } .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); } .zm-current-banner.zm-current-top { margin: 0 0 10px; } .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-label { flex: 0 0 128px; } } @media (max-width: 420px) { .bt-col .zm-label { flex: 0 0 108px; font-size: 12px; } } .cbi-page-actions { display: none !important; } .zm-ab-panel { display: flex; flex-direction: column; gap: 16px; } .zm-ab-head { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; } .zm-ab-head > .zm-badge { margin-left: auto; } .zm-ab-title { flex: 1 1 260px; min-width: 0; } .zm-ab-title h3 { margin: 2px 0 2px 0; font-size: 17px; } .zm-ab-title .zm-hint { margin-top: 0; } .zm-ab-icon { width: 44px; height: 44px; border-radius: 13px; flex-shrink: 0; display: grid; place-items: center; color: #fff; background: linear-gradient(135deg, #5fd8ff 0%, #1aa3ff 50%, #0a7cff 100%); box-shadow: 0 8px 18px -8px rgba(10,124,255,.8); } .zm-ab-hero .zm-grid { margin-top: 16px; } .zm-ab-go { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; } .zm-ab-go svg { width: 17px; height: 17px; } .zm-ab-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 12.5px; opacity: .8; border-top: 1px dashed rgba(0,0,0,.12); padding-top: 12px; margin-top: 4px; } html.zm-theme-dark .zm-ab-foot { border-top-color: rgba(255,255,255,.12); } .zm-ab-sum b { font-size: 15px; } .zm-ab-sum-ok b { color: #1a7f37; } .zm-ab-note { margin-top: 14px; border-radius: 10px; padding: 11px 14px; font-size: 13px; font-weight: 600; } .zm-ab-note-warn { background: rgba(191,135,0,.12); color: #9a6700; border: 1px solid rgba(191,135,0,.3); } .zm-ab-steps { display: flex; gap: 6px; margin: 18px 0 4px; counter-reset: s; overflow-x: auto; padding-bottom: 2px; } .zm-ab-step { flex: 1 1 0; min-width: 78px; display: flex; flex-direction: column; align-items: center; gap: 6px; position: relative; font-size: 12px; opacity: .55; } .zm-ab-step::before { content: ""; position: absolute; top: 14px; left: calc(-50% + 18px); right: calc(50% + 18px); height: 2px; background: rgba(110,118,129,.3); border-radius: 2px; } .zm-ab-step:first-child::before { display: none; } .zm-ab-step-dot { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 12.5px; background: rgba(110,118,129,.14); color: inherit; } .zm-ab-step-label { white-space: nowrap; font-weight: 600; } .zm-ab-step.zm-ab-done { opacity: 1; } .zm-ab-step.zm-ab-done .zm-ab-step-dot { background: #1a7f37; color: #fff; } .zm-ab-step.zm-ab-done::before, .zm-ab-step.zm-ab-now::before { background: #1a7f37; } .zm-ab-step.zm-ab-now { opacity: 1; } .zm-ab-step.zm-ab-now .zm-ab-step-dot { background: #1aa3ff; color: #fff; animation: zm-ab-pulse 1.6s ease-in-out infinite; } @keyframes zm-ab-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(26,163,255,.55); } 50% { box-shadow: 0 0 0 7px rgba(26,163,255,0); } } .zm-ab-svc { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 9px; margin-top: 10px; } .zm-ab-svc-item .zm-badge { flex-shrink: 0; } .zm-ab-svc-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; border: 1px solid rgba(0,0,0,.08); border-radius: 10px; padding: 10px 12px; background: var(--background-color-low, #fafafa); } html.zm-theme-dark .zm-ab-svc-item { background: #22272e; border-color: rgba(255,255,255,.1); } .zm-ab-svc-item.zm-ab-svc-bad { border-color: rgba(207,34,46,.3); } .zm-ab-svc-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-awg-if { border: 1px solid rgba(0,0,0,.1); border-radius: 12px; padding: 14px 16px; margin-top: 12px; background: var(--background-color-low, #fafafa); } html.zm-theme-dark .zm-awg-if { background: #22272e; border-color: rgba(255,255,255,.1); } .zm-awg-if .zm-actions { margin-top: 10px; } .zm-st-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 16px; } .zm-st-stat { display: flex; flex-direction: column; gap: 4px; min-width: 0; padding: 11px 14px; border-radius: 11px; border: 1px solid rgba(0,0,0,.08); background: var(--background-color-low, #fafafa); } html.zm-theme-dark .zm-st-stat { background: #22272e; border-color: rgba(255,255,255,.1); } .zm-st-stat-label { font-size: 11.5px; opacity: .65; text-transform: uppercase; letter-spacing: .04em; } .zm-st-stat-value { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-st-ok { color: #1a7f37; } .zm-st-warn { color: #9a6700; } .zm-st-bad { color: #cf222e; } .zm-st-off { opacity: .6; } .zm-st-promo { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 16px; } .zm-st-promo > div { display: flex; flex-direction: column; gap: 3px; padding: 12px 14px; border-radius: 11px; background: rgba(26,163,255,.07); border: 1px solid rgba(26,163,255,.2); } .zm-st-promo b { font-size: 14px; } .zm-st-promo span { font-size: 12px; opacity: .7; } .zm-svc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; margin-top: 12px; } .zm-svc { display: flex; align-items: center; gap: 11px; min-width: 0; padding: 10px 12px; border-radius: 12px; cursor: pointer; user-select: none; border: 1px solid rgba(0,0,0,.1); background: var(--background-color-low, #fafafa); transition: border-color .15s, background .15s, transform .1s; } html.zm-theme-dark .zm-svc { background: #22272e; border-color: rgba(255,255,255,.1); } .zm-svc:hover { transform: translateY(-1px); border-color: #1aa3ff; } .zm-svc.zm-svc-on { border-color: rgba(26,163,255,.6); background: rgba(26,163,255,.08); } .zm-svc-ico { width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0; display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 12.5px; letter-spacing: -.02em; } .zm-svc-text { display: flex; flex-direction: column; min-width: 0; flex: 1; } .zm-svc-name { font-weight: 700; font-size: 13.5px; } .zm-svc-sub { font-size: 11.5px; opacity: .6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-switch { width: 38px; height: 22px; border-radius: 999px; background: rgba(110,118,129,.35); position: relative; flex-shrink: 0; transition: background .15s; } .zm-switch > span { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: left .15s; } .zm-switch.zm-switch-on { background: #1aa3ff; } .zm-switch.zm-switch-on > span { left: 19px; } .zm-st-apply .zm-hint { margin: 0; } .zm-st-note-ok { background: rgba(26,127,55,.1); color: #1a7f37; border: 1px solid rgba(26,127,55,.25); } .zm-st-note-bad { background: rgba(207,34,46,.08); color: #cf222e; border: 1px solid rgba(207,34,46,.25); } .zm-st-checks { display: flex; flex-direction: column; gap: 7px; margin-top: 12px; } .zm-st-check { display: flex; gap: 10px; align-items: flex-start; font-size: 13px; } .zm-st-check > span:last-child { display: flex; flex-direction: column; min-width: 0; } .zm-st-check-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; } .zm-st-check-ok .zm-st-check-dot { background: #1a7f37; } .zm-st-check-warn .zm-st-check-dot { background: #bf8700; } .zm-st-check-fail .zm-st-check-dot { background: #cf222e; } .zm-st-check-why { font-size: 12px; opacity: .65; } @media (max-width: 600px) { .zm-st-stats, .zm-st-promo { grid-template-columns: 1fr; } .zm-svc-grid { grid-template-columns: 1fr; } } .zm-ab-player video { display: block; width: 100%; max-height: 72vh; aspect-ratio: 16 / 9; object-fit: contain; border-radius: 12px; background: #000; } .zm-ab-player .zm-actions { margin-bottom: 0; } .zm-ab-logcard .zm-log { margin-top: 0; } @media (max-width: 600px) { .zm-ab-title { flex-basis: calc(100% - 58px); } .zm-ab-head > .zm-badge { margin-left: 58px; } .zm-ab-step { min-width: 40px; } .zm-ab-step:not(.zm-ab-now) .zm-ab-step-label { visibility: hidden; } .zm-ab-step-label { font-size: 11.5px; } .zm-ab-svc { grid-template-columns: 1fr; } } .zm-st-tunnels { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin: 10px 0 14px; } .zm-st-tunnel { border: 1px solid rgba(127,127,127,.25); border-radius: 12px; padding: 10px 14px; } .zm-st-tunnel-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; } .zm-st-tunnel-name { font-weight: 600; } .zm-nodes { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(180px, 100%), 1fr)); gap: 10px; margin-top: 6px; } .zm-nodes > .zm-node { min-width: 0; } .zm-node { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 64px; padding: 10px 12px; border-radius: 10px; cursor: pointer; box-sizing: border-box; border: 1px solid rgba(0,0,0,.1); background: var(--background-color-low, #fafafa); transition: border-color .15s, background .15s, transform .1s, box-shadow .15s; } html.zm-theme-dark .zm-node:not(.zm-active) { background: #22272e; border-color: rgba(255,255,255,.12); } .zm-node:hover { border-color: #1a7f37; transform: translateY(-1px); } .zm-node.zm-active { border-color: #1a7f37; background: rgba(26,127,55,.12); box-shadow: 0 0 0 2px rgba(26,127,55,.3); } .zm-node.zm-node-dead { opacity: .6; } .zm-node-name { font-weight: 600; font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; } .zm-node.zm-active .zm-node-name::before { content: none; } .zm-node.zm-sec .zm-node-name::before { content: none; } .zm-tabsplit { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 40px; margin: 4px 0 0; } .zm-tabsplit > .zm-tabgrp { margin: 0; } .zm-sec-dim .zm-nodes { opacity: .45; filter: grayscale(.6); transition: opacity .15s, filter .15s; } .zm-sec-dim .zm-nodes:hover { opacity: .85; filter: none; } .zm-node.zm-sec-add { border-style: dashed; display: flex; align-items: center; justify-content: center; text-align: center; min-height: 64px; box-sizing: border-box; } .zm-node.zm-sec-add:not(:hover) { opacity: .85; } .zm-sec-add-text { font-weight: 600; font-size: 13px; line-height: 1.35; color: #1a7f37; } html.zm-theme-dark .zm-sec-add-text { color: #3fb950; } .zm-node-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 11.5px; } .zm-node-foot > span:first-child { opacity: .65; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .zm-node-foot > span:last-child { flex-shrink: 0; } .zm-lat { font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums; } .zm-lat-good { color: #1a7f37; } .zm-lat-mid { color: #9a6700; } .zm-lat-bad { color: #cf222e; } .zm-lat-none { opacity: .45; } .zm-sub-meta { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; padding: 12px 16px; margin: 4px 0 10px; border-radius: 10px; background: rgba(26,127,55,.06); border: 1px solid rgba(26,127,55,.18); } .zm-sub-title { font-weight: 700; font-size: 15px; overflow-wrap: anywhere; } .zm-sub-facts { display: flex; flex-wrap: wrap; gap: 12px 24px; } .zm-sub-fact { display: flex; flex-direction: column; gap: 3px; font-size: 12px; } .zm-sub-fact > span { opacity: .6; } .zm-sub-fact > b { font-size: 13px; font-variant-numeric: tabular-nums; } .zm-quota { width: 140px; height: 5px; border-radius: 99px; background: rgba(0,0,0,.1); overflow: hidden; } .zm-quota > i { display: block; height: 100%; border-radius: 99px; background: #1a7f37; } .zm-quota.zm-quota-high > i { background: #cf222e; } .zm-seg { display: inline-flex; padding: 3px; gap: 3px; border-radius: 9px; border: 1px solid rgba(0,0,0,.1); background: var(--background-color-low, #fafafa); } html.zm-theme-dark .zm-seg { background: #22272e; border-color: rgba(255,255,255,.12); } .zm-seg-item { padding: 5px 14px; border-radius: 7px; cursor: pointer; font-size: 13px; font-weight: 600; opacity: .7; transition: background .15s, opacity .15s; } .zm-seg-item:hover { opacity: 1; } .zm-seg-item.zm-active { opacity: 1; background: #1a7f37; color: #fff; cursor: default; } .zm-sub-input { min-height: 72px !important; } .zm-svc { min-height: 58px; box-sizing: border-box; } .zm-svc-text { overflow: hidden; } .zm-svc-name { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; line-height: 1.25; overflow-wrap: anywhere; } .zm-svc-text:has(.zm-svc-sub) .zm-svc-name { -webkit-line-clamp: 1; line-clamp: 1; } .zm-svc-grid + .zm-hint, .zm-svc-groupwrap + .zm-hint { margin-top: 12px; } .zm-svc-groupwrap { margin-top: 12px; } .zm-svc-groupwrap > div:last-child > .zm-svc-grid { margin-top: 10px; } @media (max-width: 600px) { .zm-svc-grid { gap: 8px; } .zm-svc { min-height: 50px; padding: 8px 10px; gap: 10px; } .zm-svc-ico { width: 30px; height: 30px; border-radius: 9px; font-size: 11.5px; } } .zm-svc-group { width: 100%; } .zm-svc-chev { display: inline-block; font-size: 20px; line-height: 1; opacity: .55; transition: transform .15s; } .zm-svc-open .zm-svc-chev { transform: rotate(90deg); } .zm-st-stats.zm-st-stats-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .zm-st-stats.zm-fk-secs { grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); } .zm-fk-sec { cursor: pointer; transition: border-color .15s, transform .1s; } .zm-fk-sec:hover { border-color: #1aa3ff; transform: translateY(-1px); } .zm-fk-sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; } .zm-fk-sec-head > .zm-st-stat-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .zm-fk-sec-head > .zm-badge { flex-shrink: 0; padding: 2px 9px; font-size: 11px; } .zm-fk-sec-head > .zm-st-stat-sub { flex-shrink: 0; } .zm-fk-common { border-style: dashed; } .zm-st-stat-sub { font-size: 11.5px; opacity: .6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @media (max-width: 1000px) { .zm-st-stats.zm-st-stats-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 600px) { .zm-st-stat-sub { white-space: normal; } } @media (max-width: 340px) { .zm-st-stats.zm-st-stats-4 { grid-template-columns: 1fr; } } .zm-kv .zm-row > .zm-label:first-child { min-width: 120px; } @media (max-width: 600px) { .zm-kv .zm-row > .zm-label:first-child { min-width: 0; width: 100%; } } .zm-nodes.zm-nodes-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } @media (max-width: 600px) { .zm-nodes.zm-nodes-3 { grid-template-columns: 1fr; } } @media (max-width: 600px) { .zm-seg { display: flex; flex-wrap: wrap; max-width: 100%; box-sizing: border-box; } .zm-seg-item { white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; box-sizing: border-box; } } .zm-log-wait { color: #e3c04a; font-weight: 600; white-space: pre-wrap; } .zm-si-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; margin-top: 14px; } .zm-si-box { border: 1px solid rgba(0,0,0,.1); border-radius: 12px; padding: 12px 14px; background: rgba(0,0,0,.02); min-width: 0; box-sizing: border-box; } div > .zm-si-box { margin-top: 14px; } .zm-si-grid > .zm-si-box { margin-top: 0; } html.zm-theme-dark .zm-si-box { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.03); } .zm-si-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; } .zm-si-head h4 { margin: 0; font-size: 14px; } .zm-si-box .zm-row { padding: 5px 0; gap: 12px; } .zm-si-box .zm-row > .zm-label { min-width: 150px; } .zm-si-val { overflow-wrap: anywhere; } @media (max-width: 600px) { .zm-si-box .zm-row > .zm-label { min-width: 110px; } } .zm-si-pre { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 12px; max-height: 260px; overflow: auto; } .zm-chips { display: flex; flex-wrap: wrap; gap: 6px; } .zm-chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; background: rgba(110,118,129,.14); } .zm-chip .zm-dot { width: 7px; height: 7px; background: currentColor; } .zm-chip-ok { background: rgba(46,160,67,.12); color: #1a7f37; } .zm-chip-bad { background: rgba(207,34,46,.10); color: #cf222e; } .zm-tt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } @media (max-width: 800px) { .zm-tt-grid { grid-template-columns: 1fr; } } .zm-tt-tile { border: 1px solid rgba(0,0,0,.1); border-radius: 12px; padding: 12px 14px; background: rgba(0,0,0,.02); display: flex; flex-direction: column; gap: 6px; min-width: 0; } html.zm-theme-dark .zm-tt-tile { border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.03); } .zm-tt-tile.zm-tt-active { border-color: #2d5bff; box-shadow: 0 0 0 1px #2d5bff inset; } .zm-tt-tile .zm-hint { margin: 0; flex: 1; } .zm-tt-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .zm-tt-ctl { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 4px; } .zm-tt-run { margin-bottom: 12px; } .zm-tt-tabs { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } .zm-tt-tabs .zm-seg { flex-wrap: wrap; } .zm-tt-meta { margin: 10px 0 8px; } .zm-tt-best { padding: 9px 12px; border-radius: 10px; margin-bottom: 10px; font-size: 13px; } .zm-tt-best.zm-tt-good { background: rgba(46,160,67,.12); } .zm-tt-best.zm-tt-mid { background: rgba(191,135,0,.12); } .zm-tt-best.zm-tt-bad { background: rgba(207,34,46,.10); } .zm-tt-list { display: flex; flex-direction: column; gap: 6px; } .zm-tt-row { border: 1px solid rgba(0,0,0,.08); border-radius: 10px; background: rgba(0,0,0,.015); overflow: hidden; } html.zm-theme-dark .zm-tt-row { border-color: rgba(255,255,255,.1); background: rgba(255,255,255,.025); } .zm-tt-row-ctrl { border-style: dashed; } .zm-tt-row-head { display: grid; grid-template-columns: 28px minmax(0, 1.3fr) minmax(60px, 1fr) 64px 100px 18px; align-items: center; gap: 10px; padding: 7px 10px; cursor: pointer; } .zm-tt-row-head:hover { background: rgba(45,91,255,.06); } .zm-tt-rank { font-size: 12px; opacity: .55; text-align: right; font-variant-numeric: tabular-nums; } .zm-tt-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-tt-bar { height: 7px; border-radius: 99px; background: rgba(110,118,129,.18); overflow: hidden; } .zm-tt-fill { display: block; height: 100%; border-radius: 99px; } .zm-tt-fill.zm-tt-good { background: #2ea043; } .zm-tt-fill.zm-tt-mid { background: #d4a72c; } .zm-tt-fill.zm-tt-bad { background: #e5534b; } .zm-tt-fill.zm-tt-ctrl { background: #8b949e; } .zm-tt-score { font-weight: 700; font-size: 13px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .zm-tt-score.zm-tt-good, .zm-tt-domhead.zm-tt-good { color: #1a7f37; } .zm-tt-score.zm-tt-mid { color: #9a6700; } .zm-tt-score.zm-tt-bad, .zm-tt-domhead.zm-tt-bad { color: #cf222e; } .zm-tt-score.zm-tt-ctrl { opacity: .7; } .zm-tt-apply { padding: 3px 10px !important; font-size: 12px !important; min-height: 0 !important; } .zm-tt-apply { justify-self: end; } .zm-tt-apply-m { display: none; } .zm-tt-chev { font-size: 18px; opacity: .5; transition: transform .15s; text-align: center; } .zm-tt-chev.zm-tt-open { transform: rotate(90deg); } .zm-tt-chev.zm-tt-nochev { visibility: hidden; } .zm-tt-doms { padding: 4px 12px 12px 48px; display: flex; flex-direction: column; gap: 10px; } .zm-tt-domhead { font-size: 12px; font-weight: 700; margin-bottom: 6px; } @media (max-width: 600px) { .zm-tt-row-head { grid-template-columns: 22px minmax(0, 1fr) 56px 16px; } .zm-tt-bar, .zm-tt-apply, .zm-tt-apply-ph { display: none; } .zm-tt-doms { padding-left: 12px; } .zm-tt-apply-m { display: block; } } .zm-alerts { display: flex; flex-direction: column; gap: 10px; } .zm-alerts:empty { display: none; } .zm-wrap > .zm-alerts { margin-bottom: 2px; }.zm-log-step { color: #a5b4c3; white-space: pre-wrap; } .zm-log-step-ok { color: #3fb950; } .zm-log-step-bad { color: #ff7b72; font-weight: 600; } .zm-savebar { position: sticky; bottom: 12px; z-index: 6; display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px; margin-top: 16px; padding: 12px 14px 12px 16px; border-radius: 14px; background: #fff; border: 1px solid rgba(26,163,255,.45); box-shadow: 0 14px 34px -14px rgba(0,0,0,.35); animation: zm-savebar-in .18s ease-out; } html.zm-theme-dark .zm-savebar { background: #22272e; border-color: rgba(26,163,255,.5); } .zm-savebar-dot { width: 9px; height: 9px; border-radius: 50%; background: #f59e0b; box-shadow: 0 0 0 4px rgba(245,158,11,.18); flex-shrink: 0; } .zm-savebar-text { flex: 1 1 200px; font-weight: 600; font-size: 13.5px; } .zm-savebar-btns { display: flex; gap: 8px; flex-wrap: wrap; margin-left: auto; } .zm-savebar-btns .cbi-button { margin: 0; } @keyframes zm-savebar-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } .zm-stopbar { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 10000; display: flex; align-items: center; gap: 16px; width: min(720px, calc(100vw - 32px)); box-sizing: border-box; padding: 14px 16px 14px 18px; border-radius: 16px; background: #fff5f5; border: 1px solid rgba(220,38,38,.45); color: #7f1d1d; box-shadow: 0 18px 40px -16px rgba(220,38,38,.55); animation: zm-savebar-in .2s ease-out; } html.zm-theme-dark .zm-stopbar { background: #2a1416; color: #fecaca; border-color: rgba(248,113,113,.5); } .zm-stopbar-text { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; font-size: 13px; line-height: 1.35; } .zm-stopbar-text b { font-size: 14px; } .zm-stopbar-btn, .zm-stopbar .cbi-button.zm-stopbar-btn { flex-shrink: 0; background: #dc2626 !important; border-color: #dc2626 !important; color: #fff !important; font-weight: 700; margin: 0; } .zm-stopbar-btn:hover { background: #b91c1c !important; } @media (max-width: 600px) { .zm-stopbar { flex-direction: column; align-items: stretch; } } .zm-svc.zm-svc-inc { opacity: .78; border-style: dashed; } .zm-svc.zm-svc-taken { opacity: .5; cursor: not-allowed; } .zm-switch.zm-switch-inc { opacity: .55; } .zm-ri-note { margin-top: 12px; padding: 10px 14px; border-radius: 12px; background: rgba(26,163,255,.08); border: 1px solid rgba(26,163,255,.3); font-size: 13px; line-height: 1.45; } .zm-swrow { display: flex; align-items: flex-start; gap: 12px; margin: 12px 0; cursor: pointer; } .zm-swrow .zm-switch { margin-top: 1px; } .zm-swrow-label { font-weight: 600; } .zm-swrow .zm-hint { margin: 2px 0 0; } .zm-swrow.zm-swrow-off { opacity: .5; cursor: not-allowed; } .zm-block-banner { margin-top: 14px; } .zm-sb-left, .zm-lists-now { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 14px; padding: 12px 14px; border-radius: 12px; border: 1px solid rgba(110,118,129,.25); background: rgba(110,118,129,.06); } .zm-sb-left { border-color: rgba(207,34,46,.3); background: rgba(207,34,46,.05); } .zm-sb-left-text, .zm-lists-now-text { display: flex; flex-direction: column; gap: 4px; flex: 1 1 260px; min-width: 0; } .zm-sb-left .cbi-button, .zm-lists-now .cbi-button { margin: 0; flex-shrink: 0; } .zm-dock { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); z-index: 9999; width: min(860px, calc(100vw - 32px)); display: none; flex-direction: column; gap: 10px; pointer-events: none; } .zm-dock.zm-dock-on { display: flex; } .zm-dock > * { pointer-events: auto; } .zm-dock .zm-savebar { position: static; margin: 0; box-shadow: 0 18px 44px -14px rgba(0,0,0,.45); } .zm-dock .zm-stopbar { position: static; left: auto; bottom: auto; transform: none; width: auto; } .zm-dns-order { display: flex; flex-direction: column; gap: 8px; } .zm-dns-item { display: flex; align-items: center; gap: 12px; padding: 9px 10px 9px 12px; border-radius: 12px; border: 1px solid rgba(110,118,129,.25); background: rgba(110,118,129,.05); flex-wrap: wrap; } .zm-dns-item.zm-dns-active { border-color: rgba(26,127,55,.5); background: rgba(26,127,55,.08); } .zm-dns-num { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 13px; background: #1aa3ff; color: #fff; flex-shrink: 0; } .zm-dns-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 200px; } .zm-dns-text span { font-size: 11.5px; opacity: .65; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-dns-btns { display: flex; gap: 4px; margin-left: auto; } .zm-dns-btns .cbi-button { margin: 0; min-width: 34px; padding: 4px 8px; } .zm-hide { margin-top: 12px; } .zm-hide-bar { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; } .zm-hide-grid .zm-node.zm-hide-on { opacity: .62; border-style: dashed; border-color: #cf222e; background: rgba(207,34,46,.07); } .zm-hide-grid .zm-node.zm-hide-on .zm-node-name { text-decoration: line-through; } .zm-hide-grid .zm-node.zm-hide-lock { opacity: .45; cursor: not-allowed; } .zm-hide-grid .zm-hide-mark { font-weight: 700; opacity: .8; } .zm-hide-grid .zm-node.zm-hide-on .zm-hide-mark { color: #cf222e; opacity: 1; } .zm-hide-box { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; padding: 12px 14px; border-radius: 12px; border: 1px dashed rgba(154,103,0,.55); background: rgba(154,103,0,.06); } .zm-hide-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .zm-hide-chips { display: flex; flex-wrap: wrap; gap: 6px; } .zm-hide-chip { padding: 3px 10px; border-radius: 999px; font-size: 12px; background: rgba(127,127,127,.14); text-decoration: line-through; opacity: .85; overflow-wrap: anywhere; } .zm-hide-chip.zm-hide-more { text-decoration: none; } .zm-reboot-ov { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(10,12,18,.55); backdrop-filter: blur(3px); } .zm-reboot-box { display: flex; flex-direction: column; align-items: center; gap: 10px; max-width: 360px; padding: 26px 24px; border-radius: 16px; text-align: center; background: var(--surface-solid, var(--surface, #fff)); color: var(--text, #1f2328); box-shadow: 0 20px 60px -20px rgba(0,0,0,.5); } .zm-reboot-box b { font-size: 16px; } .zm-reboot-banner { gap: 10px 14px; flex-wrap: wrap; } .zm-links { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 10px; } .zm-link-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 12px; border-radius: 10px; border: 1px solid rgba(127,127,127,.25); background: rgba(127,127,127,.04); min-width: 0; } .zm-link-row.zm-link-bad { border-color: rgba(207,34,46,.5); } .zm-link-num { flex-shrink: 0; min-width: 22px; height: 22px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; background: rgba(127,127,127,.15); } .zm-link-main { flex: 1; min-width: 0; } .zm-link-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-link-sub { font-size: 12px; opacity: .65; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-link-btns { display: flex; gap: 6px; flex-shrink: 0; } .zm-link-btns .cbi-button { padding: 4px 10px; min-width: 0; } .zm-links-ed .zm-config-editor { margin: 0; } .zm-links-ed .zm-links-tools { margin: 8px 0 0; } .zm-links-add-label { font-size: 13px; font-weight: 600; margin: 4px 0 6px; } @media (max-width: 600px) { .zm-link-row { flex-wrap: wrap; } .zm-link-main { flex: 1 1 calc(100% - 40px); } .zm-link-btns { margin-left: 32px; } .zm-link-edit input { flex-basis: calc(100% - 40px) !important; } } .zm-seg-busy { opacity: .6; pointer-events: none; } .zm-list-auto { margin: 12px 0 4px; } .zm-list-nc { margin: 12px 0 0; } .zm-list-auto-row { margin: 0; } @media (max-width: 600px) { .zm-list-auto-row { flex-wrap: wrap; } .zm-list-auto-row .zm-label { flex: 0 0 100%; } .zm-list-auto-row .zm-seg { flex-wrap: nowrap; width: 100%; } .zm-list-auto-row .zm-seg-item { flex: 1 1 0; padding: 6px 2px; text-align: center; } } .zm-tg-auto-row .zm-label { flex: 0 0 130px; } @media (max-width: 600px) { .zm-tg-auto-row { flex-wrap: wrap; } .zm-tg-auto-row .zm-label { flex: 0 0 100%; } .zm-tg-auto-row .zm-seg { flex-wrap: nowrap; width: 100%; } .zm-tg-auto-row .zm-seg-item { flex: 1 1 0; padding: 6px 2px; text-align: center; } } .zm-route { margin: 4px 0 2px; } .zm-route-form { display: flex; gap: 8px; align-items: stretch; } .zm-route-form .zm-route-input { flex: 1; min-width: 0; width: auto !important; } .zm-route-form .cbi-button { flex-shrink: 0; } .zm-route-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; } .zm-route-chip { padding: 3px 10px; border-radius: 999px; border: 1px solid rgba(127,127,127,.35); background: transparent; color: inherit; font: inherit; font-size: 12px; line-height: 1.4; cursor: pointer; } .zm-route-chip:hover { border-color: rgba(127,127,127,.65); } .zm-route-out:not(:empty) { margin-top: 10px; } .zm-route-wait { margin: 0; } .zm-route-res { padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(127,127,127,.25); border-left-width: 4px; background: rgba(127,127,127,.04); min-width: 0; } .zm-route-ok { border-left-color: #2ea043; } .zm-route-warn { border-left-color: #d29922; } .zm-route-bad { border-left-color: #cf222e; } .zm-route-off { border-left-color: #6e7681; } .zm-route-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; } .zm-route-what { min-width: 0; overflow-wrap: anywhere; } .zm-route-line { display: flex; gap: 10px; margin-top: 6px; font-size: 13px; min-width: 0; } .zm-route-k { flex-shrink: 0; width: 120px; opacity: .65; } .zm-route-v { min-width: 0; overflow-wrap: anywhere; } .zm-route-note { margin: 8px 0 0; } .zm-route-raw { margin-top: 8px; font-size: 12px; } .zm-route-raw summary { cursor: pointer; opacity: .75; } .zm-route-raw pre { margin: 6px 0 0; max-height: 220px; white-space: pre-wrap; overflow-wrap: anywhere; } @media (max-width: 600px) { .zm-route-line { flex-direction: column; gap: 0; } .zm-route-k { width: auto; } } .zm-hide-quick { margin: 0 0 12px; padding: 10px 12px; border-radius: 12px; border: 1px solid rgba(127,127,127,.25); background: rgba(127,127,127,.05); } .zm-hide-qlist { display: flex; flex-wrap: wrap; gap: 6px; } .zm-hide-q { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid rgba(127,127,127,.35); background: transparent; color: inherit; font: inherit; font-size: 13px; line-height: 1.3; cursor: pointer; } .zm-hide-q small { font-size: 11px; opacity: .65; } .zm-hide-q:hover { border-color: #cf222e; } .zm-hide-q.zm-hide-q-on { border-color: #cf222e; background: rgba(207,34,46,.1); color: #cf222e; text-decoration: line-through; } .zm-hide-q.zm-hide-q-on small { text-decoration: none; } .zm-hide-q:disabled { opacity: .5; cursor: default; } ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/style.css' 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/system.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var MIRRORS = [ { id: 'infra', label: 'Infra OpenWrt' }, { id: 'brazil', label: 'Brazil' }, { id: 'china', label: 'China' }, { id: 'france', label: 'France' }, { id: 'italy', label: 'Italy' }, { id: 'morocco', label: 'Morocco' }, { id: 'usa', label: 'USA' }, { id: 'germany_rwth', label: 'Germany' }, { id: 'default', label: 'default / OpenWrt' } ]; return view.extend({ load: function() { zm.injectCss(); return Promise.all([ zm.systemStatus(), zm.mirrorStatus(), zm.sysinfoGet().catch(function() { return {}; }) ]); }, render: function(data) { var sysData = data[0], mirrorData = data[1]; var view = this; var wrap = E('div', { 'class': 'zm-wrap' }); var netEl = E('div', {}, [ E('p', { 'class': 'zm-hint' }, 'Нажмите «Проверить», чтобы протестировать IPv4/IPv6') ]); var toggleBusy = false; function renderStatusCard(d) { var card = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Система'), E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Блокировка QUIC'), zm.badge(d.quic_blocked === true, 'включена', 'выключена') ]), E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'IPv6 в Zapret'), zm.badge(d.ipv6_enabled === true, 'включён', 'выключен') ]), E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Fix для Flow Offloading'), zm.badge(d.flow_offloading_fix === true, 'применён', 'не применён') ]), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': d.quic_blocked ? 'cbi-button cbi-button-remove' : 'cbi-button', 'click': function() { if (toggleBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } toggleBusy = true; zm.toast(d.quic_blocked ? 'Выключаем блокировку QUIC' : 'Включаем блокировку QUIC', 'warning'); zm.systemToggleQuic().then(function(res) { toggleBusy = false; zm.toast(d.quic_blocked ? 'Блокировка QUIC выключена' : 'Блокировка QUIC включена', 'info'); zm.systemStatus().then(refresh); }).catch(function() { toggleBusy = false; }); } }, d.quic_blocked ? 'Выключить блокировку QUIC' : 'Включить блокировку QUIC'), E('button', { 'class': 'cbi-button', 'click': function() { if (toggleBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } toggleBusy = true; zm.toast(d.ipv6_enabled ? 'Выключаем IPv6 в Zapret' : 'Включаем IPv6 в Zapret', 'warning'); zm.systemToggleIpv6().then(function(res) { toggleBusy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(res.ipv6_enabled ? 'IPv6 в Zapret включён' : 'IPv6 в Zapret выключен', 'info'); zm.systemStatus().then(refresh); }).catch(function() { toggleBusy = false; }); } }, d.ipv6_enabled ? 'Выключить IPv6 в Zapret' : 'Включить IPv6 в Zapret'), E('button', { 'class': 'cbi-button', 'click': function() { if (toggleBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } toggleBusy = true; zm.toast(d.flow_offloading_fix ? 'Отключаем Fix Flow Offloading' : 'Применяем Fix Flow Offloading', 'warning'); zm.systemToggleFlowOffloadingFix().then(function(res) { toggleBusy = false; if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast(res.flow_offloading_fix ? 'Fix для Flow Offloading применён' : 'Fix для Flow Offloading отключён', 'info'); zm.systemStatus().then(refresh); }).catch(function() { toggleBusy = false; }); } }, d.flow_offloading_fix ? 'Отключить Fix Flow Offloading' : 'Применить Fix Flow Offloading') ]) ]); return card; } function refresh(d) { var newCard = renderStatusCard(d); wrap.replaceChild(newCard, wrap.firstChild); } wrap.appendChild(renderStatusCard(sysData)); var si = data[2] && data[2].sections && data[2].sections.length ? data[2] : null, siBusy = false; var siLog = E('pre', { 'class': 'zm-log' }); var siBody = E('div'); var siActs = E('div', { 'class': 'zm-actions' }); var siCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Системная информация'), E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Роутер, сеть, Zapret, hosts, пакеты и доступность сайтов — одним отчётом. Займёт до минуты.'), siActs, siLog, siBody ]); function siState(v, st) { if (st === 'ok') return E('span', { 'class': 'zm-badge zm-ok' }, [ E('span', { 'class': 'zm-dot' }), [ v ] ]); if (st === 'bad') return E('span', { 'class': 'zm-badge zm-bad' }, [ E('span', { 'class': 'zm-dot' }), [ v ] ]); if (st === 'warn') return E('span', { 'class': 'zm-badge zm-warn' }, [ E('span', { 'class': 'zm-dot' }), [ v ] ]); return E('span', { 'class': 'zm-si-val' }, [ v ]); } function siBox(title, body, badge) { return E('div', { 'class': 'zm-si-box' }, [ E('div', { 'class': 'zm-si-head' }, [ E('h4', {}, [ title ]), badge || E([]) ]) ].concat(body)); } function siText() { var out = [ 'Zapret Manager — системная информация' + (si.ts ? ' (' + si.ts + ')' : '') ]; (si.sections || []).forEach(function(s) { out.push('', '== ' + s.title + ' =='); s.rows.forEach(function(r) { out.push(r[0] + ': ' + r[1]); }); }); if (si.strategy) out.push('', '== Стратегия ==', si.strategy); if (si.hosts && si.hosts.length) out.push('', '== Домены в hosts ==', si.hosts.join(', ')); if (si.packages && si.packages.length) out.push('', '== Пакеты ==', si.packages.join(', ')); if (si.sites && si.sites.length) { out.push('', '== Доступность сайтов: ' + si.sites_ok + '/' + si.sites_total + ' =='); si.sites.forEach(function(x) { out.push((x.ok ? '[ OK ] ' : '[FAIL] ') + x.name); }); } return out.join('\n'); } function siCopy() { var txt = siText(); var done = function() { zm.toast('Отчёт скопирован', 'info'); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(txt).then(done, fallback); return; } fallback(); function fallback() { var ta = E('textarea', { 'style': 'position:fixed; left:-9999px; top:0' }); ta.value = txt; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch (e) { zm.toast('Не удалось скопировать', 'error'); } document.body.removeChild(ta); } } function siRender() { siActs.innerHTML = ''; siActs.appendChild(E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': siBusy ? '' : null, 'click': siRun }, siBusy ? 'Собираем…' : (si ? 'Собрать заново' : 'Собрать'))); if (si && !siBusy) siActs.appendChild(E('button', { 'class': 'cbi-button', 'click': siCopy }, 'Скопировать отчёт')); if (si && si.ts && !siBusy) siActs.appendChild(E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Собрано ' + si.ts)); siBody.innerHTML = ''; if (!si || siBusy) return; var grid = E('div', { 'class': 'zm-si-grid' }); (si.sections || []).forEach(function(s) { grid.appendChild(siBox(s.title, s.rows.map(function(r) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, [ r[0] ]), siState(r[1], r[2]) ]); }))); }); if (si.hosts && si.hosts.length) grid.appendChild(siBox('Домены в hosts', [ E('div', { 'class': 'zm-chips' }, si.hosts.map(function(h) { return E('span', { 'class': 'zm-chip' }, [ h ]); })) ])); siBody.appendChild(grid); if (si.strategy) siBody.appendChild(siBox('Стратегия Zapret', [ E('pre', { 'class': 'zm-si-pre' }, [ si.strategy ]) ])); if (si.sites && si.sites.length) { var ok = si.sites_ok, all = si.sites_total, cls = ok === all ? 'zm-ok' : ok * 2 >= all ? 'zm-warn' : 'zm-bad'; siBody.appendChild(siBox('Доступность сайтов', [ E('div', { 'class': 'zm-chips' }, si.sites.map(function(x) { return E('span', { 'class': 'zm-chip ' + (x.ok ? 'zm-chip-ok' : 'zm-chip-bad') }, [ E('span', { 'class': 'zm-dot' }), x.name ]); })) ], E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), [ ok + ' из ' + all ] ]))); } if (si.packages && si.packages.length) siBody.appendChild(siBox('Пакеты, поставленные вручную', [ E('div', { 'class': 'zm-chips' }, si.packages.map(function(p) { return E('span', { 'class': 'zm-chip' }, [ p ]); })) ], E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ String(si.packages.length) ]))); } function siRun() { if (siBusy) return; siBusy = true; siRender(); zm.toast('Собираем информацию…', 'warning'); zm.renderLog(siLog, '==> Запускаем'); siLog.classList.add('zm-show'); zm.sysinfoRun().then(function(res) { if (res && res.error) { siBusy = false; siRender(); zm.toast(res.error, 'error'); return; } zm.pollJob('sysinfo', siLog, function(ok) { zm.sysinfoGet().then(function(r) { siBusy = false; if (r && r.sections && r.sections.length) si = r; siLog.classList.remove('zm-show'); siRender(); zm.toast(ok ? 'Готово' : 'Не удалось собрать всё', ok ? 'info' : 'error'); }).catch(function() { siBusy = false; siRender(); }); }); }).catch(function() { siBusy = false; siRender(); zm.toast('Роутер не ответил', 'error'); }); } siRender(); wrap.appendChild(siCard); var netBusy = false; var netCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Проверка сети'), netEl, E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (netBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } netBusy = true; netEl.textContent = 'Проверяем'; zm.toast('Проверяем IPv4/IPv6', 'warning'); zm.systemCheckConnectivity().then(function(res) { netBusy = false; netEl.innerHTML = ''; netEl.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'IPv4 (google.com)'), zm.badge(res.ipv4_ok === true, 'доступен, ' + res.ipv4_ms + ' мс', 'недоступен') ])); netEl.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'IPv6 (google.com)'), zm.badge(res.ipv6_ok === true, 'доступен, ' + res.ipv6_ms + ' мс', 'недоступен') ])); }).catch(function() { netBusy = false; }); } }, 'Проверить IPv4 / IPv6') ]) ]); wrap.appendChild(netCard); var mirrorGrid = E('div', { 'class': 'zm-grid' }); var mirrorCurrentEl = E('span', {}, mirrorData.current); var mirrorBusy = false; MIRRORS.forEach(function(m) { mirrorGrid.appendChild(E('div', { 'class': 'zm-tile' + (mirrorData.current === m.label ? ' zm-active' : ''), 'click': function() { if (mirrorBusy) { zm.toast('Дождитесь завершения переключения зеркала', 'warning'); return; } if (mirrorData.current === m.label) { zm.toast('Это зеркало уже выбрано', 'info'); return; } mirrorBusy = true; zm.toast('Переключаем зеркало на «' + m.label + '»', 'warning'); mirrorLog.classList.add('zm-show'); zm.renderLog(mirrorLog, '==> Проверяем и переключаем'); zm.mirrorSet(m.id).then(function(res) { if (res.error) { mirrorBusy = false; zm.toast(res.error, 'error'); return; } zm.pollJob('mirror_set', mirrorLog, function(ok) { mirrorBusy = false; zm.toast(ok ? ('Зеркало переключено на «' + m.label + '»') : 'Не удалось переключить зеркало', ok ? 'info' : 'error'); if (!ok) return; zm.mirrorStatus().then(function(res2) { mirrorData.current = res2.current; mirrorCurrentEl.textContent = res2.current; Array.prototype.forEach.call(mirrorGrid.children, function(el, i) { el.classList.toggle('zm-active', MIRRORS[i].label === res2.current); }); }); }); }).catch(function() { mirrorBusy = false; }); } }, m.label)); }); var mirrorLog = E('pre', { 'class': 'zm-log' }); var mirrorCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Зеркало OpenWrt'), E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Сейчас'), mirrorCurrentEl ]), mirrorGrid, mirrorLog ]); wrap.appendChild(mirrorCard); var uninstallLog = E('pre', { 'class': 'zm-log' }); var uninstallCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Удалить Zapret Manager'), E('p', { 'class': 'zm-hint' }, 'Уберёт только саму панель — приложение в LuCI и Web UI. Всё, что ставилось через неё ' + '(Zapret, Steer, Forkozz, Mixomo и остальное), останется на роутере и продолжит работать, ' + 'но управлять этим отсюда будет нельзя, а автоподбор WARP и автообновление подписки Steer ' + 'остановятся. После удаления эта страница станет недоступна.' ), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить панель Zapret Manager?\n\nСам Zapret и остальные установленные через панель компоненты не пострадают. Действие необратимо — панель придётся ставить заново.')) { return; } uninstallLog.classList.add('zm-show'); zm.renderLog(uninstallLog, '==> Удаляем панель Zapret Manager'); zm.toast('Удаляем панель Zapret Manager', 'warning'); zm.systemUninstallPanel().then(function(res) { if (res.error) { zm.renderLog(uninstallLog, '==> ОШИБКА: ' + res.error); zm.toast(res.error, 'error'); return; } zm.renderLog(uninstallLog, '==> Готово. Панель удалена, страница больше не будет отвечать. Выходим из панели'); zm.toast('Панель Zapret Manager удалена', 'info'); setTimeout(function() { location.href = L.url('admin/logout'); }, 2500); }).catch(function() { zm.renderLog(uninstallLog, '==> Готово (соединение прервано — это ожидаемо, панель уже удалена). Выходим из панели'); setTimeout(function() { location.href = L.url('admin/logout'); }, 2500); }); } }, 'Удалить панель') ]), uninstallLog ]); wrap.appendChild(uninstallCard); var CREDITS = [ { product: 'Zapret Manager и ByeTube', author: 'StressOzz', url: 'https://github.com/StressOzz', self: true }, { product: 'zapret, zapret2', author: 'bol-van', url: 'https://github.com/bol-van' }, { product: 'zapret-openwrt', author: 'remittor', url: 'https://github.com/remittor/zapret-openwrt' }, { product: 'Zapret2', author: 'routerich', url: 'https://github.com/routerich' }, { product: 'стратегии Flowseal', author: 'Flowseal', url: 'https://github.com/Flowseal/zapret-discord-youtube' }, { product: 'ByeDPI-OpenWrt', author: 'DPITrickster', url: 'https://github.com/DPITrickster/ByeDPI-OpenWrt' }, { product: 'mihomo, metacubexd, meta-rules-dat', author: 'MetaCubeX', url: 'https://github.com/MetaCubeX' }, { product: 'zashboard', author: 'Zephyruso', url: 'https://github.com/Zephyruso/zashboard' }, { product: 'hev-socks5-tunnel', author: 'heiher', url: 'https://github.com/heiher/hev-socks5-tunnel' }, { product: 'MagiTrickle', author: 'MagiTrickle', url: 'https://github.com/MagiTrickle/MagiTrickle' }, { product: 'sTGWS, Steer, каталог списков', author: 'xyzmean', url: 'https://github.com/xyzmean' }, { product: 'tg-ws-proxy-go (MTProto)', author: 'spatiumstas', url: 'https://github.com/spatiumstas/tg-ws-proxy-go' }, { product: 'tg-ws-proxy-go (SOCKS5)', author: 'd0mhate', url: 'https://github.com/d0mhate/-tg-ws-proxy-Manager-go' }, { product: 'tg-ws-proxy-rs (Rust)', author: 'valnesfjord', url: 'https://github.com/valnesfjord/tg-ws-proxy-rs' }, { product: 'Mixomo, GeoHideDNS', author: 'Internet-Helper', url: 'https://github.com/Internet-Helper' }, { product: 'Podkop, allow-domains', author: 'itdoginfo', url: 'https://github.com/itdoginfo' }, { product: 'NetShift (основа Forkozz)', author: 'yandexru45', url: 'https://github.com/yandexru45/netshift' }, { product: 'sing-box', author: 'SagerNet', url: 'https://github.com/SagerNet/sing-box' }, { product: 'sing-box-extended', author: 'shtorm-7', url: 'https://github.com/shtorm-7/sing-box-extended' }, { product: 'ios_rule_script (списки)', author: 'blackmatrix7', url: 'https://github.com/blackmatrix7/ios_rule_script' }, { product: 'b4geoip (списки игр и сервисов)', author: 'DanielLavrushin', url: 'https://github.com/DanielLavrushin/b4geoip' }, { product: 'b4geoip-forkop (сборка для sing-box)', author: 'Greeg0ry', url: 'https://github.com/Greeg0ry/b4geoip-forkop' }, { product: 'domain-list-community', author: 'v2fly', url: 'https://github.com/v2fly/domain-list-community' }, { product: 'supercell-ruleset', author: 'ushan0v', url: 'https://github.com/ushan0v/sing-box-supercell-ruleset' }, { product: 'ad-filter (реклама)', author: 'zxc-rv', url: 'https://github.com/zxc-rv/ad-filter' }, { product: 'dpi-checkers', author: 'hyperion-cs', url: 'https://github.com/hyperion-cs/dpi-checkers' }, { product: 'awg-openwrt (AmneziaWG)', author: 'Slava-Shchipunov', url: 'https://github.com/Slava-Shchipunov/awg-openwrt' }, { product: 'awg-openwrt (сборки)', author: '2Grey', url: 'https://github.com/2Grey/awg-openwrt' }, { product: 'warpscout (разведка WARP)', author: 'vernette', url: 'https://github.com/vernette/warpscout' }, { product: 'base-relay (реле регистрации WARP)', author: 'nellimonix', url: 'https://github.com/nellimonix/base-relay' }, { product: 'Всем пользователям', author: 'кто помогает, тестирует и поддерживает проект ❤', self: true, all: true } ]; var creditsGrid = E('div', { 'class': 'zm-credits-grid' }), allTile = null; CREDITS.forEach(function(c) { var tile = E('div', { 'class': 'zm-credit-tile' + (c.self ? ' zm-credit-self' : '') + (c.all ? ' zm-credit-all' : '') }, [ E('div', { 'class': 'zm-credit-product' }, c.product), E('div', { 'class': 'zm-credit-author' }, c.author), c.url ? E('a', { 'href': c.url, 'target': '_blank', 'rel': 'noreferrer' }, c.url.replace(/^https?:\/\//, '')) : '' ]); if (c.all) allTile = tile; creditsGrid.appendChild(tile); }); function fitAll() { if (!allTile || !document.body.contains(creditsGrid)) return; var cols = (getComputedStyle(creditsGrid).gridTemplateColumns || '').split(' ').filter(function(s) { return s; }).length || 1; var rest = (CREDITS.length - 1) % cols; allTile.style.gridColumn = rest ? 'span ' + (cols - rest) : '1 / -1'; } setTimeout(fitAll, 0); setTimeout(fitAll, 400); if (window._zmFitAll) window.removeEventListener('resize', window._zmFitAll); window._zmFitAll = fitAll; window.addEventListener('resize', fitAll); var creditsCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Спасибо'), E('p', { 'class': 'zm-hint' }, 'Zapret Manager собирает в одном месте работу этих проектов и их авторов.'), creditsGrid ]); wrap.appendChild(creditsCard); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/system.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/tgproxy.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var VARIANTS = [ { id: 'mtproto', title: 'Go (MTProto)', port: 1443, kind: 'proto' }, { id: 'socks5', title: 'SOCKS5', port: 2080, kind: 'socks' }, { id: 'rust', title: 'Rust (MTProto)', port: 2443, kind: 'proto' } ]; var AUTO_OPTS = [ [ 'off', 'Выкл' ], [ 'h1', '1 ч' ], [ 'h2', '2 ч' ], [ 'h3', '3 ч' ], [ 'h6', '6 ч' ], [ 'h12', '12 ч' ] ]; function copyToClipboard(text) { if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(function() { zm.toast('Ссылка скопирована', 'info'); }).catch(function() { fallbackCopy(text); }); } else { fallbackCopy(text); } } function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.left = '0'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.focus(); ta.select(); ta.setSelectionRange(0, text.length); var ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } document.body.removeChild(ta); zm.toast(ok ? 'Ссылка скопирована' : 'Не удалось скопировать — выделите ссылку вручную', ok ? 'info' : 'warning'); } return view.extend({ load: function() { zm.injectCss(); return Promise.all([ zm.tgStatus(), zm.tgwsStatus().catch(function() { return {}; }) ]); }, render: function(all) { var view = this; var data = all[0]; var tgwsData = all[1] || {}; var wrap = E('div', { 'class': 'zm-wrap' }); var cards = E('div', { 'class': 'zm-cards zm-cards-2' }); var linksWrap = E('div', {}); var logEl = E('pre', { 'class': 'zm-log' }); function statusOf(d, id) { if (id === 'mtproto') return { installed: d.mtproto === 'installed', running: d.mtproto_running === true, secret: d.secret_mtproto, version: d.mtproto_version, latest: d.mtproto_latest, newer: d.mtproto_newer === true }; if (id === 'socks5') return { installed: d.socks5 === 'installed', running: d.socks5_running === true, secret: '', version: d.socks5_version, latest: d.socks5_latest, newer: d.socks5_newer === true }; return { installed: d.rust === 'installed', running: d.rust_running === true, secret: d.secret_rust, version: d.rust_version, latest: d.rust_latest, newer: d.rust_newer === true }; } function renderLinks(d) { linksWrap.innerHTML = ''; VARIANTS.forEach(function(v) { var st = statusOf(d, v.id); if (!st.installed || !st.running || !d.lan_ip) return; var link, extra; if (v.kind === 'socks') { link = 'tg://socks?server=' + d.lan_ip + '&port=' + v.port; extra = 'SOCKS5-адрес: ' + d.lan_ip + ':' + v.port; } else { var secretPrefixed = 'dd' + st.secret; link = 'tg://proxy?server=' + d.lan_ip + '&port=' + v.port + '&secret=' + secretPrefixed; extra = 'Хост: ' + d.lan_ip + ' Порт: ' + v.port + ' Ключ: ' + secretPrefixed; } var qrLink = link.replace(/^tg:\/\//, 'https://t.me/'); var qrBox = E('div', { 'class': 'zm-tg-qr-box', 'style': 'display:none' }); var qrShown = false; function toggleQr() { qrShown = !qrShown; qrBox.style.display = qrShown ? '' : 'none'; if (qrShown && !qrBox.firstChild) { qrBox.appendChild(E('img', { 'src': 'https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=8&data=' + encodeURIComponent(qrLink), 'alt': 'QR-код', 'width': '220', 'height': '220' })); } } linksWrap.appendChild(E('div', { 'class': 'zm-tg-link-card' }, [ E('h4', {}, v.title), E('p', { 'class': 'zm-tg-link-hint' }, 'Откройте эту ссылку на телефоне/компьютере с Telegram — прокси подключится автоматически. Либо скопируйте и вставьте её в браузер/Telegram вручную.'), E('div', { 'class': 'zm-tg-link-row' }, [ E('div', { 'class': 'zm-tg-link-box' }, link), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { copyToClipboard(link); } }, 'Скопировать'), E('button', { 'class': 'cbi-button zm-tg-qr-btn', 'title': 'Показать QR-код', 'click': function() { toggleQr(); } }, 'QR-код') ]), E('p', { 'class': 'zm-hint' }, extra), qrBox ])); }); } var busyMap = {}; function renderCards(d) { cards.innerHTML = ''; VARIANTS.forEach(function(v) { var st = statusOf(d, v.id); var actions = []; if (st.installed) { actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить ' + v.title + '?\n\nУдаление принудительное: пакет и все файлы будут стёрты, даже если менеджер пакетов откажется.')) return; doAction(v.id, 'remove'); } }, 'Удалить')); actions.push(E('button', { 'class': 'cbi-button', 'click': function() { doAction(v.id, 'reinstall'); } }, 'Переустановить')); if (st.installed && st.newer) { actions.push(E('button', { 'class': 'cbi-button', 'click': function() { doAction(v.id, 'update'); } }, 'Обновить до ' + st.latest)); } } else { actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { doAction(v.id, 'install'); } }, 'Установить')); } cards.appendChild(E('div', { 'class': 'zm-card' }, [ E('h3', {}, v.title), E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Статус'), st.installed ? zm.badge(st.running, 'запущен', 'остановлен') : zm.badge(false, '', 'не установлен') ]), st.installed && st.version ? E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Версия'), E('span', {}, [ st.version + (st.newer ? ' · доступна ' + st.latest : st.latest ? ' · последняя' : '') ]) ]) : E([]), E('div', { 'class': 'zm-actions' }, actions) ])); }); if (tgwsCard) cards.appendChild(tgwsCard); } function doAction(variantId, action) { if (busyMap[variantId]) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } busyMap[variantId] = true; var job = action === 'remove' ? ('tg_remove_' + (variantId === 'mtproto' ? 'mtproto' : variantId)) : ('tg_install_' + (variantId === 'mtproto' ? 'mtproto' : variantId)); zm.toast((action === 'remove' ? 'Удаляем…' : action === 'reinstall' ? 'Переустанавливаем…' : action === 'update' ? 'Обновляем…' : 'Устанавливаем…'), 'warning'); zm.tgAction(variantId, action).then(function(res) { if (res.error) { busyMap[variantId] = false; zm.toast(res.error, 'error'); return; } if (res.started) { zm.pollJob(job, logEl, function(ok) { busyMap[variantId] = false; zm.toast(ok ? 'Готово' : 'Не получилось — причина в журнале ниже', ok ? 'info' : 'error'); zm.tgStatus().then(function(d) { data = d; renderCards(d); renderLinks(d); renderAuto(); }); }); } else { busyMap[variantId] = false; } }).catch(function() { busyMap[variantId] = false; }); } renderLinks(data); renderCards(data); wrap.appendChild(linksWrap); wrap.appendChild(cards); wrap.appendChild(logEl); var tgwsCard = E('div', { 'class': 'zm-card' }); var tgwsBusy = false; function renderTgws(d) { var installed = d.installed === 'installed'; var actions = []; if (installed) { actions.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить sTGWS?\n\nУдаление принудительное: пакет и все файлы будут стёрты, даже если менеджер пакетов откажется.')) return; doTgwsAction('remove'); } }, 'Удалить')); actions.push(E('button', { 'class': 'cbi-button', 'click': function() { doTgwsAction('reinstall'); } }, 'Переустановить')); if (d.version && d.newer) { actions.push(E('button', { 'class': 'cbi-button', 'click': function() { doTgwsAction('update'); } }, 'Обновить до ' + d.latest)); } actions.push(E('button', { 'class': 'cbi-button', 'click': function() { doTgwsAction('restart'); } }, 'Перезапустить')); actions.push(E('button', { 'class': 'cbi-button', 'click': function() { doTgwsAction('reconfigure'); } }, 'Подобрать новый домен')); } else { actions.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { doTgwsAction('install'); } }, 'Установить')); } tgwsCard.innerHTML = ''; tgwsCard.appendChild(E('h3', {}, 'sTGWS')); tgwsCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Статус'), installed ? zm.badge(d.running === true, 'запущен', 'остановлен') : zm.badge(false, '', 'не установлен') ])); if (installed && d.version) { tgwsCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Версия'), E('span', {}, [ String(d.version) ]) ])); } if (installed && d.domain) { tgwsCard.appendChild(E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, 'Домен'), E('span', {}, [ String(d.domain) ]) ])); } tgwsCard.appendChild(E('div', { 'class': 'zm-actions' }, actions)); } function doTgwsAction(action) { if (tgwsBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } tgwsBusy = true; var job = action === 'remove' ? 'tgws_remove' : action === 'restart' ? 'tgws_restart' : action === 'reconfigure' ? 'tgws_reconfigure' : 'tgws_install'; var label = action === 'remove' ? 'Удаляем sTGWS' : action === 'restart' ? 'Перезапускаем sTGWS' : action === 'reconfigure' ? 'Подбираем новый домен sTGWS' : action === 'reinstall' ? 'Переустанавливаем sTGWS' : action === 'update' ? 'Обновляем sTGWS' : 'Устанавливаем sTGWS'; zm.toast(label, 'warning'); zm.tgwsAction(action).then(function(res) { if (res.error) { tgwsBusy = false; zm.toast(res.error, 'error'); return; } if (res.started) { zm.pollJob(job, logEl, function(ok) { tgwsBusy = false; zm.toast(ok ? 'Готово' : 'Не получилось — причина в журнале ниже', ok ? 'info' : 'error'); zm.tgwsStatus().then(function(d) { tgwsData = d; renderTgws(d); return zm.tgStatus(); }).then(function(d) { if (d && !d.error) data = d; renderAuto(); }); }); } else { tgwsBusy = false; } }).catch(function() { tgwsBusy = false; }); } renderTgws(tgwsData); cards.appendChild(tgwsCard); var autoCard = E('div', { 'class': 'zm-card' }), autoBusy = false; function autoSet(id, value, okText) { if (autoBusy) { zm.toast('Дождитесь окончания текущей операции', 'warning'); renderAuto(); return; } autoBusy = true; renderAuto(); zm.tgAction(id, value).then(function(res) { autoBusy = false; if (!res || res.error) { zm.toast((res && res.error) || 'Роутер не ответил', 'error'); renderAuto(); return; } if (res.auto) data.auto = res.auto; zm.toast(okText, 'info'); renderAuto(); }).catch(function() { autoBusy = false; zm.toast('Роутер не ответил', 'error'); renderAuto(); }); } function renderAuto() { var a = data.auto || {}, rows = []; var list = VARIANTS.map(function(v) { return { id: v.id, title: v.title, on: statusOf(data, v.id).installed }; }); list.push({ id: 'tgws', title: 'sTGWS', on: tgwsData.installed === 'installed' }); autoCard.innerHTML = ''; autoCard.appendChild(E('h3', {}, 'Автоперезапуск')); list.filter(function(x) { return x.on; }).forEach(function(x) { var cur = a[x.id] || 'off', opts = AUTO_OPTS.slice(); if (!opts.some(function(o) { return o[0] === cur; })) opts.push([ cur, /^d\d+$/.test(cur) ? 'в ' + ('0' + cur.slice(1)).slice(-2) + ':00' : /^m\d+$/.test(cur) ? cur.slice(1) + ' мин' : /^h\d+$/.test(cur) ? cur.slice(1) + ' ч' : cur ]); rows.push(E('div', { 'class': 'zm-row zm-tg-auto-row' }, [ E('span', { 'class': 'zm-label' }, x.title), E('div', { 'class': 'zm-seg' + (autoBusy ? ' zm-seg-busy' : '') }, opts.map(function(o) { return E('div', { 'class': 'zm-seg-item' + (o[0] === cur ? ' zm-active' : ''), 'click': function() { if (o[0] === cur || autoBusy) return; autoSet(x.id, 'auto=' + o[0], o[0] === 'off' ? x.title + ': автоперезапуск выключен' : x.title + ': перезапуск каждые ' + o[1]); } }, o[1]); })) ])); }); if (!rows.length) { autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Установите хотя бы один прокси — здесь можно будет настроить его перезапуск по расписанию.')); return; } autoCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Для каждого прокси можно задать свой интервал. Перезапуск занимает пару секунд — Telegram переподключится сам.')); rows.forEach(function(r) { autoCard.appendChild(r); }); autoCard.appendChild(zm.swRow(!!a.watch, 'Поднимать упавший прокси', 'Раз в 2 минуты панель проверяет установленные прокси и запускает заново тот, чей процесс перестал работать.', function() { autoSet('watch', a.watch ? 'off' : 'on', a.watch ? 'Проверка прокси выключена' : 'Упавший прокси будет запущен заново в течение 2 минут'); }, autoBusy)); } renderAuto(); wrap.appendChild(autoCard); var restartBusy = false; wrap.appendChild(E('div', { 'class': 'zm-card' }, [ E('h3', {}, 'Общие действия'), E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': function() { if (restartBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } restartBusy = true; zm.toast('Перезапускаем TG WS Proxy', 'warning'); zm.tgRestartAll().then(function() { restartBusy = false; zm.toast('Все запущенные TG WS Proxy перезапущены', 'info'); zm.tgwsStatus().then(function(d) { renderTgws(d); }); }).catch(function() { restartBusy = false; }); } }, 'Перезапустить все') ]) ])); return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/tgproxy.js' cat > '/www/luci-static/resources/view/zapret-manager/forkozz.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require view'; 'require zapret-manager.common as zm'; var CATEGORY_IDS = [ 'geoblock', 'block', 'news', 'anime', 'porn', 'hodca', 'russia_inside', 'russia_outside', 'ukraine_inside' ]; var OLD_GROUP = 'Выбрано раньше (нет в каталоге)'; var OLD_NAMES = { supercell: 'Supercell', ads_hagezi_pro: 'Реклама (HaGeZi Pro)', github: 'GitHub', youtube: 'YouTube', discord: 'Discord', telegram: 'Telegram', meta: 'Meta', twitter: 'X (Twitter)', tiktok: 'TikTok', hdrezka: 'HDRezka', roblox: 'Roblox', google_ai: 'Google AI (Gemini)', google_play: 'Google Play', cloudflare: 'Cloudflare', cloudfront: 'Amazon CloudFront', digitalocean: 'DigitalOcean', hetzner: 'Hetzner', ovh: 'OVH', hodca: 'Хостинги и CDN', russia_outside: 'Сайты РФ из-за рубежа', ukraine_inside: 'Блокировки Украины' }; function refNorm(t) { t = String(t || ''); if (t.indexOf('c:') === 0) return 'itd:' + t.slice(2); var u = t.slice(2).toLowerCase().replace(/[?#].*$/, ''), b = u.replace(/^.*\//, ''), m; if (/allow-domains/.test(u) && /\.srs$/.test(b)) return 'itd:' + b.slice(0, -4); if (/b4geoip/.test(u) && /\.srs$/.test(b)) return 'b4:' + b.slice(0, -4); if ((m = /github\.com\/([^\/]+\/[^\/]+)\/releases\/(?:latest\/download|download\/[^\/]+)\/(.+)$/.exec(u))) return 'gh:' + m[1] + '/' + m[2]; return 'u:' + u; } function refLabel(t) { if (t.indexOf('c:') === 0) return OLD_NAMES[t.slice(2)] || t.slice(2); var b = t.slice(2).replace(/[?#].*$/, '').replace(/^.*\//, '').replace(/\.(srs|json|lst)$/i, ''); return /b4geoip/i.test(t) ? b + ' (b4geoip)' : (b || t.slice(2)); } function pickFromCfg(c) { c = c || {}; var items = ((c.catalog && c.catalog.items) || []).filter(function(it) { return it && it.id && (it.targets || []).length; }); var refs = c.refs || {}, toks = [], present = {}, sel = {}, covered = {}, extra = []; [ 'c', 's', 'r' ].forEach(function(k) { (refs[k] || []).forEach(function(v) { toks.push(k + ':' + v); }); }); var lists = (c.lists || []).slice(); toks.forEach(function(t) { present[refNorm(t)] = true; }); lists.forEach(function(u) { present[refNorm('l:' + u)] = true; }); items.forEach(function(it) { if (!it.targets.every(function(t) { return present[refNorm(t)]; })) return; sel[it.id] = true; it.targets.forEach(function(t) { covered[refNorm(t)] = true; }); }); toks.forEach(function(t) { var n = refNorm(t); if (covered[n]) return; covered[n] = true; extra.push({ id: 'old:' + t, name: refLabel(t), group: OLD_GROUP, targets: [ t ] }); sel['old:' + t] = true; }); return { items: items.concat(extra), sel: sel, lists: lists.filter(function(u) { return !covered[refNorm('l:' + u)]; }) }; } function refsOf(d) { var out = { c: [], s: [], r: [], l: [] }, seen = {}; (d.items || []).forEach(function(it) { if (!d.sel[it.id]) return; it.targets.forEach(function(t) { var n = refNorm(t), k = t.slice(0, 1); if (seen[n] || !out[k]) return; seen[n] = true; out[k].push(t.slice(2)); }); }); return out; } function selCount(p) { return (p.items || []).filter(function(it) { return p.sel[it.id]; }).length; } var DNS_CAT = { doh: [ { id: 'quad9', name: 'Quad9', value: 'https://dns.quad9.net/dns-query', boot: '9.9.9.9', alias: [ 'dns.quad9.net', '9.9.9.9' ] }, { id: 'cloudflare', name: 'Cloudflare', value: 'https://cloudflare-dns.com/dns-query', boot: '1.1.1.1', alias: [ '1.1.1.1', 'cloudflare-dns.com', 'https://1.1.1.1/dns-query' ] }, { id: 'google', name: 'Google', value: 'https://dns.google/dns-query', boot: '8.8.8.8', alias: [ '8.8.8.8', 'dns.google', 'https://8.8.8.8/dns-query' ] }, { id: 'yandex', name: 'Яндекс', value: 'https://common.dot.dns.yandex.net/dns-query', boot: '77.88.8.8' }, { id: 'yandex_safe', name: 'Яндекс Безопасный', value: 'https://safe.dot.dns.yandex.net/dns-query', boot: '77.88.8.88' }, { id: 'yandex_family', name: 'Яндекс Семейный', value: 'https://family.dot.dns.yandex.net/dns-query', boot: '77.88.8.7' }, { id: 'comss', name: 'Comss', value: 'https://dns.comss.one/dns-query', boot: '77.88.8.8', alias: [ 'dns.comss.one' ] }, { id: 'xbox', name: 'XBOX', value: 'https://xbox-dns.ru/dns-query', boot: '77.88.8.8' }, { id: 'dnsai', name: 'DNS-AI', value: 'https://dns.dns-ai.ru/dns-query', boot: '77.88.8.8' }, { id: 'geohide_ru', name: 'GeoHide RU', value: 'https://geohide.ru/dns-query', boot: '77.88.8.8' }, { id: 'geohide_eu', name: 'GeoHide EU', value: 'https://eu.geohide.ru/dns-query', boot: '77.88.8.8' }, { id: 'geohide_us', name: 'GeoHide US', value: 'https://us.geohide.ru/dns-query', boot: '77.88.8.8' } ], dot: [ { id: 'quad9', name: 'Quad9', value: 'dns.quad9.net', boot: '9.9.9.9', alias: [ '9.9.9.9' ] }, { id: 'cloudflare', name: 'Cloudflare', value: '1.1.1.1', boot: '1.1.1.1', alias: [ 'one.one.one.one', '1dot1dot1dot1.cloudflare-dns.com' ] }, { id: 'google', name: 'Google', value: 'dns.google', boot: '8.8.8.8', alias: [ '8.8.8.8' ] }, { id: 'yandex', name: 'Яндекс', value: 'common.dot.dns.yandex.net', boot: '77.88.8.8' }, { id: 'yandex_safe', name: 'Яндекс Безопасный', value: 'safe.dot.dns.yandex.net', boot: '77.88.8.88' }, { id: 'yandex_family', name: 'Яндекс Семейный', value: 'family.dot.dns.yandex.net', boot: '77.88.8.7' }, { id: 'comss', name: 'Comss', value: 'dns.comss.one', boot: '77.88.8.8' } ], udp: [ { id: 'quad9', name: 'Quad9', value: '9.9.9.9', boot: '9.9.9.9' }, { id: 'cloudflare', name: 'Cloudflare', value: '1.1.1.1', boot: '1.1.1.1' }, { id: 'google', name: 'Google', value: '8.8.8.8', boot: '8.8.8.8' }, { id: 'yandex', name: 'Яндекс', value: '77.88.8.8', boot: '77.88.8.8' }, { id: 'yandex_safe', name: 'Яндекс Безопасный', value: '77.88.8.88', boot: '77.88.8.88' }, { id: 'yandex_family', name: 'Яндекс Семейный', value: '77.88.8.7', boot: '77.88.8.7' } ] }; var DNS_DEF = { doh: [ 'https://dns.quad9.net/dns-query', 'https://common.dot.dns.yandex.net/dns-query' ], dot: [ 'dns.quad9.net', 'common.dot.dns.yandex.net' ], udp: [ '9.9.9.9' ] }; var DNS_MAX = 8; var DNS_TYPES = { udp: 'UDP', dot: 'DoT', doh: 'DoH' }; var DNS_HELP = { udp: { ph: '9.9.9.9', hint: 'Обычный DNS без шифрования — только IP-адрес. Провайдер видит и может подменять ответы.' }, dot: { ph: 'dns.quad9.net', hint: 'DNS over TLS: адрес сервера без https://, например dns.quad9.net или 9.9.9.9.' }, doh: { ph: 'https://dns.quad9.net/dns-query', hint: 'DNS over HTTPS: ссылка https://…/dns-query или просто адрес сервера.' } }; var DNS_RE = { udp: /^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(:\d{1,5})?$/, dot: /^[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:\d{1,5})?$/, doh: /^(https:\/\/[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:\d{1,5})?(\/[^\s@|,;'"<>]*)?|[A-Za-z0-9.-]+\.[A-Za-z0-9-]+(:\d{1,5})?)$/i }; function dnsFind(t, v) { var list = DNS_CAT[t] || []; for (var i = 0; i < list.length; i++) if (list[i].value === v || (list[i].alias || []).indexOf(v) >= 0) return list[i]; return null; } function dnsName(t, v) { var p = dnsFind(t, v); return p ? p.name : String(v || '').replace(/^https:\/\//, '').replace(/\/dns-query$/, ''); } function dnsBoots(t, servers) { var out = []; (servers || []).forEach(function(v) { var p = dnsFind(t, v), b = p ? p.boot : (t === 'udp' ? '' : '9.9.9.9'); if (b && out.indexOf(b) < 0) out.push(b); }); [ '9.9.9.9', '77.88.8.8' ].forEach(function(b) { if (out.length < 2 && out.indexOf(b) < 0) out.push(b); }); return out.slice(0, 4); } var TABS = [ { id: 'conn', label: 'Подключение' }, { id: 'svc', label: 'Сервисы' }, { id: 'byp', label: 'Исключения', common: true }, { id: 'dev', label: 'Устройства', common: true }, { id: 'set', label: 'Настройки', common: true } ]; function isSecTab(id) { return id === 'conn' || id === 'svc'; } var SEC_MAX = 9; var MODES = [ { id: 'links', label: 'Серверы' }, { id: 'sub', label: 'Подписка' }, { id: 'iface', label: 'Туннель' } ]; var SB_VARS = [ { id: 'tiny', label: 'Облегчённый', hint: 'Пакет sing-box-tiny из OpenWrt: меньше памяти, все протоколы, которые понимает NetShift, кроме VMess. Подходит почти всем.', warn: '' }, { id: 'stable', label: 'Обычный', hint: 'Полный sing-box из репозитория OpenWrt. Нужно больше места.', warn: '' }, { id: 'extended', label: 'Расширенный', hint: 'Сборка sing-box-extended с GitHub: XHTTP, VMess и другое. Ставит сам NetShift, нужно ~40 МБ.', warn: 'Скачивается с GitHub (shtorm-7/sing-box-extended), нужно около 40 МБ свободной памяти.\n' } ]; var SB_NAMES = { tiny: 'облегчённый', stable: 'обычный', extended: 'расширенный', lite: 'расширенный облегчённый', compact: 'расширенный компактный' }; var SUB_IV = [ { id: '1h', label: '1 ч' }, { id: '6h', label: '6 ч' }, { id: '12h', label: '12 ч' }, { id: '1d', label: '24 ч' } ]; var LIST_IV = [ { id: '3h', label: '3 ч' }, { id: '12h', label: '12 ч' }, { id: '1d', label: '24 ч' }, { id: '3d', label: '3 дня' } ]; var LINK_RE = /^(vless|vmess|trojan|ss|socks4a?|socks5|hysteria2|hy2):\/\/\S+$/i; var DOMAIN_RE = /^\.?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; function domainProblem(v) { var raw = v, d = String(v || '').trim().toLowerCase().replace(/^full:/, '').replace(/^\*\./, ''); if (/^(keyword|regex|domain_keyword|domain_regex):/.test(d)) return '«' + raw + '» — keyword: и regex: NetShift не понимает, впишите домен целиком'; if (/[^\x00-\x7f]/.test(d)) return '«' + raw + '» — русские домены пишите в punycode, например xn--h1alffa9f.xn--p1ai'; if (!DOMAIN_RE.test(d)) return '«' + raw + '» не похоже на домен'; return ''; } var WARN = { steer: 'Работает Steer. Один и тот же сервис выбирайте только в одном месте.', bytetube: 'Работает ByeTube. YouTube пускайте только через что-то одно.' }; var ACT_TEXT = { install: 'Устанавливаем Forkozz', update: 'Обновляем Forkozz', remove: 'Удаляем Forkozz', stop: 'Выключаем Forkozz', start: 'Включаем Forkozz', restart: 'Перезапускаем Forkozz', apply: 'Применяем настройки', lists: 'Обновляем списки сервисов', subs: 'Обновляем серверы подписки', singbox: 'Меняем sing-box', singbox_remove: 'Удаляем sing-box' }; var DONE_TEXT = { install: 'Forkozz установлен', update: 'Forkozz обновлён', remove: 'Forkozz удалён', stop: 'Forkozz выключен', start: 'Forkozz включён', restart: 'Forkozz перезапущен', apply: 'Настройки применены', lists: 'Списки сервисов обновлены', subs: 'Серверы подписки обновлены', singbox: 'sing-box заменён', singbox_remove: 'sing-box удалён' }; function badge(cls, text) { return E('span', { 'class': 'zm-badge ' + cls }, [ E('span', { 'class': 'zm-dot' }), text ]); } function row(label, node) { return E('div', { 'class': 'zm-row' }, [ E('span', { 'class': 'zm-label' }, label), node ]); } function plural(n, one, few, many) { var a = n % 10, h = n % 100; if (a === 1 && h !== 11) return one; if (a >= 2 && a <= 4 && (h < 12 || h > 14)) return few; return many; } function nn(n, one, few, many) { return n + ' ' + plural(n, one, few, many); } function latClass(ms) { if (!(ms > 0)) return 'zm-lat-none'; if (ms < 400) return 'zm-lat-good'; if (ms < 900) return 'zm-lat-mid'; return 'zm-lat-bad'; } function latText(ms) { return ms > 0 ? ms + ' мс' : ms === 0 ? 'нет ответа' : ''; } function flag(cc) { cc = String(cc || '').trim().toUpperCase(); if (!/^[A-Z]{2}$/.test(cc)) return cc; return String.fromCodePoint(127397 + cc.charCodeAt(0), 127397 + cc.charCodeAt(1)); } function fmtBytes(n) { n = +n || 0; var u = [ 'Б', 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)) + ' ' + u[i]; } function fmtDate(sec) { var d = new Date((+sec || 0) * 1000); if (isNaN(d.getTime()) || !(+sec > 0)) return ''; return ('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2) + '.' + d.getFullYear(); } function tokLines(t) { var out = []; textLines(t).forEach(function(l) { l.replace(/\s+(#|\/\/).*$/, '').split(/[\s,;]+/).forEach(function(w) { if (w) out.push(w); }); }); return out; } function textLines(t) { return String(t || '').split(/\r?\n/).map(function(l) { return l.trim(); }).filter(function(l) { return l && !/^(#|\/\/)/.test(l); }); } function pageLink(id) { return document.body.classList.contains('zmw-body') ? '#/' + id : L.url('admin/services/zapret-manager/' + id); } function fromCfg(c) { c = c || {}; var dns = c.dns || {}, pick = pickFromCfg(c); return { sec: c.sec || 'main', isMain: c.is_main !== false, label: c.label || '', enabled: c.enabled !== false, byDomains: ((c.bypass && c.bypass.domains) || []).join('\n'), bySubnets: ((c.bypass && c.bypass.subnets) || []).join('\n'), mode: c.mode || 'links', links: (c.links || []).join('\n'), sub: c.sub || '', sub_interval: c.sub_interval || '12h', iface: c.iface || '', fastest: true, exclude: c.exclude || '', items: pick.items, sel: pick.sel, domains: (c.domains || []).join('\n'), subnets: (c.subnets || []).join('\n'), lists: pick.lists.join('\n'), full: (c.full || []).slice(), excl: (c.excl || []).slice(), dns: { type: dns.type || (dns.server ? 'udp' : 'dot'), servers: (dns.servers && dns.servers.length ? dns.servers : [ dns.server || '9.9.9.9' ]).slice(), bootstraps: (dns.bootstraps && dns.bootstraps.length ? dns.bootstraps : [ dns.bootstrap || '9.9.9.9' ]).slice(), detour: !!dns.detour }, quic_off: c.quic_off !== false, list_interval: c.list_interval || '1d', lists_via: !!c.lists_via }; } function withBootMode(d) { var auto = dnsBoots(d.dns.type, d.dns.servers); d.dns.bootOwn = d.dns.bootstraps.slice().sort().join(',') !== auto.slice().sort().join(','); return d; } function validCfg(x) { return (x && typeof x === 'object' && !x.error && x.dns) ? x : null; } function sig(d) { var srt = function(a) { return (a || []).slice().sort(); }; var tl = function(v) { return String(v || '').split(/\n/).map(function(x) { return x.trim(); }).filter(Boolean).join('\n'); }; return JSON.stringify([ d.sec, d.mode, tl(d.links), d.sub.trim(), d.sub_interval, d.iface, d.fastest, (function(r) { return [ srt(r.c), srt(r.s), srt(r.r), srt(r.l) ]; })(refsOf(d)), tl(d.domains), tl(d.subnets), tl(d.lists), srt(d.full), srt(d.excl), d.mode === 'iface' ? '' : String(d.exclude || ''), d.dns.type, d.dns.servers, d.dns.bootstraps, d.dns.detour, d.quic_off, d.list_interval, d.lists_via, String(d.label || '').trim(), d.isMain || d.enabled, tl(d.byDomains), tl(d.bySubnets) ]); } var GLOBAL_KEYS = [ 'byDomains', 'bySubnets', 'full', 'excl', 'dns', 'quic_off', 'list_interval', 'lists_via' ]; function secSig(d) { var x = {}, k; for (k in d) if (GLOBAL_KEYS.indexOf(k) < 0) x[k] = d[k]; x.dns = { type: 'udp', servers: [], bootstraps: [], detour: false }; x.byDomains = x.bySubnets = ''; x.full = []; x.excl = []; x.quic_off = true; x.list_interval = '1d'; x.lists_via = false; return sig(x); } function subName(t) { t = String(t || '').trim(); return t.replace(/[\s\uFE0F]/g, '') === '\uD83C\uDF28VPN' ? 'StressKVN' : t; } function dnsLabel(d) { if (!d) return '—'; var sv = d.servers && d.servers.length ? d.servers : [ d.server ]; var cur = sv[d.active > 0 && d.active < sv.length ? d.active : 0]; return dnsName(d.type, cur) + (sv.length > 1 ? ' +' + (sv.length - 1) : ''); } function secConn(s) { if (!s) return ''; if (s.mode === 'iface') return (/^zmwarp/.test(s.iface || '') ? 'WARP · ' : 'Туннель · ') + (s.iface || '—'); if (s.mode === 'sub') return 'подписка'; return s.links ? nn(s.links, 'сервер', 'сервера', 'серверов') : 'серверы'; } function secPick(c, s) { return pickFromCfg({ catalog: c && c.catalog, refs: s.refs || {}, lists: s.lists || [] }); } function secSel(c, s) { return selCount(secPick(c, s)); } function savedSec() { try { return localStorage.getItem('zm.forkozz.sec') || ''; } catch (e) { return ''; } } function newCfg(c) { var x = {}, k, tl = (c && c.tunnels) || []; for (k in (c || {})) x[k] = c[k]; var warp = tl.filter(function(t) { return t.steer || /^zmwarp/.test(t.name); })[0] || tl[0]; var n = ((c && c.sections) || []).length + 1; x.sec = 'new'; x.is_main = false; x.exists = false; x.enabled = true; x.label = warp && /^zmwarp/.test(warp.name) && !((c && c.sections) || []).some(function(s) { return s.label === 'WARP'; }) ? 'WARP' : 'Секция ' + n; x.mode = 'iface'; x.iface = warp ? warp.name : ''; x.links = []; x.sub = ''; x.sub_interval = '12h'; x.exclude = ''; x.hide_names = []; x.refs = { c: [], s: [], r: [] }; x.domains = []; x.subnets = []; x.lists = []; return x; } function getCfg(sec) { if (sec === 'new') return zm.forkopConfigGet().then(function(c) { return c && !c.error && c.main_exists ? newCfg(c) : c; }); return sec ? zm.forkopAction('cfg', sec) : zm.forkopConfigGet(); } return view.extend({ load: function() { zm.injectCss(); var ss = savedSec(); return Promise.all([ zm.forkopStatus().catch(function() { return {}; }), getCfg(ss === 'new' ? '' : ss).catch(function() { return null; }) ]); }, render: function(all) { var st = all[0] || {}, cfg = validCfg(all[1]); var draft = withBootMode(fromCfg(cfg)), savedSig = sig(draft), dirty = false, busy = false, lastAct = '', saving = false; var servers = null, srvBusy = false, srvErr = '', latBusy = false; var diag = null, diagBusy = false, openGroups = {}, catWait = 0; var secState = null, secProbe = false, secProbed = false; var dnsRes = {}, dnsTesting = false, dnsInput = null; var tab = 'conn'; try { tab = localStorage.getItem('zm.forkozz.tab') || 'conn'; } catch (e) {} if (!TABS.some(function(t) { return t.id === tab; }) || (cfg && !cfg.main_exists)) tab = 'conn'; var curSec = (cfg && cfg.sec) || ''; var wrap = E('div', { 'class': 'zm-wrap' }); var hostsWarn = zm.hostsWarn('Forkozz'); var mainCard = E('div', { 'class': 'zm-card zm-kv' }); var logEl = E('pre', { 'class': 'zm-log' }); var tabBar = E('div', { 'class': 'zm-tabsplit' }); var saveBar = E('div', { 'class': 'zm-savebar', 'style': 'display:none' }); var panes = { conn: E('div', { 'class': 'zm-wrap' }), svc: E('div', { 'class': 'zm-wrap' }), byp: E('div', { 'class': 'zm-wrap' }), dev: E('div', { 'class': 'zm-wrap' }), set: E('div', { 'class': 'zm-wrap' }) }; var secCard = E('div', { 'class': 'zm-card zm-kv' }); var bypCard = E('div', { 'class': 'zm-card' }); var connCard = E('div', { 'class': 'zm-card zm-kv' }); var srvCard = E('div', { 'class': 'zm-card zm-kv' }); var checkCard = E('div', { 'class': 'zm-card zm-kv' }); var routeCard = E('div', { 'class': 'zm-card' }), routeBox = null; var svcCard = E('div', { 'class': 'zm-card' }); var ownCard = E('div', { 'class': 'zm-card' }); var fullCard = E('div', { 'class': 'zm-card' }); var exclCard = E('div', { 'class': 'zm-card' }); var dnsCard = E('div', { 'class': 'zm-card zm-kv' }); var miscCard = E('div', { 'class': 'zm-card zm-kv' }); var sbCard = E('div', { 'class': 'zm-card zm-kv' }); function area(key, placeholder, minh) { var ta = E('textarea', { 'class': 'zm-config-editor', 'spellcheck': 'false', 'autocapitalize': 'off', 'placeholder': placeholder, 'style': 'min-height:' + (minh || 120) + 'px' }); ta.value = draft[key]; ta.addEventListener('input', function() { draft[key] = ta.value; touch(); if (key === 'links') renderLinkCount(); if (key === 'byDomains' || key === 'bySubnets') bypBadge(); }); return ta; } var taLinks = area('links', 'vless://…\nss://…\ntrojan://…', 130); var linksEd = zm.linksEditor({ value: draft.links, re: LINK_RE, kinds: 'vless, vmess, trojan, ss, socks5, hysteria2', placeholder: 'vless://…\nss://…\ntrojan://…', empty: 'Серверов пока нет — вставьте ссылки ниже и нажмите «Добавить».', busy: function() { return busy || saving; }, onChange: function(v) { draft.links = v; taLinks.value = v; touch(); renderLinkCount(); } }); var taSub = area('sub', 'https://…/sub/…', 60); taSub.classList.add('zm-sub-input'); var taDomains = area('domains', 'chatgpt.com\nopenai.com\nspotify.com', 140); var taSubnets = area('subnets', '91.108.4.0/22\n149.154.160.0/20', 90); var taLists = area('lists', 'https://raw.githubusercontent.com/…/list.lst', 70); var taByD = area('byDomains', 'youtube.com\ngooglevideo.com', 140); var taByS = area('bySubnets', '203.0.113.0/24', 80); var linkCountEl = E('span', {}); function exclNames() { if (draft.mode === 'links') return textLines(draft.links).map(function(l) { var i = l.indexOf('#'); if (i < 0) return ''; try { return decodeURIComponent(l.slice(i + 1).replace(/\+/g, ' ')); } catch (e) { return l.slice(i + 1); } }).filter(Boolean); if (!servers || !servers.nodes) return []; return (servers.list || []).filter(function(t) { return servers.nodes[t] && !servers.nodes[t].members; }).map(function(t) { return servers.nodes[t].name || t; }); } function syncAreas() { taLinks.value = draft.links; linksEd.set(draft.links); taSub.value = draft.sub; taDomains.value = draft.domains; taSubnets.value = draft.subnets; taLists.value = draft.lists; taByD.value = draft.byDomains; taByS.value = draft.bySubnets; } function touch() { var d = sig(draft) !== savedSig; if (d !== dirty) { dirty = d; renderSaveBar(); } } function set(k, v) { draft[k] = v; touch(); renderPanes(); } function configured() { return !!(cfg && (cfg.main_exists != null ? cfg.main_exists : cfg.exists)); } function secExists() { return !!(cfg && cfg.exists && cfg.sec !== 'new'); } function secLive() { return secExists() && cfg.enabled !== false && cfg.mode !== 'iface'; } function secs() { return (cfg && cfg.sections) || []; } function secName() { return String(draft.label || '').trim() || (draft.isMain ? 'Основная' : 'новая секция'); } function mainName() { var m = secs().filter(function(s) { return s.main; })[0]; return m ? m.label : 'Основная'; } function rememberSec(s) { curSec = s; try { localStorage.setItem('zm.forkozz.sec', cfg && s === cfg.main ? '' : s); } catch (e) {} } var loadGen = 0; function reload(after) { var g = ++loadGen; return Promise.all([ zm.forkopStatus().catch(function() { return st; }), getCfg(curSec).catch(function() { return null; }) ]).then(function(r) { if (g !== loadGen || saving || secBusy) return; st = r[0] || {}; if (r[1] !== null) cfg = validCfg(r[1]); if (cfg && curSec && cfg.sec !== curSec) { rememberSec(cfg.sec); dirty = false; } if (!dirty) { draft = withBootMode(fromCfg(cfg)); savedSig = sig(draft); syncAreas(); } else if (cfg) { draft.exclude = cfg.exclude || ''; var rf = refsOf(draft); var p = pickFromCfg({ catalog: cfg.catalog, refs: rf, lists: textLines(draft.lists).concat(rf.l) }); draft.items = p.items; draft.sel = p.sel; draft.lists = p.lists.join('\n'); taLists.value = draft.lists; savedSig = sig(withBootMode(fromCfg(cfg))); dirty = sig(draft) !== savedSig; } renderAll(); if (st.running && secLive()) loadServers(); loadSecState(st.running && !secProbed); if (after) after(); }); } var pingAfter = false; var sortPing = false; try { sortPing = localStorage.getItem('zm.fk.sort') === 'ping'; } catch (e) {} function connSig(d) { return [ d.mode, textLines(d.links).join('\n'), String(d.sub || '').trim(), d.iface || '', d.isMain || d.enabled ].join('\u0001'); } function finish(ok) { busy = false; var done = lastAct; lastAct = ''; var pa = ok && (pingAfter || done === 'subs'); pingAfter = false; zm.toast(ok ? (DONE_TEXT[done] || 'Готово') : 'Не получилось — смотрите журнал', ok ? 'info' : 'error'); diag = null; servers = null; if (ok) secProbed = false; reload(function() { if (ok && st.running && /^(apply|start|restart|update|install)$/.test(done)) runDiag(true); if (pa && st.running && secLive()) { zm.toast('Серверы обновлены — проверяем задержку', 'info'); setTimeout(testLatency, 2500); } }); } function follow() { busy = true; renderAll(); zm.pollJob('forkop', logEl, finish); } function act(action, arg) { if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } zm.forkopAction(action, arg || '').then(function(res) { if (res.error) { pingAfter = false; zm.toast(res.error, 'error'); return; } if (!res.started) { pingAfter = false; return; } lastAct = action; zm.toast((ACT_TEXT[action] || 'Выполняем') + ' — ход работы виден в окне вывода', 'warning'); follow(); try { logEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } var hideMode = false; function hideAct(arg) { if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return Promise.resolve(false); } return zm.forkopAction('hide', arg).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return false; } if (res.started) { lastAct = 'apply'; zm.toast('Применяем список серверов — ход работы виден в окне вывода', 'warning'); follow(); } else { zm.toast('Сохранено — применится, когда Forkozz будет включён', 'info'); reload(); } return true; }, function() { zm.toast('Роутер не ответил', 'error'); return false; }); } var hidePick = zm.hidePicker({ noun: 'серверы', busy: function() { return busy || saving; }, onMode: function(on) { hideMode = on; renderServers(); }, onSave: function(names, markers) { return hideAct(JSON.stringify({ sec: cfg && cfg.sec, names: names, markers: markers })); }, onReset: function() { return hideAct(JSON.stringify({ sec: cfg && cfg.sec, reset: true })); } }); function payload() { return JSON.stringify({ sec: draft.sec, label: String(draft.label || '').trim(), enabled: draft.isMain || draft.enabled, bypass: { domains: draft.byDomains, subnets: draft.bySubnets }, mode: draft.mode, links: draft.links, sub: draft.sub.trim(), sub_interval: draft.sub_interval, iface: draft.iface, fastest: true, refs: refsOf(draft), domains: draft.domains, exclude: draft.mode === 'iface' ? '' : draft.exclude, subnets: draft.subnets, lists: draft.lists, full: draft.full, excl: draft.excl, dns: { type: draft.dns.type, servers: draft.dns.servers, bootstraps: draft.dns.bootstraps, server: draft.dns.servers[0], bootstrap: draft.dns.bootstraps[0], detour: draft.dns.detour }, quic_off: draft.quic_off, list_interval: draft.list_interval, lists_via: draft.lists_via }); } function check() { if (draft.mode === 'links' && !linksEd.flush()) return [ 'conn', 'В поле «Добавить» есть строки, которые не похожи на ссылки — исправьте или очистите поле' ]; if (draft.mode === 'links' && !textLines(draft.links).length) return [ 'conn', 'Вставьте хотя бы одну ссылку на сервер' ]; if (draft.mode === 'sub' && !/^https?:\/\//i.test(draft.sub.trim())) return [ 'conn', 'Вставьте ссылку на подписку' ]; if (draft.mode !== 'iface' && zm.exclParse(draft.exclude).length) { var xn = exclNames(), xl = zm.exclParse(draft.exclude); if (xn.length && xn.every(function(n) { return zm.exclHit(n, xl); })) return [ 'conn', 'Под маркеры фильтра попадают все серверы — уберите лишние маркеры' ]; } if (draft.mode === 'iface' && !draft.iface) return [ 'conn', 'Выберите туннель' ]; var ln = String(draft.label || '').trim(); if (!draft.isMain && !ln) return [ 'conn', 'Дайте секции название — например, WARP или «Сервер в Европе»' ]; if (ln) { if (secs().some(function(s) { return s.name !== draft.sec && s.label.toLowerCase() === ln.toLowerCase(); })) return [ 'conn', 'Секция «' + ln + '» уже есть — выберите другое название' ]; } var rf = refsOf(draft); if (!rf.c.length && !rf.s.length && !rf.r.length && !rf.l.length && !textLines(draft.domains).length && !textLines(draft.subnets).length && !textLines(draft.lists).length && !(draft.isMain && draft.full.length)) return [ 'svc', draft.isMain ? 'Выберите хотя бы один сервис' : 'Выберите, что пускать через секцию «' + secName() + '»' ]; var badOwn = tokLines(draft.domains).map(domainProblem).filter(Boolean)[0]; if (badOwn) return [ 'svc', 'Свои домены: ' + badOwn ]; var badSub = tokLines(draft.subnets).filter(function(v) { return !/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(v); })[0]; if (badSub) return [ 'svc', 'Свои адреса: «' + badSub + '» не похоже на IP-адрес или подсеть' ]; var badBy = tokLines(draft.byDomains).map(domainProblem).filter(Boolean)[0]; if (badBy) return [ 'byp', 'Исключения: ' + badBy ]; var badBs = tokLines(draft.bySubnets).filter(function(v) { return !/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(v); })[0]; if (badBs) return [ 'byp', 'В исключениях «' + badBs + '» не похоже на IP-адрес или подсеть' ]; if (!draft.dns.servers.length) return [ 'set', 'Выберите хотя бы один DNS-сервер' ]; var badDns = draft.dns.servers.filter(function(v) { return !DNS_RE[draft.dns.type].test(v); })[0]; if (badDns) return [ 'set', 'DNS «' + badDns + '» не подходит для ' + DNS_TYPES[draft.dns.type] + '. ' + DNS_HELP[draft.dns.type].hint ]; return null; } function save(enable) { if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } var bad = check(); if (bad) { tabTo(bad[0]); zm.toast(bad[1], 'warning'); return; } saving = true; loadGen++; renderSaveBar(); var sent = sig(draft), connChanged = !configured() || !secExists() || connSig(draft) !== connSig(withBootMode(fromCfg(cfg))); zm.forkopConfigSet(payload()).then(function(res) { saving = false; if (res.error) { renderSaveBar(); zm.toast(res.error, 'error'); return; } if (res.sec && res.sec !== draft.sec) { draft.sec = res.sec; draft.isMain = !!(cfg && cfg.main_exists === false); rememberSec(res.sec); sent = sig(draft); } savedSig = sent; dirty = sig(draft) !== savedSig; renderSaveBar(); if (dirty) { zm.toast('Сохранено, но вы успели ещё что-то изменить — сохраните снова', 'warning'); return; } if (enable || st.enabled) { pingAfter = connChanged; act(st.enabled && st.running ? (connChanged ? 'restart' : 'apply') : 'start'); } else { zm.toast('Настройки сохранены', 'info'); reload(); } }).catch(function() { saving = false; renderSaveBar(); zm.toast('Роутер не ответил', 'error'); }); } function discard() { if (draft.sec === 'new') { switchSec(cfg.main || '', true); return; } draft = withBootMode(fromCfg(cfg)); savedSig = sig(draft); dirty = false; syncAreas(); renderAll(); } var secBusy = false; function switchSec(name, force) { if (secBusy) return; if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } if (name === draft.sec && !force) return; var secDirty = draft.sec === 'new' ? false : secSig(draft) !== secSig(withBootMode(fromCfg(cfg))); if (secDirty && !force && !confirm('В секции «' + secName() + '» есть несохранённые изменения.\n\nОтменить их и перейти к другой секции?')) return; var keep = {}; GLOBAL_KEYS.forEach(function(k) { keep[k] = draft[k]; }); secBusy = true; loadGen++; renderSections(); getCfg(name === (cfg && cfg.main) ? '' : name).then(function(c) { secBusy = false; c = validCfg(c); if (!c) { zm.toast('Не удалось загрузить секцию', 'error'); renderSections(); return; } cfg = c; rememberSec(c.sec); draft = withBootMode(fromCfg(cfg)); savedSig = sig(draft); GLOBAL_KEYS.forEach(function(k) { draft[k] = keep[k]; }); dirty = sig(draft) !== savedSig; servers = null; srvErr = ''; srvRetry = 0; hideMode = false; syncAreas(); if (tab !== 'conn' && tab !== 'svc') tabTo('conn'); renderAll(); if (st.running && secLive()) loadServers(); }).catch(function() { secBusy = false; renderSections(); zm.toast('Роутер не ответил', 'error'); }); } function deleteSec() { if (busy || saving) { zm.toast('Дождитесь окончания текущей операции', 'warning'); return; } if (!confirm('Удалить секцию «' + secName() + '»?\n\nЕё сервисы пойдут через другие секции, если они там выбраны, иначе — напрямую.')) return; zm.forkopAction('secdel', draft.sec).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } rememberSec(cfg.main || ''); dirty = false; if (res.started) { lastAct = 'restart'; zm.toast('Секция удалена — перезапускаем Forkozz', 'warning'); switchSec(cfg.main || '', true); follow(); } else { zm.toast('Секция удалена', 'info'); switchSec(cfg.main || '', true); } }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function renderSections() { secCard.innerHTML = ''; var list = secs(), show = !!st.installed && configured(), common = !isSecTab(tab); secCard.style.display = show ? '' : 'none'; secCard.classList.toggle('zm-sec-dim', common); if (!show) return; var isNew = draft.sec === 'new'; function open(name) { if (common) tabTo('conn'); switchSec(name); } secCard.appendChild(E('h3', {}, [ 'Секции ', badge(list.length > 1 || isNew ? 'zm-ok' : 'zm-off', nn(list.length + (isNew ? 1 : 0), 'секция', 'секции', 'секций')) ])); secCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, common ? 'Исключения, устройства и настройки действуют для всех секций сразу. Нажмите на секцию, чтобы вернуться к её подключению и сервисам.' : 'Каждая секция ведёт свои сервисы через своё подключение: например, YouTube — через сервер, а ChatGPT — через WARP. Нажмите на секцию, чтобы настроить её на вкладках «Подключение» и «Сервисы».')); var tiles = list.map(function(s) { var on = s.name === draft.sec, n = secSel(cfg, s), extra = (s.domains || 0) + (s.subnets || 0); var what = n ? nn(n, 'список', 'списка', 'списков') : extra ? nn(extra, 'адрес', 'адреса', 'адресов') : 'пусто'; return node(s.label, secConn(s), on, function() { open(s.name); }, E('span', { 'class': 'zm-lat ' + (!s.enabled ? 'zm-lat-bad' : n || extra ? 'zm-lat-good' : 'zm-lat-none') }, s.enabled ? what : 'выключена'), 'zm-sec' + (s.enabled ? '' : ' zm-node-dead')); }); if (isNew) tiles.push(node(secName(), 'ещё не сохранена', true, function() {}, E('span', { 'class': 'zm-lat zm-lat-mid' }, 'черновик'), 'zm-sec')); else if (list.length < SEC_MAX) tiles.push(E('div', { 'class': 'zm-node zm-sec zm-sec-add', 'title': 'Свои списки через другой VPN', 'click': function() { open('new'); } }, [ E('span', { 'class': 'zm-sec-add-text' }, 'Создать новую секцию') ])); secCard.appendChild(E('div', { 'class': 'zm-nodes' }, tiles)); if (list.length > 1 && !common) secCard.appendChild(E('p', { 'class': 'zm-hint' }, (mainName() === 'Основная' ? 'Основная секция' : 'Основная секция — «' + mainName() + '»') + ': через неё идут устройства «Всё через Forkozz», DNS и скачивание списков. Секции проверяются по порядку слева направо: если сайт попал в две, сработает левая. Один сервис можно включить только в одной секции. Исключения действуют на все секции сразу.')); } function tabTo(id) { tab = id; try { localStorage.setItem('zm.forkozz.tab', tab); } catch (e) {} renderTabs(); renderSections(); } function srvState() { if (!servers) return null; var ns = servers.nodes || {}, list = servers.list || []; var autoTag = list.filter(function(t) { return ns[t] && ns[t].members; })[0], auto = autoTag ? ns[autoTag] : null; var isAuto = !!(autoTag && servers.now === autoTag); var curTag = isAuto ? auto.now : servers.now; return { ns: ns, list: list, autoTag: autoTag, auto: auto, isAuto: isAuto, curTag: curTag, cur: ns[curTag] || {} }; } function connNow() { var s = srvState(); if (!s || !s.curTag) return ''; var hc = (cfg && cfg.hide_names || []).length; return (s.auto ? (s.isAuto ? 'авто · ' : 'вручную · ') : '') + nodeName(s.cur) + (hc ? ' · скрыто серверов: ' + hc : ''); } function connValue(c) { if (!c || !c.exists) return [ 'не настроено', 'выберите способ ниже' ]; if (c.mode === 'iface') return [ 'Туннель ' + c.iface, 'WireGuard / AmneziaWG' ]; var now = st.running ? connNow() : ''; if (c.mode === 'sub') { var t = servers && servers.sub && servers.sub.title ? subName(servers.sub.title) : ''; return [ t || 'Подписка', now || (t ? 'подписка' : 'серверы из подписки') ]; } var n = (c.links || []).length; return [ nn(n, 'сервер', 'сервера', 'серверов'), now || (n > 1 ? 'авто или вручную' : 'по ссылке') ]; } function routeValue(c) { if (!c || !c.main_exists) return [ '—', 'ничего не выбрано' ]; var n = 0, d = 0, f = (c.full || []).length, extra = [], bp = c.bypass || {}, bn = (bp.domains || []).length + (bp.subnets || []).length; (c.sections || []).forEach(function(s) { if (s.enabled) { n += secSel(c, s); d += s.domains || 0; } }); if (d) extra.push(nn(d, 'домен', 'домена', 'доменов')); if (f) extra.push(nn(f, 'устройство', 'устройства', 'устройств')); if (bn) extra.push('исключений: ' + bn); return [ n ? nn(n, 'список', 'списка', 'списков') : (extra.shift() || 'ничего'), extra.join(' · ') || 'только выбранное' ]; } function statusBadge() { if (busy) return badge('zm-warn', (ACT_TEXT[lastAct] || 'Работаем').toLowerCase() + '…'); if (!st.installed && st.legacy) return badge('zm-warn', st.running ? 'работает на прежнем движке' : 'нужен переход на NetShift'); if (!st.installed) return badge('zm-off', 'не установлен'); if (!st.enabled) return badge('zm-off', configured() ? 'выключен' : 'не настроен'); if (st.running) return badge('zm-ok', 'работает'); return badge('zm-bad', 'не работает'); } function stat(label, value, sub) { return E('div', { 'class': 'zm-st-stat' }, [ E('span', { 'class': 'zm-st-stat-label' }, label), E('span', { 'class': 'zm-st-stat-value', 'title': value }, [ String(value) ]), E('span', { 'class': 'zm-st-stat-sub', 'title': sub || '' }, [ String(sub || ' ') ]) ]); } function sbLeft() { var sbn = SB_NAMES[st.singbox_pkg] || 'установлен вручную'; if (st.singbox_users) return E('div', { 'class': 'zm-sb-left' }, [ E('div', { 'class': 'zm-sb-left-text' }, [ E('b', {}, [ 'sing-box ' + String(st.singbox).replace(/-extended.*$/, '') + ' · ' + sbn ]), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ 'Он нужен другим программам (' + st.singbox_users + ') — панель его не удаляет. Если поставить Forkozz, он возьмёт этот sing-box.' ]) ]) ]); return E('div', { 'class': 'zm-sb-left' }, [ E('div', { 'class': 'zm-sb-left-text' }, [ E('b', {}, 'Остался sing-box ' + st.singbox.replace(/-extended.*$/, '') + ' · ' + sbn), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, 'Forkozz не установлен, а sing-box остался. Без Forkozz он не нужен — его можно удалить вместе с настройками. Если поставить Forkozz, он возьмёт этот sing-box.') ]), E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить sing-box ' + st.singbox + ' (' + sbn + ')?\n\nБудут удалены пакет, файл программы, её настройки и кэш.')) return; act('singbox_remove'); } }, 'Удалить sing-box') ]); } function loadSecState(probe) { if (!st.installed || !configured()) { secState = null; return; } if (probe) { secProbe = true; secProbed = true; } zm.forkopAction('secstate', probe ? 'probe' : '').then(function(r) { if (probe) secProbe = false; if (r && !r.error && r.sections) secState = r; if (!busy) renderMain(); }).catch(function() { if (probe) secProbe = false; }); } function ago(sec) { return sec < 60 ? sec + ' с назад' : sec < 3600 ? Math.floor(sec / 60) + ' мин назад' : Math.floor(sec / 3600) + ' ч назад'; } function secListsText(s) { var p = secPick(cfg, s), names = p.items.filter(function(it) { return p.sel[it.id]; }).map(function(it) { return it.name; }), parts = []; if (names.length) parts.push(names.slice(0, 2).join(', ') + (names.length > 2 ? ' +' + (names.length - 2) : '')); if ((p.lists || []).length) parts.push(nn(p.lists.length, 'свой список', 'своих списка', 'своих списков')); if (s.domains) parts.push(nn(s.domains, 'домен', 'домена', 'доменов')); if (s.subnets) parts.push(nn(s.subnets, 'подсеть', 'подсети', 'подсетей')); return parts.join(' · ') || 'ничего не выбрано'; } function secConnText(s, x) { if (s.mode === 'iface') { var warp = /^zmwarp/.test(s.iface || ''), v = (warp ? 'WARP · ' : 'Туннель · ') + (s.iface || '—'); if (x && x.probed && x.delay > 0) v += ' · ' + x.delay + ' мс'; return [ v, x && x.up === false ? 'интерфейс не поднят' : x && x.hs > 0 ? 'рукопожатие ' + ago(x.hs) : x && x.hs === -1 ? 'рукопожатия ещё не было' : (warp ? 'WARP-туннель' : 'WireGuard / AmneziaWG') ]; } var n = (x && x.count) || s.links || 0, srv = x && x.server ? x.server + (x.delay > 0 ? ' · ' + x.delay + ' мс' : '') : ''; var how = n > 1 ? (x && x.auto ? 'авто из ' : 'вручную из ') + nn(n, 'сервера', 'серверов', 'серверов') : n === 1 ? 'один сервер' : ''; if (s.mode === 'sub') return [ srv || 'Подписка', [ x && x.sub ? 'подписка «' + subName(x.sub) + '»' : 'подписка', how ].filter(Boolean).join(' · ') ]; return [ srv || (n ? nn(n, 'сервер', 'сервера', 'серверов') : 'серверы'), how || 'по ссылке' ]; } function secStateBadge(s, x) { if (!s.enabled) return badge('zm-off', 'выключена'); if (!st.enabled) return badge('zm-off', 'Forkozz выключен'); if (!x) return badge('zm-off', st.running ? 'проверяем…' : 'не работает'); if (x.state === 'ok') return badge('zm-ok', 'работает'); if (x.state === 'bad') return badge('zm-bad', s.mode === 'iface' ? 'туннель не отвечает' : 'сервер не отвечает'); if (x.state === 'stopped') return badge('zm-bad', 'не работает'); if (x.state === 'wait') return badge('zm-warn', 'запускается'); return badge('zm-off', secProbe ? 'проверяем…' : 'нет данных'); } function secBox(s) { var x = secState && (secState.sections || []).filter(function(e) { return e.name === s.name; })[0]; var c = secConnText(s, x), l = secListsText(s); return E('div', { 'class': 'zm-st-stat zm-fk-sec' + (s.name === draft.sec ? ' zm-fk-sec-cur' : ''), 'title': 'Открыть секцию «' + s.label + '»', 'click': function() { if (!isSecTab(tab)) tabTo('conn'); if (s.name !== draft.sec) switchSec(s.name); } }, [ E('span', { 'class': 'zm-fk-sec-head' }, [ E('span', { 'class': 'zm-st-stat-label' }, [ s.label ]), secStateBadge(s, x) ]), E('span', { 'class': 'zm-st-stat-value', 'title': c[0] }, [ c[0] ]), E('span', { 'class': 'zm-st-stat-sub', 'title': c[1] }, [ c[1] ]), E('span', { 'class': 'zm-st-stat-sub', 'title': l }, [ l ]) ]); } function commonBox() { var bp = cfg.bypass || {}, bn = (bp.domains || []).length + (bp.subnets || []).length, f = (cfg.full || []).length, e = (cfg.excl || []).length; var dev = [ f ? 'всё через Forkozz: ' + f : '', e ? 'мимо Forkozz: ' + e : '' ].filter(Boolean).join(' · ') || 'устройства по спискам'; var sb = st.singbox ? 'sing-box ' + st.singbox.replace(/-extended.*$/, '') : 'sing-box не установлен'; var dn = cfg.dns ? dnsLabel(cfg.dns) : '—', dt = cfg.dns ? (DNS_TYPES[cfg.dns.type] || '') + (cfg.dns.detour ? ' · через VPN' : '') : ''; return E('div', { 'class': 'zm-st-stat zm-fk-common' }, [ E('span', { 'class': 'zm-fk-sec-head' }, [ E('span', { 'class': 'zm-st-stat-label' }, 'Общее'), E('span', { 'class': 'zm-st-stat-sub' }, 'для всех секций') ]), E('span', { 'class': 'zm-st-stat-value', 'title': 'DNS ' + dn }, [ 'DNS ' + dn + (dt ? ' · ' + dt : '') ]), E('span', { 'class': 'zm-st-stat-sub' }, [ (bn ? 'исключений: ' + bn : 'без исключений') + ' · ' + dev ]), E('span', { 'class': 'zm-st-stat-sub', 'title': sb }, [ 'NetShift ' + (st.version || '—') + ' · ' + sb ]) ]); } function renderMain() { hostsWarn.set(!!(st && st.installed && st.hosts_extra)); mainCard.innerHTML = ''; if (st.blocker && !st.installed && !busy) { zm.blockCard(mainCard, 'Forkozz', 'Выбранные сервисы идут через ваш сервер, подписку или туннель. Остальное — напрямую.', st.blocker_text || st.blocker); if (st.singbox) mainCard.appendChild(sbLeft()); return; } mainCard.appendChild(E('h3', {}, 'Forkozz')); mainCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Выбранные сервисы идут через ваш сервер, подписку или туннель. Остальное — напрямую.')); if (st.installed && st.enabled && st.watch > 0 && !st.running) mainCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ Forkozz включён, но не работает — сторож уже перезапускал его ' + st.watch + ' раз. Посмотрите «Проверку» и журнал.')); if (st.installed && st.enabled) (st.warn || []).forEach(function(w) { if (WARN[w]) mainCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ ' + WARN[w])); }); mainCard.appendChild(row('Состояние', E('span', { 'style': 'display:inline-flex; gap:8px; flex-wrap:wrap' }, [ statusBadge(), st.installed && st.newer && !busy ? badge('zm-warn', 'есть версия ' + st.latest) : E([]) ]))); if (st.installed && configured() && secs().length) { mainCard.appendChild(E('div', { 'class': 'zm-st-stats zm-fk-secs' }, secs().map(secBox).concat([ commonBox() ]))); } else if (st.installed) { var sl = secs(), cv, rv = routeValue(cfg); if (sl.length > 1) cv = [ nn(sl.length, 'секция', 'секции', 'секций'), sl.map(function(s) { return s.label + (s.enabled ? '' : ' (выкл.)'); }).join(' · ') ]; else if (cfg && cfg.is_main) cv = connValue(cfg); else cv = sl.length ? [ secConn(sl[0]), sl[0].label ] : connValue(null); var sb = st.singbox ? 'sing-box ' + st.singbox.replace(/-extended.*$/, '') + (SB_NAMES[st.singbox_pkg] ? ' · ' + SB_NAMES[st.singbox_pkg] : '') : 'sing-box не установлен'; mainCard.appendChild(E('div', { 'class': 'zm-st-stats zm-st-stats-4' }, [ stat('Подключение', cv[0], cv[1]), stat('Через Forkozz', rv[0], rv[1]), stat('DNS', cfg && cfg.dns ? dnsLabel(cfg.dns) : '—', cfg && cfg.dns ? (DNS_TYPES[cfg.dns.type] || '') + (cfg.dns.servers && cfg.dns.servers.length > 1 ? ' · автопереключение' : '') + (cfg.dns.detour ? ' · через VPN' : '') : ''), stat('Версия', 'NetShift ' + (st.version || '—'), sb) ])); } if (st.foreign && !busy) { mainCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin-top:14px' }, [ E('span', {}, 'Стоит старая версия с LuCI и сторонними зеркалами. Перевод на Zapret Manager уберёт их и сохранит настройки.'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('install'); } }, 'Перевести') ])); } if (busy) { mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Можно закрыть страницу — роутер всё доделает сам.')); return; } var b = []; if (!st.installed && st.legacy) { mainCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'style': 'margin:4px 0 12px' }, [ E('span', {}, 'Forkozz переходит на новый движок — NetShift (github.com/yandexru45/netshift). Подключения, секции, списки, свои домены, исключения, устройства и DNS перенесутся сами. Займёт около минуты.'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('install'); } }, 'Перейти на NetShift') ])); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Пока перехода не было, Forkozz работает на прежнем движке как раньше, но настройки здесь не меняются. keyword: и regex: в своих доменах NetShift не понимает — такие строки при переходе пропустятся, а журнал покажет какие.')); return; } if (!st.installed) { b.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('install'); } }, 'Установить')); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, b)); mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Около минуты. Нужно ~15 МБ свободной памяти. Forkozz работает на NetShift; всё скачивается только из официальных источников: GitHub и репозиторий OpenWrt. DNS по умолчанию — Quad9 (9.9.9.9).' + (st.saved ? ' Прежние настройки вернутся.' : ''))); if (st.singbox) mainCard.appendChild(sbLeft()); return; } if (st.enabled) { b.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() { act('restart'); } }, 'Перезапустить')); b.push(E('button', { 'class': 'cbi-button', 'click': function() { act('stop'); } }, 'Выключить')); } else { b.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': st.blocker ? '' : null, 'title': st.blocker ? (st.blocker_text || '') : '', 'click': function() { if (!configured()) { tabTo('conn'); zm.toast('Сначала настройте подключение и сервисы', 'warning'); return; } if (dirty) { save(true); return; } act('start'); } }, 'Включить')); } if (st.newer) b.push(E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { act('update'); } }, 'Обновить до ' + st.latest)); b.push(E('button', { 'class': 'cbi-button cbi-button-remove', 'click': function() { if (!confirm('Удалить Forkozz полностью?\n\nБудут удалены Forkozz, его настройки, правила файрвола и маршрутизации, задания в cron и sing-box. Трафик пойдёт напрямую.')) return; act('remove'); } }, 'Удалить')); mainCard.appendChild(E('div', { 'class': 'zm-actions' }, b)); if (!configured()) mainCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Три шага: подключение → сервисы → «Сохранить и включить».')); } function renderTabs() { tabBar.innerHTML = ''; var show = !!st.installed; tabBar.style.display = show ? '' : 'none'; [ false, true ].forEach(function(common) { tabBar.appendChild(E('div', { 'class': 'zm-actions zmw-tabs zm-tabgrp' }, TABS.filter(function(t) { return !!t.common === common; }).map(function(t) { return E('button', { 'class': 'cbi-button' + (t.id === tab ? ' cbi-button-positive' : ''), 'click': function() { tabTo(t.id); } }, t.label); }))); }); Object.keys(panes).forEach(function(k) { panes[k].style.display = show && k === tab ? '' : 'none'; }); saveBar.style.display = show && (dirty || !configured() || draft.sec === 'new') ? '' : 'none'; } function renderSaveBar() { saveBar.innerHTML = ''; var isNew = draft.sec === 'new'; var show = !!st.installed && (dirty || !configured() || isNew); saveBar.style.display = show ? '' : 'none'; zm.dock(saveBar, show); if (!show) return; var btns = []; if ((dirty && configured()) || isNew) btns.push(E('button', { 'class': 'cbi-button', 'disabled': saving ? '' : null, 'click': discard }, 'Отменить')); if (!st.enabled) btns.push(E('button', { 'class': 'cbi-button', 'disabled': saving || busy ? '' : null, 'click': function() { save(false); } }, 'Только сохранить')); btns.push(E('button', { 'class': 'cbi-button cbi-button-positive', 'disabled': saving || busy ? '' : null, 'click': function() { save(true); } }, saving ? 'Сохраняем…' : st.enabled ? 'Сохранить и применить' : 'Сохранить и включить')); saveBar.appendChild(E('span', { 'class': 'zm-savebar-dot' })); saveBar.appendChild(E('span', { 'class': 'zm-savebar-text' }, busy ? 'Дождитесь окончания операции' : isNew ? 'Новая секция «' + secName() + '»: выберите подключение и сервисы, затем сохраните' : dirty ? 'Есть несохранённые изменения' + (secs().length > 1 && secSig(draft) !== secSig(withBootMode(fromCfg(cfg))) ? ' в секции «' + secName() + '»' : '') : 'Настройте подключение и сервисы, затем сохраните')); saveBar.appendChild(E('div', { 'class': 'zm-savebar-btns' }, btns)); } function renderLinkCount() { var l = textLines(draft.links), bad = l.filter(function(x) { return !LINK_RE.test(x); }).length; linkCountEl.innerHTML = ''; if (!l.length) { linkCountEl.appendChild(badge('zm-off', 'ссылок нет')); return; } linkCountEl.appendChild(bad ? badge('zm-warn', 'не распознано: ' + bad) : badge('zm-ok', nn(l.length, 'сервер', 'сервера', 'серверов'))); } function sw(on, label, hint, click) { return E('div', { 'class': 'zm-row', 'style': 'cursor:pointer; align-items:flex-start; flex-wrap:nowrap; margin:12px 0', 'click': click }, [ E('div', { 'class': 'zm-switch' + (on ? ' zm-switch-on' : ''), 'style': 'margin-top:1px' }, [ E('span') ]), E('div', {}, [ E('div', { 'style': 'font-weight:600' }, label), hint ? E('div', { 'class': 'zm-hint', 'style': 'margin-top:2px' }, hint) : E([]) ]) ]); } function seg(items, cur, pick) { return E('div', { 'class': 'zm-seg' }, items.map(function(it) { return E('div', { 'class': 'zm-seg-item' + (it.id === cur ? ' zm-active' : ''), 'click': function() { if (it.id !== cur) pick(it.id); } }, it.label); })); } function node(name, foot, on, click, right, cls) { return E('div', { 'class': 'zm-node' + (on ? ' zm-active' : '') + (cls ? ' ' + cls : ''), 'click': click }, [ E('div', { 'class': 'zm-node-name' }, [ String(name) ]), E('div', { 'class': 'zm-node-foot' }, [ E('span', {}, [ String(foot || '') ]), right || E('span') ]) ]); } function renderConn() { connCard.innerHTML = ''; var many = secs().length > 1 || draft.sec === 'new'; connCard.appendChild(E('h3', {}, many ? 'Подключение · «' + secName() + '»' : 'Подключение')); { var nameIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'maxlength': '60', 'placeholder': draft.isMain ? 'Основная' : 'WARP, Европа, Игры…', 'style': 'flex:1; min-width:180px; max-width:340px' }); nameIn.value = draft.label || ''; nameIn.addEventListener('input', function() { draft.label = nameIn.value; touch(); renderSaveBar(); }); nameIn.addEventListener('change', function() { renderSections(); renderSvc(); }); connCard.appendChild(row('Название', nameIn)); if (!draft.isMain) connCard.appendChild(sw(draft.enabled, 'Секция включена', draft.enabled ? 'Выбранные в ней сервисы идут через её подключение.' : 'Секция сохранена, но не работает — её сервисы идут напрямую или через другие секции.', function() { set('enabled', !draft.enabled); renderSections(); })); } connCard.appendChild(row('Способ', seg(MODES, draft.mode, function(m) { set('mode', m); }))); if (draft.mode === 'links') { connCard.appendChild(linksEd); connCard.appendChild(row('Распознано', linkCountEl)); renderLinkCount(); connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ссылки vless, vmess, trojan, ss, socks5, hysteria2 — можно сразу несколько, по одной на строку. Серверы с XHTTP и VMess — только с расширенным sing-box. После правок нажмите «Сохранить» внизу.')); connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Если серверов несколько, выбирать между ними — в карточке «Серверы» ниже.')); } else if (draft.mode === 'sub') { connCard.appendChild(taSub); connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ссылка на подписку от VPN-сервиса, обычно https://…/sub/…. Выбор сервера — в карточке «Серверы» ниже.')); connCard.appendChild(row('Обновлять серверы', seg(SUB_IV, draft.sub_interval, function(v) { set('sub_interval', v); }))); } else { var tl = (cfg && cfg.tunnels) || []; if (draft.iface && !tl.some(function(t) { return t.name === draft.iface; })) tl = tl.concat([ { name: draft.iface, up: false, missing: true } ]); if (tl.length) connCard.appendChild(E('div', { 'class': 'zm-nodes' }, tl.map(function(t) { var up = t.up && !t.missing; return node(t.name, t.missing ? 'не найден' : t.steer ? 'туннель Steer' : 'туннель', draft.iface === t.name, function() { if (draft.iface !== t.name) set('iface', t.name); }, E('span', { 'class': 'zm-lat ' + (up ? 'zm-lat-good' : 'zm-lat-bad') }, up ? 'поднят' : 'выключен')); }))); else connCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Туннелей WireGuard или AmneziaWG пока нет.')); connCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Создать туннель WARP или свой можно на вкладке ', E('a', { 'href': pageLink('awg') }, 'AmneziaWG'), '.' ])); } if (!draft.isMain && secExists()) connCard.appendChild(E('div', { 'class': 'zm-actions', 'style': 'margin-top:14px' }, [ E('button', { 'class': 'cbi-button cbi-button-remove', 'disabled': busy || saving ? '' : null, 'click': deleteSec }, 'Удалить секцию') ])); } var srvRetry = 0; function loadServers() { if (srvBusy || !secLive()) return; srvBusy = true; renderServers(); var forSec = cfg && cfg.sec; zm.forkopAction('servers', forSec || '').then(function(res) { if (!cfg || cfg.sec !== forSec) { srvBusy = false; return; } srvBusy = false; if (res.error) { servers = null; srvErr = res.error; if (srvRetry < 5 && st.running) { srvRetry++; setTimeout(function() { if (!servers) loadServers(); }, 4000); } } else { servers = res; srvErr = ''; srvRetry = 0; } renderServers(); renderMain(); }).catch(function() { srvBusy = false; srvErr = 'роутер не ответил'; renderServers(); }); } function testLatency() { if (latBusy) return; latBusy = true; renderServers(); zm.toast('Проверяем задержку…', 'warning'); zm.forkopAction('latency', (cfg && cfg.sec) || '').then(function(res) { latBusy = false; if (res.error) zm.toast(res.error, 'error'); else zm.toast('Задержка проверена', 'info'); loadServers(); }).catch(function() { latBusy = false; renderServers(); zm.toast('Роутер не ответил', 'error'); }); } function pickServer(tag, name) { zm.forkopAction('select', ((cfg && cfg.sec) || '') + '|' + tag).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } zm.toast('Выбрано: ' + name, 'info'); loadServers(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } function nodeName(n) { var f = flag(n.country), nm = n.name || n.tag; return f && nm.indexOf(f) !== 0 ? f + ' ' + nm : nm; } function renderServers() { srvCard.innerHTML = ''; var show = st.installed && st.running && secLive() && draft.mode === cfg.mode && !busy; srvCard.style.display = show ? '' : 'none'; if (!show) return; srvCard.appendChild(E('h3', {}, 'Серверы')); if (!servers) { srvCard.appendChild(E('p', { 'class': 'zm-hint' }, srvBusy ? 'Загружаем список…' : (srvErr || 'Список ещё не загружен.'))); if (!srvBusy) srvCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'click': loadServers }, 'Обновить') ])); if (!hideMode && cfg && ((cfg.hide_names || []).length || cfg.exclude)) { hidePick.update([], { markers: cfg.exclude || '', hideNames: cfg.hide_names || [], noEdit: true }); srvCard.appendChild(hidePick); } return; } var S = srvState(), ns = S.ns, autoTag = S.autoTag, auto = S.auto, isAuto = S.isAuto, curTag = S.curTag, cur = S.cur; var manual = S.list.filter(function(t) { return t !== autoTag && !(ns[t] && ns[t].hidden); }); var sub = servers.sub; if (sub && (sub.title || sub.used != null || sub.expire)) { if (sub.title) srvCard.appendChild(row('Подписка', E('b', {}, [ subName(sub.title) ]))); if (sub.used != null) srvCard.appendChild(row('Трафик', E('span', {}, fmtBytes(sub.used) + (sub.total ? ' из ' + fmtBytes(sub.total) : sub.unlimited ? ' · без лимита' : '')))); if (sub.expire) srvCard.appendChild(row('Действует до', E('span', {}, fmtDate(sub.expire)))); } if (servers.fell && (isAuto || nodeName(cur) !== servers.fell)) srvCard.appendChild(E('div', { 'class': 'zm-refresh-banner zm-show', 'role': 'note' }, [ E('span', {}, [ 'Сервер «', String(servers.fell), '» перестал отвечать — Forkozz сам переключился на ' + (isAuto ? 'автовыбор. Чтобы вернуть его, переключите режим на «Вручную» и выберите его в списке.' : 'другой рабочий сервер. Чтобы вернуть его, нажмите на него в списке.') ]) ])); if (auto) srvCard.appendChild(row('Режим', seg([ { id: 'auto', label: 'Авто' }, { id: 'manual', label: 'Вручную' } ], isAuto ? 'auto' : 'manual', function(m) { if (m === 'auto') pickServer(autoTag, 'автовыбор'); else if (curTag) pickServer(curTag, nodeName(cur)); }))); srvCard.appendChild(row('Сейчас', E('span', { 'style': 'display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap' }, [ E('b', {}, [ curTag ? nodeName(cur) : '—' ]), cur.type ? E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, [ String(cur.type).toLowerCase() ]) : E([]), cur.delay >= 0 ? E('span', { 'class': 'zm-lat ' + (cur.delay === 0 ? 'zm-lat-bad' : latClass(cur.delay)) }, latText(cur.delay)) : E([]) ]))); var tags = (isAuto ? (auto.members || []) : manual).filter(function(t) { return !(ns[t] && ns[t].hidden); }); var hn = servers.hide_names || [], shown = {}; var hpItems = S.list.filter(function(t) { return t !== autoTag && ns[t] && !ns[t].members; }).map(function(t) { var n = ns[t], nm = n.name || t, byName = hn.indexOf(nm) >= 0; shown[nm] = true; return { name: nm, foot: String(n.type || '').toLowerCase(), hidden: byName, locked: !!n.hidden && !byName }; }); hn.forEach(function(nm) { if (!shown[nm]) hpItems.push({ name: nm, foot: 'скрыт', hidden: true, locked: false }); }); hidePick.update(hpItems, { markers: servers.exclude || '', hideNames: hn }); if (hideMode) { srvCard.appendChild(hidePick); return; } if (sortPing) { var rank = function(t) { var d = ns[t] ? ns[t].delay : -1; return d > 0 ? d : d === 0 ? 1e9 : 1e8; }; tags = tags.map(function(t, i) { return { t: t, i: i }; }).sort(function(a, b) { return rank(a.t) - rank(b.t) || a.i - b.i; }).map(function(x) { return x.t; }); } srvCard.appendChild(E('div', { 'class': 'zm-nodes', 'style': 'margin-top:12px' }, tags.map(function(t) { var n = ns[t] || { name: t, delay: -1 }, on = t === curTag; return node(nodeName(n), String(n.type || '').toLowerCase(), on, function() { if (!on) pickServer(t, nodeName(n)); }, E('span', { 'class': 'zm-lat ' + (n.delay === 0 ? 'zm-lat-bad' : latClass(n.delay)) }, latText(n.delay)), n.delay === 0 ? 'zm-node-dead' : ''); }))); var acts = [ E('button', { 'class': 'cbi-button', 'disabled': latBusy ? '' : null, 'click': testLatency }, latBusy ? 'Проверяем…' : 'Проверить задержку') ]; if (cfg.mode === 'sub') acts.push(E('button', { 'class': 'cbi-button', 'click': function() { act('subs'); } }, 'Обновить серверы подписки')); srvCard.appendChild(E('div', { 'class': 'zm-actions' }, acts)); if (tags.length > 1) srvCard.appendChild(zm.swRow(sortPing, 'Сортировать по пингу', 'Серверы идут от быстрых к медленным, неотвечающие — в конце.', function() { sortPing = !sortPing; try { localStorage.setItem('zm.fk.sort', sortPing ? 'ping' : 'list'); } catch (e) {} var tested = tags.some(function(t) { return ns[t] && ns[t].delay >= 0; }); renderServers(); if (sortPing && !tested && !latBusy) testLatency(); })); srvCard.appendChild(E('p', { 'class': 'zm-hint' }, isAuto ? 'Авто: Forkozz сам выбирает лучший из этих серверов. Нажмите на сервер, чтобы закрепить его.' : 'Нажмите на сервер, чтобы переключиться. Выбор держится до перезагрузки роутера.')); srvCard.appendChild(hidePick); } function catalogBlock() { var c = (cfg && cfg.catalog && cfg.catalog.meta) || {}, box = E('div', { 'class': 'zm-cat-src' }); var state = c.off ? 'выключен — показаны только встроенные сервисы' : c.busy ? 'обновляется…' : c.version ? 'версия ' + c.version + ' · пунктов: ' + c.services + (c.error ? ' · последнее обновление не удалось' : '') : c.error ? 'не скачался — проверьте доступ к GitHub' : 'ещё не скачан'; function call(action, arg, okText, after) { zm.steerAction(action, arg).then(function(res) { if (res.error) { zm.toast(res.error, 'error'); return; } if (okText) zm.toast(okText, 'info'); if (after) after(); }).catch(function() { zm.toast('Роутер не ответил', 'error'); }); } box.appendChild(E('div', { 'class': 'zm-cat-src-info' }, [ E('div', { 'class': 'zm-cat-src-title' }, [ 'Каталог списков ', E('span', { 'class': 'zm-badge ' + (c.off ? 'zm-off' : c.error ? 'zm-warn' : c.version ? 'zm-ok' : 'zm-off') }, c.off ? 'выключен' : c.busy ? 'обновляется' : c.error ? (c.version ? 'не обновился' : 'не скачан') : c.version ? 'подключён' : 'нет') ]), E('div', { 'class': 'zm-cat-src-state' }, state), E('div', { 'class': 'zm-cat-src-url' }, [ String(c.url || '') ]) ])); var b = []; if (!c.off) b.push(E('button', { 'class': 'cbi-button', 'click': function() { call('catalog_refresh', '', 'Обновляем каталог', function() { setTimeout(function() { reload(); }, 5000); }); } }, 'Обновить')); if (!c.off) b.push(E('button', { 'class': 'cbi-button', 'click': function() { var u = prompt('Ссылка на свой каталог списков (lists.json в формате splify2-lists, например из форка).\n\nПусто — каталог по умолчанию.', c.custom ? c.url : ''); if (u === null) return; zm.toast('Скачиваем каталог…', 'info'); call('catalog_src', u.trim(), 'Каталог подключён', function() { reload(); }); } }, 'Сменить источник')); b.push(E('button', { 'class': 'cbi-button', 'click': function() { call('catalog_src', c.off ? 'on' : 'off', '', function() { reload(); }); } }, c.off ? 'Включить каталог' : 'Выключить каталог')); box.appendChild(E('div', { 'class': 'zm-actions zm-cat-src-actions' }, b)); return box; } function listsNow() { var ok = configured() && st.running; return E('div', { 'class': 'zm-lists-now' }, [ E('div', { 'class': 'zm-lists-now-text' }, [ E('b', {}, 'Списки сервисов'), E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, ok ? 'Обновляются сами раз в ' + ((LIST_IV.filter(function(x) { return x.id === ((cfg && cfg.list_interval) || '1d'); })[0] || {}).label || '24 ч') + '. Можно скачать заново сейчас — в окне вывода будет видно, что скачалось, применилось и работает.' : 'Скачиваются, когда Forkozz включён.') ]), E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': !ok || busy || dirty ? '' : null, 'title': dirty ? 'Сначала сохраните изменения' : '', 'click': function() { act('lists'); } }, 'Обновить списки сейчас') ]); } var takenMemo = null; function takenMap() { var key = cfg ? JSON.stringify([ draft.sec, secs() ]) : ''; if (takenMemo && takenMemo.key === key) return takenMemo.map; var map = {}, ri = {}, shadow = [], list = secs(), cur = -1; list.forEach(function(s, i) { if (s.name === draft.sec) cur = i; }); if (cur < 0) cur = list.length; list.forEach(function(s, i) { if (s.name === draft.sec) return; var p = secPick(cfg, s); Object.keys(p.sel).forEach(function(id) { if (p.sel[id] && id.indexOf('old:') !== 0 && !map[id]) map[id] = s.label; }); if (!s.enabled) return; if (i < cur && p.sel.russia_inside) ri.label = ri.label || s.label; if (i > cur) p.items.forEach(function(it) { if (p.sel[it.id] && !it.group && it.id !== 'russia_inside' && zm.riCovers(it.id)) shadow.push(it.name + ' в «' + s.label + '»'); }); }); takenMemo = { key: key, map: map, ri: ri.label || '', shadow: shadow }; return map; } function riAbove() { takenMap(); return takenMemo.ri; } function svcTile(s) { var by = takenMap()[s.id], ra = riAbove(); var shadowed = !by && ra && !s.group && s.id !== 'russia_inside' && zm.riCovers(s.id); if (shadowed && !draft.sel[s.id]) return zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'входит в «Всё сразу» секции «' + ra + '»', cls: 'zm-svc-taken', title: s.name + ' уже идёт через «Всё сразу» секции «' + ra + '»', click: function() { zm.toast('«' + s.name + '» входит в «Всё сразу» секции «' + ra + '». Та секция выше и срабатывает первой — чтобы пустить «' + s.name + '» здесь, выключите «Всё сразу» там.', 'warning'); } }); if (by && !draft.sel[s.id]) return zm.svcCard({ name: s.name, key: s.id, on: false, sub: 'в секции «' + by + '»', cls: 'zm-svc-taken', title: s.name + ' уже идёт через секцию «' + by + '»', click: function() { zm.toast('«' + s.name + '» уже идёт через секцию «' + by + '». Сначала выключите его там — один сервис работает только в одной секции.', 'warning'); } }); return zm.svcCard({ name: s.name, key: s.id, on: !!draft.sel[s.id], sub: by ? 'и в секции «' + by + '» — сработает верхняя' : shadowed ? 'перекрыт «Всё сразу» секции «' + ra + '»' : '', inc: !!draft.sel.russia_inside && s.id !== 'russia_inside' && !s.group && zm.riCovers(s.id), click: function() { var nsel = {}; for (var k in draft.sel) if (draft.sel[k]) nsel[k] = true; if (nsel[s.id]) delete nsel[s.id]; else nsel[s.id] = true; set('sel', nsel); } }); } function renderSvc() { svcCard.innerHTML = ''; var list = draft.items || [], sel = draft.sel || {}, n = selCount(draft); var many = secs().length > 1 || draft.sec === 'new'; svcCard.appendChild(E('h3', {}, [ many ? 'Что пускать через «' + secName() + '» ' : 'Что пускать через Forkozz ', n ? badge('zm-ok', 'выбрано ' + n) : badge('zm-off', 'ничего') ])); if (many) svcCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, [ 'Подключение этой секции: ', E('b', {}, draft.mode === 'iface' ? (draft.iface ? (/^zmwarp/.test(draft.iface) ? 'WARP · ' : 'туннель ') + draft.iface : 'туннель не выбран') : draft.mode === 'sub' ? 'подписка' : nn(textLines(draft.links).length, 'сервер', 'сервера', 'серверов')), '. Сервисы, выбранные в других секциях, помечены их названием.' ])); svcCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Нажмите на пункт, чтобы включить или выключить его, и затем «Сохранить». Списки те же, что в Steer: берутся из каталога списков (itdoginfo/allow-domains, b4geoip и другие) и обновляются сами.')); var remote = list.filter(function(s) { return !!s.group; }); var svcs = list.filter(function(s) { return !s.group && CATEGORY_IDS.indexOf(s.id) < 0; }); var cats = list.filter(function(s) { return !s.group && CATEGORY_IDS.indexOf(s.id) >= 0; }); svcCard.appendChild(E('h4', { 'style': 'margin:0' }, 'Сервисы')); svcCard.appendChild(zm.svcGrid(svcs.map(svcTile))); if (cats.length) { svcCard.appendChild(E('h4', { 'style': 'margin:18px 0 0' }, 'Категории')); svcCard.appendChild(zm.svcGrid(cats.map(svcTile))); if (sel.russia_inside) svcCard.appendChild(zm.riNote(list, sel)); if (sel.russia_inside && takenMemo && takenMemo.shadow.length) svcCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ «Всё сразу» здесь перекроет: ' + takenMemo.shadow.join(', ') + '. Эта секция выше, поэтому такие сервисы пойдут через неё.')); else svcCard.appendChild(E('p', { 'class': 'zm-hint' }, '«Всё сразу» — полный список Russia inside: все категории и сервисы одним набором. Он большой, на слабых роутерах лучше включать отдельные пункты.')); } var groups = []; remote.forEach(function(s) { if (groups.indexOf(s.group) < 0) groups.push(s.group); }); groups.sort(function(a, b) { return (a === OLD_GROUP) - (b === OLD_GROUP); }); if (groups.length) { svcCard.appendChild(E('h4', { 'style': 'margin:20px 0 4px' }, 'Дополнительные списки')); svcCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin:0 0 10px' }, 'Готовые наборы доменов и подсетей из каталога. Разверните источник и отметьте нужное.')); } groups.forEach(function(g) { var items = remote.filter(function(s) { return s.group === g; }); var on = items.filter(function(s) { return sel[s.id]; }).length; var m = /^(.*?)\s*\((.*)\)\s*$/.exec(g); svcCard.appendChild(zm.svcGroup({ name: m ? m[1] : g, sub: m ? m[2] : '', key: g, open: g in openGroups ? openGroups[g] : on > 0, badge: E('span', { 'class': 'zm-badge ' + (on ? 'zm-ok' : 'zm-off') }, on ? 'выбрано ' + on + ' из ' + items.length : String(items.length)), body: zm.svcGrid(items.map(svcTile)), toggle: function(o) { openGroups[g] = o; } })); }); if (groups.indexOf(OLD_GROUP) >= 0) svcCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Эти списки были выбраны раньше, но их нет в каталоге. Они продолжают работать; выключите ненужные и сохраните.')); if (sel.discord && (sel.cloudflare || sel.c_itdoginfo_cloudflare)) svcCard.appendChild(E('p', { 'class': 'zm-hint' }, '⚠ Discord вместе с Cloudflare уводят в Forkozz много лишнего, даже торренты.')); svcCard.appendChild(catalogBlock()); svcCard.appendChild(listsNow()); var meta = (cfg && cfg.catalog && cfg.catalog.meta) || {}; if (!meta.off && !remote.length && catWait < 4 && (meta.busy || !meta.version)) { catWait++; setTimeout(function() { if (document.body.contains(svcCard) && !saving && !busy && !secBusy) reload(); }, 6000); } ownCard.innerHTML = ''; ownCard.appendChild(E('h3', {}, many ? 'Свои домены и адреса · «' + secName() + '»' : 'Свои домены и адреса')); ownCard.appendChild(E('h4', { 'style': 'margin:4px 0 8px' }, 'Домены')); ownCard.appendChild(taDomains); ownCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Один на строку, домен целиком: поддомены включаются сами (example.com — это и www.example.com). keyword: и regex: NetShift не понимает; русские домены — в punycode.')); ownCard.appendChild(E('h4', { 'style': 'margin:16px 0 8px' }, 'IP-адреса и подсети')); ownCard.appendChild(taSubnets); ownCard.appendChild(E('h4', { 'style': 'margin:16px 0 8px' }, 'Внешние списки')); ownCard.appendChild(taLists); ownCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Ссылки на .lst-файлы, по одной на строку.')); ownCard.appendChild(E('p', { 'class': 'zm-hint' }, [ 'Нужно, чтобы какой-то сайт из выбранного списка шёл мимо VPN? Впишите его на вкладке ', E('a', { 'href': '#', 'click': function(ev) { ev.preventDefault(); tabTo('byp'); } }, 'Исключения'), '.' ])); } var bypBadgeEl = null; function bypBadge() { if (!bypBadgeEl) return; var n = tokLines(draft.byDomains).length + tokLines(draft.bySubnets).length; bypBadgeEl.innerHTML = ''; bypBadgeEl.appendChild(n ? badge('zm-warn', nn(n, 'строка', 'строки', 'строк')) : badge('zm-off', 'нет')); } function renderByp() { bypCard.innerHTML = ''; bypBadgeEl = E('span'); bypCard.appendChild(E('h3', {}, [ 'Исключения ', bypBadgeEl ])); bypBadge(); bypCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Эти сайты и адреса всегда идут напрямую — мимо VPN во всех секциях, даже если входят в выбранный список. Например, включён «YouTube», а youtube.com нужно оставить без VPN — впишите его сюда.')); bypCard.appendChild(E('h4', { 'style': 'margin:4px 0 8px' }, 'Домены')); bypCard.appendChild(taByD); bypCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Один на строку, домен целиком. Поддомены исключаются сами: youtube.com — это и www.youtube.com, и m.youtube.com.')); bypCard.appendChild(E('h4', { 'style': 'margin:16px 0 8px' }, 'IP-адреса и подсети')); bypCard.appendChild(taByS); bypCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Например, 203.0.113.10 или 203.0.113.0/24 — по одному на строку.')); bypCard.appendChild(E('p', { 'class': 'zm-hint' }, 'После сохранения устройства могут ещё пару минут помнить старый адрес сайта — если сайт всё ещё идёт через VPN, очистите DNS-кэш или перезапустите браузер. Проверить, куда пойдёт сайт, можно в «Куда пойдёт запрос» на вкладке «Подключение».')); } function devCard(card, key, other, title, hint) { card.innerHTML = ''; var list = draft[key], devs = (cfg && cfg.devices) || [], known = {}; card.appendChild(E('h3', {}, [ title + ' ', list.length ? badge(key === 'full' ? 'zm-ok' : 'zm-warn', nn(list.length, 'устройство', 'устройства', 'устройств')) : E([]) ])); card.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, hint)); function toggle(ip) { var l = draft[key].slice(), i = l.indexOf(ip); if (i >= 0) l.splice(i, 1); else { l.push(ip); var o = draft[other].indexOf(ip); if (o >= 0) { draft[other] = draft[other].slice(); draft[other].splice(o, 1); } } set(key, l); } var grid = E('div', { 'class': 'zm-nodes' }); devs.forEach(function(d) { known[d.ip] = true; grid.appendChild(node(d.name || d.ip, d.name ? d.ip : 'без имени', list.indexOf(d.ip) >= 0, function() { toggle(d.ip); })); }); list.forEach(function(ip) { if (!known[ip]) grid.appendChild(node(ip, 'добавлен вручную', true, function() { toggle(ip); })); }); if (!devs.length && !list.length) grid.appendChild(E('p', { 'class': 'zm-hint', 'style': 'grid-column: 1 / -1; margin: 0' }, 'Устройства не найдены — добавьте адрес вручную.')); card.appendChild(grid); var inp = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': '192.168.1.100', 'style': 'flex:1; min-width:180px' }); function add() { var v = inp.value.trim(); if (!/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(v)) { zm.toast('Введите IP-адрес, например 192.168.1.100', 'warning'); return; } if (draft[key].indexOf(v) < 0) toggle(v); } inp.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); add(); } }); card.appendChild(E('div', { 'class': 'zm-actions' }, [ inp, E('button', { 'class': 'cbi-button', 'click': add }, 'Добавить') ])); } function renderDev() { devCard(fullCard, 'full', 'excl', 'Всё через Forkozz', 'Весь интернет устройства идёт через подключение' + (secs().length > 1 ? ' секции «' + mainName() + '»' : '') + '. Удобно для ТВ и приставок. Исключения действуют и здесь.'); devCard(exclCard, 'excl', 'full', 'Мимо Forkozz', 'Устройство всегда ходит напрямую.'); } function dnsUniq(t, list) { var seen = {}, out = []; (list || []).forEach(function(v) { var p = dnsFind(t, v), k = p ? 'p:' + p.id : 'v:' + v; if (seen[k]) return; seen[k] = true; out.push(v); }); return out; } function dnsSet(servers, keepBoots) { var d = draft.dns; servers = dnsUniq(d.type, servers); draft.dns = { type: d.type, servers: servers, bootstraps: d.bootOwn ? d.bootstraps : dnsBoots(d.type, servers), detour: d.detour, bootOwn: !!d.bootOwn }; touch(); renderDns(); renderSaveBar(); } function dnsSaved() { return !dirty && cfg && cfg.dns && cfg.dns.type === draft.dns.type; } function dnsTest(pick) { if (dnsTesting) return; var t = draft.dns.type, list = []; (DNS_CAT[t] || []).forEach(function(p) { list.push(p.value); }); draft.dns.servers.forEach(function(v) { if (list.indexOf(v) < 0) list.push(v); }); dnsTesting = true; renderDns(); zm.toast(pick ? 'Проверяем все серверы ' + DNS_TYPES[t] + ' и подбираем лучшие…' : 'Проверяем серверы ' + DNS_TYPES[t] + '…', 'warning'); zm.forkopAction('dns_test', t + '|' + list.join(' ')).then(function(res) { dnsTesting = false; if (!res || res.error) { zm.toast((res && res.error) || 'Проверка не удалась', 'error'); renderDns(); return; } if (res.type !== draft.dns.type) { renderDns(); return; } dnsRes = {}; (res.results || []).forEach(function(r) { dnsRes[t + '|' + r.server] = r; }); var good = (res.results || []).filter(function(r) { return r.ok; }).sort(function(a, b) { return a.ms - b.ms; }); if (pick) { if (!good.length) { zm.toast('Ни один сервер ' + DNS_TYPES[t] + ' не ответил — попробуйте другой протокол или включите «DNS через VPN»', 'error'); renderDns(); return; } var chosen = dnsUniq(t, good.map(function(r) { return r.server; })).slice(0, 4); dnsSet(chosen); zm.toast('Подобрано ' + chosen.length + ': ' + chosen.map(function(v) { return dnsName(t, v); }).join(', ') + '. Сохраните, чтобы применить', 'info'); return; } zm.toast('Отвечают ' + good.length + ' из ' + (res.results || []).length, good.length ? 'info' : 'warning'); renderDns(); }).catch(function() { dnsTesting = false; renderDns(); zm.toast('Роутер не ответил', 'error'); }); } function dnsLat(v) { var r = dnsRes[draft.dns.type + '|' + v]; if (!r) return E('span'); return E('span', { 'class': 'zm-lat ' + (r.ok ? latClass(r.ms) : 'zm-lat-bad'), 'title': r.how === 'tls' ? 'TLS-соединение есть, ответ DNS проверить не удалось' : '' }, r.ok ? r.ms + ' мс' + (r.how === 'tls' ? '*' : '') : 'не отвечает'); } function renderDns() { dnsCard.innerHTML = ''; var d = draft.dns, t = d.type, cat = DNS_CAT[t] || [], sel = d.servers, act = dnsSaved() ? (cfg.dns.active || 0) : -1; dnsCard.appendChild(E('h3', {}, [ 'DNS ', sel.length > 1 ? badge('zm-ok', 'автопереключение · ' + sel.length) : E([]) ])); dnsCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Через какие DNS-серверы Forkozz ищет адреса сайтов. Выберите несколько — раз в пару минут Forkozz проверяет их по порядку: если текущий не отвечает, переходит на следующий, а когда первый оживёт, возвращается к нему. При «DNS через VPN» переключения нет.')); dnsCard.appendChild(row('Протокол', seg([ { id: 'doh', label: 'DoH' }, { id: 'dot', label: 'DoT' }, { id: 'udp', label: 'UDP' } ], t, function(nt) { var keep = sel.map(function(v) { var p = dnsFind(t, v); var q = p && (DNS_CAT[nt] || []).filter(function(x) { return x.id === p.id; })[0]; return q ? q.value : null; }).filter(Boolean); draft.dns = { type: nt, servers: [], bootstraps: d.bootOwn ? d.bootstraps : [], detour: d.detour, bootOwn: !!d.bootOwn }; dnsSet(keep.length ? keep : DNS_DEF[nt].slice()); }))); dnsCard.appendChild(E('p', { 'class': 'zm-hint' }, (t === 'udp' ? '⚠ ' : '') + DNS_HELP[t].hint)); dnsCard.appendChild(E('h4', { 'style': 'margin:14px 0 8px' }, 'Порядок серверов')); var ol = E('div', { 'class': 'zm-dns-order' }); sel.forEach(function(v, i) { ol.appendChild(E('div', { 'class': 'zm-dns-item' + (i === act ? ' zm-dns-active' : '') }, [ E('span', { 'class': 'zm-dns-num' }, String(i + 1)), E('div', { 'class': 'zm-dns-text' }, [ E('b', {}, dnsName(t, v)), E('span', {}, v) ]), i === act ? badge('zm-ok', sel.length > 1 ? 'сейчас работает' : 'используется') : E([]), dnsLat(v), E('div', { 'class': 'zm-dns-btns' }, [ E('button', { 'class': 'cbi-button', 'title': 'Выше', 'disabled': i === 0 ? '' : null, 'click': function() { var a = sel.slice(); a.splice(i - 1, 0, a.splice(i, 1)[0]); dnsSet(a, true); } }, '↑'), E('button', { 'class': 'cbi-button', 'title': 'Ниже', 'disabled': i === sel.length - 1 ? '' : null, 'click': function() { var a = sel.slice(); a.splice(i + 1, 0, a.splice(i, 1)[0]); dnsSet(a, true); } }, '↓'), E('button', { 'class': 'cbi-button', 'title': 'Убрать', 'disabled': sel.length === 1 ? '' : null, 'click': function() { var a = sel.slice(); a.splice(i, 1); dnsSet(a); } }, '✕') ]) ])); }); dnsCard.appendChild(ol); dnsCard.appendChild(E('h4', { 'style': 'margin:16px 0 4px' }, 'Серверы ' + DNS_TYPES[t])); var g = E('div', { 'class': 'zm-nodes zm-nodes-3' }); cat.forEach(function(p) { var on = sel.indexOf(p.value) >= 0 || (p.alias || []).some(function(a) { return sel.indexOf(a) >= 0; }); g.appendChild(node(p.name, p.value.replace(/^https:\/\//, ''), on, function() { if (on) { if (sel.length === 1) { zm.toast('Нужен хотя бы один сервер', 'warning'); return; } dnsSet(sel.filter(function(v) { return v !== p.value && (p.alias || []).indexOf(v) < 0; })); } else { if (sel.length >= DNS_MAX) { zm.toast('Не больше ' + DNS_MAX + ' серверов', 'warning'); return; } dnsSet(sel.concat([ p.value ])); } }, dnsLat(p.value))); }); dnsCard.appendChild(g); dnsInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'autocapitalize': 'off', 'spellcheck': 'false', 'placeholder': 'Свой: ' + DNS_HELP[t].ph, 'style': 'flex:1; min-width:220px' }); function addOwn() { var v = dnsInput.value.trim(); if (!v) return; if (t === 'doh' && /^[A-Za-z0-9.-]+\.[A-Za-z0-9-]+$/.test(v)) v = 'https://' + v + '/dns-query'; if (!DNS_RE[t].test(v)) { zm.toast(DNS_HELP[t].hint, 'warning'); return; } if (sel.indexOf(v) >= 0) { zm.toast('Этот сервер уже в списке', 'warning'); return; } if (sel.length >= DNS_MAX) { zm.toast('Не больше ' + DNS_MAX + ' серверов', 'warning'); return; } dnsSet(sel.concat([ v ])); } dnsInput.addEventListener('keydown', function(ev) { if (ev.key === 'Enter') { ev.preventDefault(); addOwn(); } }); dnsCard.appendChild(E('div', { 'class': 'zm-actions' }, [ dnsInput, E('button', { 'class': 'cbi-button', 'click': addOwn }, 'Добавить') ])); dnsCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'disabled': dnsTesting || busy ? '' : null, 'click': function() { dnsTest(true); } }, dnsTesting ? 'Проверяем…' : 'Подобрать лучшие'), E('button', { 'class': 'cbi-button', 'disabled': dnsTesting || busy ? '' : null, 'click': function() { dnsTest(false); } }, 'Только проверить') ])); dnsCard.appendChild(E('p', { 'class': 'zm-hint' }, '«Подобрать лучшие» проверит с роутера все серверы ' + DNS_TYPES[t] + ' и поставит в список до 4 отвечающих — самые быстрые первыми.')); if (t !== 'udp') { dnsCard.appendChild(E('h4', { 'style': 'margin:16px 0 4px' }, 'Bootstrap DNS')); dnsCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Обычный DNS, через который Forkozz узнаёт адреса самих серверов ' + DNS_TYPES[t] + ' (например, dns.quad9.net).')); dnsCard.appendChild(row('Адреса', seg([ { id: 'auto', label: 'Авто' }, { id: 'own', label: 'Свои' } ], d.bootOwn ? 'own' : 'auto', function(m) { var own = m === 'own'; draft.dns = { type: d.type, servers: d.servers, bootstraps: own ? d.bootstraps : dnsBoots(d.type, d.servers), detour: d.detour, bootOwn: own }; touch(); renderDns(); renderSaveBar(); }))); if (d.bootOwn) { var bIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'autocapitalize': 'off', 'spellcheck': 'false', 'placeholder': '77.88.8.8, 9.9.9.9', 'style': 'flex:1; min-width:220px; max-width:420px' }); bIn.value = d.bootstraps.join(', '); bIn.addEventListener('input', function() { var l = bIn.value.split(/[\s,;]+/).filter(Boolean).slice(0, 4); draft.dns.bootstraps = l.length ? l : dnsBoots(d.type, d.servers); touch(); renderSaveBar(); }); dnsCard.appendChild(E('div', { 'class': 'zm-actions' }, [ bIn ])); dnsCard.appendChild(E('p', { 'class': 'zm-hint' }, 'IPv4-адрес, через который Forkozz находит сами DNS-серверы. Работает первый из списка, остальные — про запас.')); } else dnsCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Сейчас: ' + d.bootstraps.join(', ') + ' — подбираются под выбранные серверы.')); } dnsCard.appendChild(sw(d.detour, 'DNS через VPN', 'Запросы к DNS-серверам пойдут через ваше подключение Forkozz' + (secs().length > 1 ? ' (секция «' + mainName() + '»)' : '') + '. Помогает, если провайдер блокирует или подменяет DoH/DoT.', function() { draft.dns = { type: d.type, servers: d.servers, bootstraps: d.bootstraps, detour: !d.detour, bootOwn: !!d.bootOwn }; touch(); renderDns(); renderSaveBar(); })); } function renderSet() { renderDns(); miscCard.innerHTML = ''; miscCard.appendChild(E('h3', {}, 'Прочее')); miscCard.appendChild(sw(draft.quic_off, 'Отключить QUIC для выбранных сервисов', 'Приложения перейдут на обычный HTTPS — через сервер так стабильнее.', function() { set('quic_off', !draft.quic_off); })); miscCard.appendChild(E('h4', { 'style': 'margin:18px 0 4px' }, 'Списки сервисов')); miscCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:0' }, 'Домены и адреса выбранных сервисов (YouTube, Discord и другие) — по ним Forkozz понимает, что вести через VPN.')); miscCard.appendChild(sw(draft.lists_via, 'Скачивать списки сервисов через VPN', 'Списки будут качаться через ваше подключение (сервер, подписку или туннель' + (secs().length > 1 ? ' секции «' + mainName() + '»' : '') + '). Помогает, если GitHub у провайдера не открывается.', function() { set('lists_via', !draft.lists_via); })); miscCard.appendChild(row('Обновлять списки', seg(LIST_IV, draft.list_interval, function(v) { set('list_interval', v); }))); miscCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': !configured() || busy ? '' : null, 'click': function() { act('lists'); } }, 'Обновить списки сервисов сейчас') ])); var extra = (cfg && cfg.extra) || []; if (extra.length) miscCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Есть ещё ' + nn(extra.length, 'секция', 'секции', 'секций') + ', созданных раньше (' + extra.map(function(x) { return x.label; }).join(', ') + '). Они работают как прежде.')); sbCard.innerHTML = ''; sbCard.appendChild(E('h3', {}, 'sing-box')); var sv = st.singbox_pkg; sbCard.appendChild(row('Версия', E('span', {}, st.singbox ? st.singbox + ' · ' + (SB_NAMES[sv] || 'установлен вручную') : 'не установлен'))); var vars = SB_VARS; sbCard.appendChild(row('Вариант', seg(vars, SB_NAMES[sv] ? sv : '', function(v) { var it = SB_VARS.filter(function(x) { return x.id === v; })[0]; if (!confirm('Заменить sing-box на ' + it.label.toLowerCase() + '?\n\n' + it.warn + 'Forkozz перезапустится. Если новый sing-box не заработает, вернётся прежний.')) return; act('singbox', v); }))); sbCard.appendChild(E('div', { 'class': 'zm-kv' }, vars.map(function(x) { return row(x.label, E('span', { 'class': 'zm-hint', 'style': 'margin:0' }, x.hint)); }))); } function runDiag(quiet) { if (diagBusy) return; diagBusy = true; renderCheck(); if (!quiet) zm.toast('Проверяем…', 'warning'); zm.forkopAction('diag', '').then(function(res) { diagBusy = false; diag = res && !res.error ? res : null; if (res && res.error && !quiet) zm.toast(res.error, 'error'); renderCheck(); if (!quiet) zm.toast('Проверка закончена', 'info'); }).catch(function() { diagBusy = false; renderCheck(); }); } function renderCheck() { renderRoute(); checkCard.innerHTML = ''; var show = st.installed && configured() && st.enabled && !busy; checkCard.style.display = show ? '' : 'none'; if (!show) return; checkCard.appendChild(E('h3', {}, 'Проверка')); if (diag && diag.checks) { var fails = diag.checks.filter(function(c) { return c.verdict === 'fail'; }).length, warns = diag.checks.filter(function(c) { return c.verdict === 'warn'; }).length; checkCard.appendChild(row('Итог', fails ? badge('zm-bad', 'есть поломка') : warns ? badge('zm-warn', 'есть замечания') : badge('zm-ok', 'всё в порядке'))); checkCard.appendChild(E('div', { 'class': 'zm-st-checks' }, diag.checks.map(function(c) { return E('div', { 'class': 'zm-st-check zm-st-check-' + c.verdict }, [ E('span', { 'class': 'zm-st-check-dot' }), E('span', {}, [ E('span', {}, c.what), E('span', { 'class': 'zm-st-check-why' }, c.why) ]) ]); }))); } else checkCard.appendChild(E('p', { 'class': 'zm-hint', 'style': 'margin-top:-6px' }, 'Служба, sing-box, DNS и ответ сервера.')); checkCard.appendChild(E('div', { 'class': 'zm-actions' }, [ E('button', { 'class': 'cbi-button', 'disabled': diagBusy ? '' : null, 'click': function() { runDiag(false); } }, diagBusy ? 'Проверяем…' : 'Проверить') ])); } function routeVerdict(r) { var sl = secs(), rs = sl.filter(function(s) { return s.name === r.section; })[0] || (sl.length === 1 ? sl[0] : null); var lines = [], via = rs ? (rs.mode === 'iface' ? 'интерфейс ' + (rs.iface || '') : 'VPN') : 'VPN'; if (r.verdict === 'bypass') { return { tone: 'warn', cls: 'zm-warn', label: 'напрямую · исключение', title: 'адрес в исключениях — идёт мимо VPN', lines: lines, note: 'Он есть на вкладке «Исключения». Чтобы он снова шёл через VPN, уберите его оттуда и сохраните.' }; } if (r.verdict === 'off') return { tone: 'off', cls: 'zm-off', label: 'Forkozz не работает', title: 'запрос пойдёт напрямую', note: 'Запустите Forkozz и проверьте ещё раз.' }; if (r.addr && !r.ip) lines.push([ 'Адрес', r.how === 'fakeip' ? [ r.addr, ' (подменный — домен есть в списках Forkozz)' ] : r.addr ]); if (r.how === 'subnet') lines.push([ 'Совпадение', 'адрес входит в подсети Forkozz' ]); if (sl.length > 1 && rs) lines.push([ 'Секция', rs.label ]); else if (r.rule) lines.push([ 'Правило', r.rule ]); if (r.verdict === 'proxy') { if (rs) lines.push([ 'Выход', secConn(rs) ]); else if (r.outbound) lines.push([ 'Выход', r.outbound ]); if (servers && cfg && rs && rs.name === cfg.sec && cfg.mode !== 'iface') { try { var S = srvState(); if (S.curTag) lines.push([ 'Сервер сейчас', nodeName(S.cur) ]); } catch (e) {} } return { tone: 'ok', cls: 'zm-ok', label: rs && rs.mode === 'iface' ? 'через ' + (/^zmwarp/.test(rs.iface || '') ? 'WARP' : 'интерфейс') : 'через VPN', title: 'идёт через ' + (rs && sl.length > 1 ? '«' + rs.label + '» · ' : '') + via, lines: lines }; } if (r.verdict === 'block') { return { tone: 'bad', cls: 'zm-bad', label: 'заблокировано', title: rs ? 'секция «' + rs.label + '» сейчас без рабочего подключения' : 'правило блокирует этот адрес', lines: lines, note: rs ? 'Пока подписка или серверы секции не загрузятся, её сайты блокируются, а не уходят напрямую. Проверьте ссылку на подписку и нажмите «Обновить серверы подписки».' : 'Так работает блокировка DoH или секция-блокировщик.' }; } if (r.verdict === 'direct' && (r.action === 'connection' || r.action === 'outbound')) { return { tone: 'warn', cls: 'zm-warn', label: 'напрямую', title: 'по правилам идёт через ' + (rs ? '«' + rs.label + '»' : 'VPN') + ', но устройство получило настоящий адрес', lines: lines, note: r.ip ? 'Адреса из списков попадают в файрвол после загрузки списков — нажмите «Обновить списки сейчас» на вкладке «Сервисы».' : 'Устройство помнит старый адрес сайта: очистите DNS-кэш, перезапустите браузер или подождите пару минут.' }; } if (r.verdict === 'direct') { if (r.outbound && r.outbound !== 'direct' && r.outbound !== 'direct-out') lines.push([ 'По настройкам', r.outbound ]); return { tone: 'warn', cls: 'zm-warn', label: 'напрямую', title: 'Forkozz этот адрес не трогает', lines: lines, note: r.ip ? 'Чтобы IP шёл через VPN, добавьте его подсеть на вкладке «Сервисы».' : 'Чтобы сайт шёл через VPN, включите его сервис или добавьте домен на вкладке «Сервисы». Если домен уже в списке — устройство могло запомнить старый адрес: очистите DNS-кэш или подождите пару минут.' }; } return { tone: 'bad', cls: 'zm-bad', label: 'не удалось определить', title: 'роутер не смог узнать адрес домена', lines: lines, note: 'Проверьте, что DNS роутера работает (кнопка «Проверить» выше).' }; } function renderRoute() { var show = st.installed && configured() && st.enabled && !busy; routeCard.style.display = show ? '' : 'none'; if (!show) return; if (!routeBox) routeBox = zm.routeCheck({ call: function(v) { return zm.forkopAction('route', v); }, verdict: routeVerdict }); if (routeBox.parentNode === routeCard) return; routeCard.innerHTML = ''; routeCard.appendChild(E('h3', {}, 'Куда пойдёт запрос')); routeCard.appendChild(E('p', { 'class': 'zm-hint' }, 'Введите сайт или IP-адрес — Forkozz покажет, пойдёт он через VPN или напрямую.')); routeCard.appendChild(routeBox); } function renderPanes() { renderConn(); renderServers(); renderSvc(); renderByp(); renderDev(); renderSet(); renderMain(); renderSaveBar(); } function renderAll() { renderMain(); renderTabs(); renderSections(); renderPanes(); renderCheck(); } panes.conn.appendChild(connCard); panes.conn.appendChild(srvCard); panes.conn.appendChild(checkCard); panes.conn.appendChild(routeCard); panes.svc.appendChild(svcCard); panes.svc.appendChild(ownCard); panes.dev.appendChild(fullCard); panes.dev.appendChild(exclCard); panes.set.appendChild(dnsCard); panes.set.appendChild(miscCard); panes.set.appendChild(sbCard); panes.byp.appendChild(bypCard); [ mainCard, hostsWarn, logEl, secCard, tabBar, panes.conn, panes.svc, panes.byp, panes.dev, panes.set, saveBar ].forEach(function(n) { wrap.appendChild(n); }); syncAreas(); renderAll(); if (st.busy) { lastAct = ''; follow(); } else { if (st.running && secLive()) loadServers(); loadSecState(!!st.running); } return wrap; } }); ZM_INSTALLER_EOF chmod 0644 '/www/luci-static/resources/view/zapret-manager/forkozz.js' mkdir -p /www/luci-static/resources/bytetube cat > '/www/luci-static/resources/bytetube/common.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require baseclass'; 'require fs'; 'require uci'; 'require rpc'; 'require ui'; var callUciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ] }); var CTL = '/usr/bin/bytetube'; var INIT = '/etc/init.d/bytetube'; var CONF = 'bytetube'; 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/bytetube/style.css'); document.head.appendChild(l); } function badge(ok, textOk, textBad) { var cls = ok ? 'zm-ok' : (textBad === 'не установлен' ? 'zm-off' : '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' }), 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 }; } 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 callUciCommit(CONF); }).then(function() { try { ui.changes.setIndicator(0); } catch (e) {} return { ok: true }; }); }).catch(function(e) { return { error: e.message }; }); } return baseclass.extend({ injectCss: injectCss, toast: toast, badge: badge, span: span, chipClass: chipClass, renderTestLog: renderTestLog, parseResults: parseResults, 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/bytetube/common.js' cat > '/www/luci-static/resources/bytetube/presets.js' << 'ZM_INSTALLER_EOF' 'use strict'; 'require baseclass'; 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/bytetube/presets.js' mkdir -p /www/luci-static/resources/view/bytetube cat > '/www/luci-static/resources/view/bytetube/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-cards.zm-cards-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .zm-cards.zm-cards-2 > .zm-card { display: flex; flex-direction: column; } .zm-cards.zm-cards-2 > .zm-card > .zm-actions:last-child { margin-top: auto; padding-top: 6px; margin-bottom: 0; } @media (max-width: 720px) { .zm-cards.zm-cards-2 { grid-template-columns: minmax(0, 1fr); } } .zm-card { min-width: 0; box-sizing: border-box; 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: none; } .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: none; } .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 { scrollbar-width: auto; scrollbar-color: auto; } .zm-log::-webkit-scrollbar { width: 14px; } .zm-log::-webkit-scrollbar-track { background: transparent; margin: 8px 0; } .zm-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,.2); border-radius: 10px; border: 4px solid transparent; background-clip: padding-box; min-height: 40px; } .zm-log::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.34); background-clip: padding-box; } @supports not selector(::-webkit-scrollbar) { .zm-log { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.25) transparent; } } .zm-config-editor { scrollbar-width: auto; scrollbar-color: auto; } .zm-config-editor::-webkit-scrollbar { width: 14px; height: 14px; } .zm-config-editor::-webkit-scrollbar-button { display: none; } .zm-config-editor::-webkit-scrollbar-track, .zm-config-editor::-webkit-scrollbar-corner, .zm-config-editor::-webkit-resizer { background: transparent; } .zm-config-editor::-webkit-scrollbar-track { margin: 8px; } .zm-config-editor::-webkit-scrollbar-thumb { background: rgba(255,255,255,.2); border-radius: 10px; border: 4px solid transparent; background-clip: padding-box; min-height: 40px; } .zm-config-editor::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.34); background-clip: padding-box; } @supports not selector(::-webkit-scrollbar) { .zm-config-editor { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.25) transparent; } } .zm-log:empty::before { content: "Ожидание вывода..."; opacity: .4; } .zm-log-bar { position: sticky; top: 16px; height: 0; z-index: 3; } .zm-log-min { position: absolute; right: -8px; top: -8px; width: 30px; height: 24px; padding: 0; margin: 0; border: 1px solid rgba(255,255,255,.14); border-radius: 7px; background: rgba(255,255,255,.06); color: #c9d1d9; font: 700 13px/1 ui-monospace, Consolas, monospace; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s; } .zm-log-min:hover { background: rgba(255,255,255,.16); color: #fff; } .zm-log.zm-log-collapsed { min-height: 0; max-height: none; overflow: hidden; padding-right: 48px; } .zm-log.zm-log-collapsed > div:not(.zm-log-bar):not(:last-child) { display: none; } .zm-log.zm-log-collapsed > div:last-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .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-lists-intro { margin: 0 0 12px; } .zm-list-card { padding: 0; margin-bottom: 12px; overflow: hidden; } .zm-list-head { display: flex; align-items: center; gap: 14px; padding: 14px 20px; cursor: pointer; user-select: none; outline: none; transition: background .15s; } @media (hover: hover) { .zm-list-head:hover { background: rgba(127,127,127,.06); } } .zm-list-head:focus-visible { box-shadow: inset 0 0 0 2px rgba(34,158,217,.55); } .zm-list-chev { flex: 0 0 22px; width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; background: rgba(127,127,127,.12); font-size: 16px; font-weight: 700; line-height: 1; transition: transform .2s; } .zm-list-open .zm-list-chev { transform: rotate(90deg); } .zm-list-titles { flex: 1 1 auto; min-width: 0; } .zm-list-title { font-size: 15px; font-weight: 600; line-height: 1.3; } .zm-list-path { margin-top: 3px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; opacity: .6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .zm-list-count { flex: 0 0 auto; min-width: 92px; justify-content: center; } .zm-list-body { padding: 4px 20px 18px; border-top: 1px solid rgba(127,127,127,.15); } .zm-list-hint { margin: 12px 0 10px; } textarea.zm-list-editor { display: block; width: 100%; box-sizing: border-box; min-height: 320px; resize: vertical; white-space: pre; overflow-wrap: normal; overflow-x: auto; tab-size: 4; } .zm-list-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; } .zm-list-actions .cbi-button { margin: 0; } .zm-list-dirty { flex: 0 0 auto; font-size: 12px; white-space: nowrap; font-weight: 600; color: #9a6700; display: inline-flex; align-items: center; gap: 6px; } .zm-list-dirty::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } html.zm-theme-dark .zm-list-dirty { color: #d29922; } .zm-cat { border: 1px solid rgba(127,127,127,.18); border-radius: 12px; margin-top: 10px; overflow: hidden; } .zm-cat .zm-cat-head { padding: 12px 16px; } .zm-cat-sub { margin-top: 2px; font-size: 12px; opacity: .6; } .zm-cat-count { flex: 0 0 auto; } .zm-cat-body { padding: 12px 16px 16px; border-top: 1px solid rgba(127,127,127,.15); } .zm-cat-tools { display: flex; align-items: center; justify-content: flex-end; margin: -4px -6px 8px; } .zm-cat-links { display: flex; gap: 4px; margin-left: auto; } .zm-linkbtn { background: none; border: 0; padding: 6px 10px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600; color: #2563eb; cursor: pointer; } .zm-linkbtn:hover { background: rgba(37,99,235,.08); } .zm-linkbtn[disabled] { opacity: .35; cursor: default; background: none; } html.zm-theme-dark .zm-linkbtn { color: #6ea8fe; } .zm-cat-grid { margin: 0; } .zm-cat-src { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px; padding: 14px 16px; border: 1px dashed rgba(127,127,127,.3); border-radius: 12px; } .zm-cat-src-info { flex: 1 1 260px; min-width: 0; } .zm-cat-src-title { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 14px; } .zm-cat-src-state { margin-top: 4px; font-size: 13px; opacity: .8; } .zm-cat-src-url { margin-top: 2px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; opacity: .55; overflow-wrap: anywhere; } .zm-cat-src-actions { margin: 0 !important; flex: 0 0 auto; } @media (max-width: 600px) { .zm-cat-tools { justify-content: flex-start; } .zm-cat-src-actions { flex: 1 1 100%; } } @media (max-width: 600px) { .zm-list-head { padding: 12px 14px; gap: 10px; } .zm-list-body { padding: 4px 14px 14px; } .zm-list-count { min-width: 0; } .zm-list-actions .cbi-button { flex: 1 1 100%; } .zm-list-dirty { font-size: 0; gap: 0; } } @media (max-width: 600px) { .zm-config-editor { font-size: 16px; min-height: 320px; } } .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; overflow: hidden; pointer-events: none; } #zm-toast-container > .zm-toast { pointer-events: auto; flex-shrink: 0; } .zm-toast { display: flex; align-items: flex-start; gap: 14px; background: #1c2128; color: #e6edf3; padding: 24px 28px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,.4); font-size: 18px; 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; width: 10px; height: 10px; border-radius: 50%; background: currentColor; align-self: center; } .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; } html.zm-theme-dark .zm-toast { background: #ffffff; color: #1f2328; box-shadow: 0 12px 44px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.1); } html.zm-theme-dark .zm-toast-info .zm-toast-icon { color: #1a7f37; } html.zm-theme-dark .zm-toast-error .zm-toast-icon { color: #cf222e; } html.zm-theme-dark .zm-toast-warning .zm-toast-icon { color: #9a6700; } .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); } .zm-current-banner.zm-current-top { margin: 0 0 10px; } .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-label { flex: 0 0 128px; } } @media (max-width: 420px) { .bt-col .zm-label { flex: 0 0 108px; font-size: 12px; } } .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/bytetube/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 bytetube.common as bt'; 'require bytetube.presets as presets'; var callBytetubeInstalled = rpc.declare({ object: 'zapret-manager', method: 'bytetube_installed', expect: {}, filter: function(r) { return typeof r === 'number' ? (r === 0 ? {} : { error: 'Роутер не ответил (ubus ' + r + ')' }) : (r == null ? {} : r); } }); var callBytetubeAction = rpc.declare({ object: 'zapret-manager', method: 'bytetube_action', params: [ 'action' ], expect: {}, filter: function(r) { return typeof r === 'number' ? (r === 0 ? {} : { error: 'Роутер не ответил (ubus ' + r + ')' }) : (r == null ? {} : r); } }); 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(mixomo) { var wrap = E('div', { 'class': 'zm-wrap' }); if (mixomo) { wrap.appendChild(zm.blockCard(E('div', { 'class': 'zm-card' }), 'ByeTube', 'Обходит блокировку только доменов YouTube (ByeDPI + hev-socks5-tunnel), остальной трафик идёт напрямую.', 'Стоит Mixomo — он тоже использует hev-socks5-tunnel, вместе они мешают друг другу. Чтобы поставить ByeTube, удалите Mixomo.')); return 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' }); var removeLogEl = E('pre', { 'class': 'zm-log' }); var removeBusy = false; function removeByeTube() { if (removeBusy) { zm.toast('Дождитесь завершения текущей операции', 'warning'); return; } removeBusy = true; zm.toast('Удаляем ByeTube', 'warning'); callBytetubeAction('purge').then(function(res) { if (res && res.error) { removeBusy = false; zm.toast(res.error, 'error'); return; } if (res && res.started) { zm.pollJob('bytetube_remove', removeLogEl, function(ok) { removeBusy = false; zm.toast(ok ? 'ByeTube удалён' : 'Ошибка удаления — смотрите журнал', ok ? 'info' : 'error'); if (ok) location.reload(); }); } else { removeBusy = false; } }).catch(function() { removeBusy = false; }); } 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('Перезапускаем ByeTube', '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(); }); }), btn('Удалить', 'cbi-button-remove', removeByeTube) ])); 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 Promise.resolve(false); busy = true; bt.toast('Применяем стратегию', 'warning'); return bt.setStrategy(opts).then(function(res) { busy = false; if (res.error) { bt.toast(res.error, 'error'); return false; } bt.toast((label || 'Стратегия') + ' применена', 'info'); refreshState(); return true; }, function() { busy = false; bt.toast('Роутер не ответил', 'error'); return false; }); } 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 customBar = zm.saveBar({ saveLabel: 'Применить', onCancel: function() { customTa.value = ''; customBar.set(false); }, onSave: function() { var v = presets.norm(customTa.value); if (!v) { bt.toast('Стратегия не может быть пустой', 'error'); return; } setStrategy(v, 'Своя стратегия').then(function(ok) { if (ok) customBar.set(false); }); } }); customTa.addEventListener('input', function() { var v = presets.norm(customTa.value); customBar.set(!!v && v !== presets.norm(cfg.byedpi_opts || '')); }); var customCard = E('div', { 'class': 'zm-card' }, [ E('h3', {}, [ 'Своя стратегия' ]), hint('Параметры командной строки ciadpi одной строкой. Кавычки (-n "google.com") служба обрабатывает сама.'), customTa, customBar, E('div', { 'class': 'zm-actions' }, [ btn('Применить', 'cbi-button-positive', function() { var v = presets.norm(customTa.value); if (!v) { bt.toast('Стратегия не может быть пустой', 'error'); return; } setStrategy(v, 'Своя стратегия').then(function(ok) { if (ok) customBar.set(false); }); }), btn('Подставить текущую', '', function() { customTa.value = cfg.byedpi_opts || ''; customBar.set(false); }) ]) ]); 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); var ebar = zm.saveBar({ saveLabel: 'Сохранить', onSave: function() { if (ed.locked) { bt.toast('Дождитесь окончания теста', 'warning'); return; } onSave(); }, onCancel: function() { ta.value = texts[o.kind] || ''; ebar.set(false); } }); ta.addEventListener('input', function() { ebar.set(ta.value !== (texts[o.kind] || '')); }); ed.node = E('div', { 'class': 'zm-card' }, [ E('h3', {}, [ o.title ]), hint(o.hint), ta, meta, E('div', { 'class': 'zm-actions' }, [ resetBtn ]), ebar ]); 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; ebar.set(false); }); } 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; var extraBar = zm.saveBar({ onCancel: function() { extraDirty = false; extraTa.value = (cfg.domain || []).join('\n'); extraBar.set(false); }, onSave: function() { var r = parseDomains(extraTa.value); if (r.bad.length) { bt.toast('Некорректный домен: ' + r.bad[0], 'error'); return; } extraDirty = false; extraBar.set(false); applyConfig({ domain: r.list }, 'Домены сохранены'); } }); extraTa.addEventListener('input', function() { extraDirty = extraTa.value !== (cfg.domain || []).join('\n'); extraBar.set(extraDirty); }); var extraHint = hint(''); function renderExtraDomains() { if (!extraDirty) extraTa.value = (cfg.domain || []).join('\n'); fill(extraHint, [ 'По одному домену в строке; поддомены подхватываются автоматически. Добавляются к встроенному списку. Сейчас доменов: ' + (cfg.domain || []).length + '.' ]); if (!extraDomainsCard.firstChild) fill(extraDomainsCard, [ E('h3', {}, [ 'Дополнительные домены' ]), extraHint, extraTa, extraBar ]); extraBar.set(extraDirty); } 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]); }); wrap.appendChild(removeLogEl); 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, mixomo: !!(inst && inst.mixomo) }; 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(result && result.mixomo); 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 ZM_BUILD_ID="$(date +%s)" { echo "$ZM_BUILD_ID" for _zm_f in /www/luci-static/resources/zapret-manager/*.js /www/luci-static/resources/bytetube/*.js /www/luci-static/resources/view/zapret-manager/*.js /www/luci-static/resources/view/zapret-manager/*.css /www/luci-static/resources/view/bytetube/*.css; do [ -f "$_zm_f" ] && echo "${_zm_f#/www/luci-static/resources/}" done } > /www/luci-static/resources/zapret-manager/build.txt.zm-new 2>/dev/null || true mv -f /www/luci-static/resources/zapret-manager/build.txt.zm-new /www/luci-static/resources/zapret-manager/build.txt 2>/dev/null || true chmod 0644 /www/luci-static/resources/zapret-manager/build.txt 2>/dev/null || true ZM_RPC_WANT="$(grep -c 'json_add_object' /usr/libexec/rpcd/zapret-manager 2>/dev/null || true)" _zm_rpcd_ready() { ubus -t 5 list zapret-manager >/dev/null 2>&1 || return 1 _zm_have="$(ubus -v list zapret-manager 2>/dev/null | grep -c '^[[:space:]]*"' || true)" [ "$_zm_have" = "$ZM_RPC_WANT" ] || [ "$_zm_have" = 0 ] } _zm_rpcd_wait() { _zm_i=0 while [ "$_zm_i" -lt "$1" ]; do if _zm_rpcd_ready; then return 0; fi sleep 1 _zm_i=$((_zm_i + 1)) done return 1 } /etc/init.d/rpcd reload >/dev/null 2>&1 || /etc/init.d/rpcd restart >/dev/null 2>&1 || true if [ -x /etc/init.d/rpcd ] && command -v ubus >/dev/null 2>&1; then if ! _zm_rpcd_wait 10; then /etc/init.d/rpcd restart >/dev/null 2>&1 || true if ! _zm_rpcd_wait 15; then echo -e "${YELLOW}rpcd не подхватил панель — если страница покажет «Object not found», выполните: /etc/init.d/rpcd restart${NC}" fi fi fi if [ -s /etc/zm-steer/owned ]; then /opt/zapret-manager-luci/backend.sh steer_action sync x >/dev/null 2>&1 || true fi if command -v apk >/dev/null 2>&1; then PM="apk"; INSTALL="apk add" else PM="opkg"; INSTALL="opkg install"; fi command -v curl >/dev/null 2>&1 || $INSTALL curl >&2 || true command -v unzip >/dev/null 2>&1 || $INSTALL unzip >&2 || true mkdir -p /www/zm chmod 0755 /www/zm cat > '/www/zm/app.js' << 'ZM_INSTALLER_EOF' (function () { 'use strict'; var BUILD = '__ZMW_BUILD__'; var RES = '/luci-static/resources/'; var NULL_SID = '00000000000000000000000000000000'; var K_SID = 'zmw.sid', K_THEME = 'zmw.theme', K_USER = 'zmw.user', K_NOTE = 'zmw.note'; function store(kind) { try { return kind === 'local' ? window.localStorage : window.sessionStorage; } catch (e) { return null; } } function sget(k) { var v = null; try { var s = store('session'); v = s && s.getItem(k); } catch (e) {} if (v) return v; try { var l = store('local'); v = l && l.getItem(k); } catch (e) {} return v || null; } function sset(k, v, persist) { ['session', 'local'].forEach(function (kind) { try { var s = store(kind); if (!s) return; if (v == null || (kind === 'local') !== !!persist) s.removeItem(k); else s.setItem(k, v); } catch (e) {} }); } var sid = sget(K_SID); function isNode(x) { return x != null && typeof x === 'object' && typeof x.nodeType === 'number'; } function isObj(x) { return x != null && typeof x === 'object' && !Array.isArray(x) && !isNode(x); } function typeOf(x) { return x === null ? 'null' : Array.isArray(x) ? 'array' : typeof x; } function domAttr(node, attrs) { for (var k in attrs) { if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue; var v = attrs[k]; if (v == null) continue; if (typeof v === 'function') node.addEventListener(k, v); else if (typeof v === 'object') node.setAttribute(k, JSON.stringify(v)); else node.setAttribute(k, v); } } function domAppend(node, ch) { if (Array.isArray(ch)) { for (var i = 0; i < ch.length; i++) { var c = ch[i]; if (isNode(c)) node.appendChild(c); else if (Array.isArray(c)) domAppend(node, c); else if (c != null) node.appendChild(document.createTextNode(String(c))); } } else if (typeof ch === 'function') domAppend(node, ch(node)); else if (isNode(ch)) node.appendChild(ch); else if (ch != null) node.innerHTML = String(ch); } function E(tag, attrs, data) { var node; if (isNode(tag)) node = tag; else if (Array.isArray(tag)) { node = document.createDocumentFragment(); domAppend(node, tag); return node; } else if (typeof tag === 'string' && tag.charAt(0) === '<') { var t = document.createElement('template'); t.innerHTML = tag.trim(); node = t.content.firstChild; } else node = document.createElement(tag); if (attrs != null && !isObj(attrs)) { data = attrs; attrs = null; } if (attrs) domAttr(node, attrs); if (data !== undefined) domAppend(node, data); return node; } function tr(s) { return s; } var dom = { elem: isNode, create: E, attr: function (n, k, v) { if (isObj(k)) domAttr(n, k); else { var o = {}; o[k] = v; domAttr(n, o); } }, append: domAppend, content: function (n, ch) { while (n.firstChild) n.removeChild(n.firstChild); domAppend(n, ch); return n; }, parse: function (html) { return E(html); } }; function extend(Base, props) { var C = function () { if (typeof this.__init__ === 'function') return this.__init__.apply(this, arguments); }; C.prototype = Object.create(Base.prototype); C.prototype.constructor = C; for (var k in props) if (Object.prototype.hasOwnProperty.call(props, k)) C.prototype[k] = props[k]; C.prototype.super = function (name, args) { var fn = Base.prototype[name]; return typeof fn === 'function' ? fn.apply(this, args || []) : undefined; }; C.extend = function (p) { return extend(C, p); }; C.__zmClass = true; return C; } function RootClass() {} var baseclass = { extend: function (p) { return extend(RootClass, p); } }; baseclass.singleton = function (p) { return new (baseclass.extend(p))(); }; var UBUS_ERR = [ 'OK', 'неверная команда', 'неверный аргумент', 'метод не найден', 'объект не найден', 'нет данных', 'доступ запрещён', 'таймаут', 'не поддерживается', 'неизвестная ошибка', 'нет соединения' ]; var rpcSeq = 0; function ubus(object, method, params, useSid) { var left = 6; function go() { return ubusOnce(object, method, params, useSid).catch(function (e) { if (left-- > 0 && e && e.rpcCode === -32000 && /Object not found/i.test(e.message || '')) return new Promise(function (ok) { setTimeout(ok, 1500); }).then(go); throw e; }); } return go(); } function ubusOnce(object, method, params, useSid) { var ac = window.AbortController ? new AbortController() : null; var tm = ac ? setTimeout(function () { ac.abort(); }, 60000) : 0; return fetch('/ubus', { method: 'POST', signal: ac ? ac.signal : undefined, headers: { 'Content-Type': 'application/json' }, cache: 'no-store', credentials: 'omit', body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcSeq, method: 'call', params: [ useSid || sid || NULL_SID, object, method, params || {} ] }) }).then(function (r) { clearTimeout(tm); if (r.status === 404) { var e404 = new Error('На роутере не отвечает /ubus — нужен пакет uhttpd-mod-ubus.'); e404.noUbus = true; throw e404; } return r.json().catch(function () { throw new Error('Некорректный ответ роутера (HTTP ' + r.status + ')'); }); }).then(function (msg) { if (msg && msg.error) { var e = new Error(msg.error.message || 'Ошибка RPC'); e.rpcCode = msg.error.code; if (msg.error.code === -32002 && !useSid) { e.authLost = true; authLost(); } throw e; } if (!msg || !Array.isArray(msg.result)) throw new Error('Некорректный ответ ubus'); if (object === 'zapret-manager' && !/(status|info|health|list|tail|log|latest|version|export|get)/.test(method)) scheduleShellRefresh(); return msg.result; }, function (err) { clearTimeout(tm); if (err && err.name === 'AbortError') throw new Error('роутер не ответил за 60 секунд'); throw err; }); } var MEMO_TTL = { 'zapret-manager.system_info': 4000, 'zapret-manager.health': 4000, 'system.info': 3000 }, memoCache = {}; function ubusMemo(obj, method, params) { var ttl = MEMO_TTL[obj + '.' + method], k = obj + '.' + method + '.' + sid, now = Date.now(), m = memoCache[k]; if (!ttl || Object.keys(params).length) return ubus(obj, method, params); if (m && now - m.t < ttl) return m.p; var p = ubus(obj, method, params); memoCache[k] = { t: now, p: p }; p.catch(function () { if (memoCache[k] && memoCache[k].p === p) delete memoCache[k]; }); return p; } function declare(o) { return function () { var args = arguments, params = {}; (o.params || []).forEach(function (p, i) { if (args[i] !== undefined) params[p] = args[i]; }); return ubusMemo(o.object, o.method, params).then(function (res) { var code = res[0]; var ret = res.length > 1 ? res[1] : res[0]; if (code !== 0) { var text = 'ubus ' + o.object + '.' + o.method + ': ' + (UBUS_ERR[code] || ('код ' + code)); if (code === 6) toast('Недостаточно прав: ' + o.object + '.' + o.method, 'error'); if (!o.expect) return code; var k0 = Object.keys(o.expect)[0]; if (k0 === undefined || isObj(o.expect[k0])) return { error: text }; return o.expect[k0]; } if (o.expect) { for (var key in o.expect) { if (ret != null && key !== '') ret = ret[key]; if (ret == null || typeOf(ret) !== typeOf(o.expect[key])) ret = o.expect[key]; break; } } if (typeof o.filter === 'function') ret = o.filter(ret, params); return ret; }); }; } var rpc = { declare: declare, getSessionID: function () { return sid; }, setSessionID: function (s) { sid = s; } }; function fsCall(method, params, pick) { return ubus('file', method, params).then(function (res) { if (res[0] !== 0) throw new Error((UBUS_ERR[res[0]] || ('код ' + res[0])) + ' (' + (params.path || params.command || method) + ')'); var r = res[1] || {}; return pick ? r[pick] : r; }); } var fs = { exec: function (command, params, env) { var p = { command: command }; if (Array.isArray(params)) p.params = params; if (isObj(env)) p.env = env; return fsCall('exec', p); }, exec_direct: function (command, params) { return fs.exec(command, params).then(function (r) { return r.stdout || ''; }); }, read: function (path) { return fsCall('read', { path: path }, 'data'); }, read_direct: function (path) { return fs.read(path); }, write: function (path, data, mode) { return fsCall('write', { path: path, data: data == null ? '' : String(data), mode: mode }); }, stat: function (path) { return fsCall('stat', { path: path }); }, list: function (path) { return fsCall('list', { path: path }, 'entries'); }, remove: function (path) { return fsCall('remove', { path: path }); } }; var uciValues = {}, uciChanges = {}; var uci = { load: function (confs) { var list = Array.isArray(confs) ? confs : [ confs ]; return Promise.all(list.map(function (c) { return ubus('uci', 'get', { config: c }).then(function (res) { uciValues[c] = (res[0] === 0 && res[1] && res[1].values) || {}; return c; }); })); }, unload: function (confs) { (Array.isArray(confs) ? confs : [ confs ]).forEach(function (c) { delete uciValues[c]; delete uciChanges[c]; }); }, get: function (conf, sec, opt) { var ch = uciChanges[conf] && uciChanges[conf][sec]; if (opt == null) { var base = uciValues[conf] && uciValues[conf][sec]; if (!base && !ch) return null; var o = {}, k; for (k in base) o[k] = base[k]; for (k in ch) { if (ch[k] === null) delete o[k]; else o[k] = ch[k]; } return o; } if (ch && Object.prototype.hasOwnProperty.call(ch, opt)) return ch[opt] === null ? null : ch[opt]; var s = uciValues[conf] && uciValues[conf][sec]; return s && s[opt] != null ? s[opt] : null; }, get_first: function (conf, type, opt) { var vals = uciValues[conf] || {}; for (var s in vals) if (!type || vals[s]['.type'] === type) return uci.get(conf, s, opt); return null; }, set: function (conf, sec, opt, val) { uciChanges[conf] = uciChanges[conf] || {}; uciChanges[conf][sec] = uciChanges[conf][sec] || {}; uciChanges[conf][sec][opt] = (val == null || val === '') ? null : val; }, unset: function (conf, sec, opt) { uci.set(conf, sec, opt, null); }, sections: function (conf, type, cb) { var vals = uciValues[conf] || {}, out = []; Object.keys(vals).forEach(function (s) { if (!type || vals[s]['.type'] === type) { out.push(vals[s]); if (cb) cb(vals[s], s); } }); return out; }, changes: function () { return Promise.resolve(uciChanges); }, save: function () { var jobs = []; Object.keys(uciChanges).forEach(function (conf) { Object.keys(uciChanges[conf]).forEach(function (sec) { var ch = uciChanges[conf][sec], sets = {}, dels = [], hasSet = false; Object.keys(ch).forEach(function (k) { if (ch[k] === null) dels.push(k); else { sets[k] = ch[k]; hasSet = true; } }); if (hasSet) jobs.push(ubus('uci', 'set', { config: conf, section: sec, values: sets })); if (dels.length) jobs.push(ubus('uci', 'delete', { config: conf, section: sec, options: dels })); }); }); var confs = Object.keys(uciChanges); uciChanges = {}; return Promise.all(jobs).then(function () { return confs.length ? uci.load(confs) : []; }); }, apply: function () { return uci.save().then(function () { return ubus('uci', 'apply', { rollback: false }); }); } }; var pollJobs = [], pollTimer = null, pollTickN = 0; function pollEnsure() { if (pollTimer) return; pollTimer = setInterval(function () { pollTickN++; if (document.hidden) return; pollJobs.forEach(function (j) { if (j.busy || pollTickN % j.interval !== 0) return; j.busy = true; Promise.resolve().then(j.fn).catch(function () {}).then(function () { j.busy = false; }); }); }, 1000); } var poll = { add: function (fn, interval) { pollJobs.push({ fn: fn, interval: Math.max(1, +interval || 5), busy: false }); pollEnsure(); return true; }, remove: function (fn) { var n = pollJobs.length; pollJobs = pollJobs.filter(function (j) { return j.fn !== fn; }); return n !== pollJobs.length; }, start: function () { pollEnsure(); return true; }, stop: function () { return true; }, active: function () { return pollJobs.length > 0; }, _reset: function () { pollJobs = []; } }; var modalEl = null; var ui = { changes: { setIndicator: function () {}, renderChangeIndicator: function () {}, init: function () {} }, addNotification: function (title, content, cls) { var txt = (title ? title + ': ' : '') + (isNode(content) ? content.textContent : (content || '')); toast(txt, /danger|error/.test(cls || '') ? 'error' : (/warning/.test(cls || '') ? 'warning' : 'info')); return null; }, showModal: function (title, children) { ui.hideModal(); modalEl = E('div', { 'class': 'zmw-modal-wrap' }, [ E('div', { 'class': 'zmw-modal' }, [ title ? E('h3', {}, [ title ]) : null, E('div', {}, children) ]) ]); document.body.appendChild(modalEl); requestAnimationFrame(function () { modalEl && modalEl.classList.add('zmw-in'); }); return modalEl; }, hideModal: function () { if (modalEl) { modalEl.remove(); modalEl = null; } }, createHandlerFn: function (ctx, fn) { var args = [].slice.call(arguments, 2); if (typeof fn === 'string') fn = ctx[fn]; return function (ev) { return fn.apply(ctx, args.concat([ ev ])); }; }, awaitReconnect: function () { setTimeout(function () { location.reload(); }, 5000); } }; var ViewBase = extend(RootClass, { load: function () {}, render: function () {}, handleSave: null, handleSaveApply: null, handleReset: null, addFooter: function () { return E('div'); } }); var viewMod = { extend: function (p) { return ViewBase.extend(p); } }; var L = { env: { resource: RES.replace(/\/$/, ''), sessionid: sid }, resource: function () { return RES + [].slice.call(arguments).join('/'); }, url: function () { var p = [].slice.call(arguments).join('/').replace(/^\/+/, ''); if (/(^|\/)logout$/.test(p)) return '/?logout=1' + location.hash; return '/cgi-bin/luci/' + p; }, bind: function (fn, self) { var a = [].slice.call(arguments, 2); return function () { return fn.apply(self, a.concat([].slice.call(arguments))); }; }, isObject: isObj, toArray: function (x) { return x == null ? [] : Array.isArray(x) ? x : [ x ]; }, resolveDefault: function (p, d) { return Promise.resolve(p).catch(function () { return d; }); }, sortedKeys: function (o) { return Object.keys(o || {}).sort(); }, hasSystemFeature: function () { return false; }, raise: function (t, m) { var e = new Error(m || t); e.name = t; throw e; }, error: function (t, m) { L.raise(t, m); }, dom: dom, ui: ui, Poll: poll, Class: baseclass, require: function (n) { return requireModule(n); } }; window.L = L; window.E = E; window._ = tr; var BUILTIN = { baseclass: baseclass, rpc: rpc, ui: ui, view: viewMod, fs: fs, uci: uci, poll: poll, dom: dom }; var srcCache = {}, modCache = {}; function modUrl(name) { return RES + name.replace(/\./g, '/') + '.js'; } function fetchSrc(name) { if (!srcCache[name]) { srcCache[name] = fetch(modUrl(name) + '?v=' + BUILD, { cache: 'no-cache' }).then(function (r) { if (!r.ok) throw new Error('Не удалось загрузить ' + name + ' (HTTP ' + r.status + ')'); return r.text(); }); srcCache[name].catch(function () { delete srcCache[name]; }); } return srcCache[name]; } var SRC_PATCHES = [ [ /E\('h2', \{\}, 'Zapret Manager LuCI'\)/g, "E('h2', {}, 'Zapret Manager')" ], [ /выведены из LuCI/g, 'выведены из панели' ], [ /(var tabBar = E\('div', \{ 'class': 'zm-actions)'/g, "$1 zmw-tabs'" ] ]; var REQ_RE = /^[ \t]*'require[ \t]+([A-Za-z0-9_.\-\/]+)(?:[ \t]+as[ \t]+([A-Za-z_$][A-Za-z0-9_$]*))?[ \t]*'[ \t]*;?/gm; function evaluate(name, src) { SRC_PATCHES.forEach(function (p) { src = src.replace(p[0], p[1]); }); var deps = [], m; REQ_RE.lastIndex = 0; while ((m = REQ_RE.exec(src))) deps.push({ name: m[1], as: m[2] || m[1].split(/[.\/]/).pop() }); return Promise.all(deps.map(function (d) { return requireModule(d.name); })).then(function (insts) { var names = [ 'E', '_', 'N_', 'L' ].concat(deps.map(function (d) { return d.as; })); var fn = Function.apply(null, names.concat([ src + '\n//# sourceURL=' + location.origin + modUrl(name) ])); return fn.apply(window, [ E, tr, tr, L ].concat(insts)); }); } function postPatch(name, inst) { if (name === 'zapret-manager.common' && inst) { inst.refreshBanner = function () { return E('div', { 'class': 'zm-refresh-banner zm-show' }, [ E('span', {}, 'Состав компонентов изменился — обновите страницу, чтобы увидеть актуальное состояние.'), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function () { location.reload(); } }, 'Обновить') ]); }; var origPoll = inst.pollJob; inst.pollJob = function (job, logEl, onDone, onTick) { return origPoll.call(this, job, logEl, function (ok) { try { if (typeof onDone === 'function') onDone(ok); } finally { setTimeout(function () { refreshShellStatus(); refreshMemory(); }, 1000); } }, onTick); }; setTimeout(refreshShellStatus, 300); } return inst; } function requireModule(name) { if (BUILTIN[name]) return Promise.resolve(BUILTIN[name]); if (!modCache[name]) { modCache[name] = fetchSrc(name).then(function (src) { return evaluate(name, src); }).then(function (res) { return postPatch(name, (typeof res === 'function' && res.__zmClass) ? new res() : res); }); modCache[name].catch(function () { delete modCache[name]; }); } return modCache[name]; } var ICONS = { dashboard: '', shield: '', bolt: '', list: '', globe: '', send: '', layers: '', play: '', cpu: '', logout: '', sun: '', moon: '', ink: '', bento: '', minimal: '', retro: '', depth: '', palette: '', refresh: '', menu: '', close: '', external: '', eye: '', eyeOff: '', user: '', lock: '', arrow: '', alert: '', fork: '', route: '', tunnel: '', anarchy: '', rocket: '', telegram: '' }; function icon(name, cls) { var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '1.8'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); s.setAttribute('aria-hidden', 'true'); s.setAttribute('class', 'zmw-i' + (cls ? ' ' + cls : '')); s.innerHTML = ICONS[name] || ''; return s; } var logoSeq = 0; function logo(cls) { var id = 'zmwz' + (++logoSeq); return E('div', { 'class': 'zmw-logo' + (cls ? ' ' + cls : '') }, [ E('') ]); } function toast(message, kind, duration) { var c = document.getElementById('zm-toast-container'); if (!c) { c = E('div', { id: 'zm-toast-container' }); document.body.appendChild(c); } var el = E('div', { 'class': 'zm-toast zm-toast-' + (kind || 'info') }, [ E('span', { 'class': 'zm-toast-icon' }), E('span', { 'class': 'zm-toast-text' }, [ message ]) ]); Array.prototype.slice.call(c.children).forEach(function (o) { if (o !== el && o._zmText === String(message)) o.remove(); }); el._zmText = String(message); c.appendChild(el); if (window.zmToastFit) window.zmToastFit(); requestAnimationFrame(function () { el.classList.add('zm-toast-show'); }); var hide = function () { el.classList.remove('zm-toast-show'); setTimeout(function () { el.remove(); }, 250); }; el.addEventListener('click', hide); setTimeout(hide, duration || (kind === 'error' ? 12000 : 6000)); } var mqDark = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; var THEMES = [ { id: 'micro', name: 'Светлая', ico: 'sun', fonts: 'Onest:wght@400;500;600;700', color: '#f4f5f8' }, { id: 'light', name: 'Лёгкая', ico: 'sun' }, { id: 'dark', name: 'Тёмная', ico: 'moon', dark: true }, { id: 'ink', name: 'Контур', ico: 'ink' }, { id: 'bento', name: 'Бенто', ico: 'bento', fonts: 'Onest:wght@400;500;600;700&family=Unbounded:wght@600;800', color: '#efebe4' }, { id: 'minimal', name: 'Минимал', ico: 'minimal', fonts: 'IBM+Plex+Sans:wght@300;400;500', color: '#ffffff' }, { id: 'retro', name: 'Ретро 98', ico: 'retro', color: '#008080' }, { id: 'depth', name: 'Объём 3D', ico: 'depth', dark: true, fonts: 'Onest:wght@400;500;600;700&family=Unbounded:wght@600;800', color: '#0a0e18' } ]; function themeById(id) { return THEMES.filter(function (x) { return x.id === id; })[0]; } function themePref() { var t = sget(K_THEME); return themeById(t) ? t : 'auto'; } var fontsFailed = false; function themeFonts(t) { if (fontsFailed || !t || !t.fonts || document.getElementById('zmw-font-' + t.id)) return; var l = document.createElement('link'), done = false; l.id = 'zmw-font-' + t.id; l.rel = 'stylesheet'; l.media = 'print'; l.onload = function () { done = true; l.media = 'all'; }; l.onerror = function () { done = true; fontsFailed = true; l.remove(); }; l.href = 'https://fonts.googleapis.com/css2?family=' + t.fonts + '&display=swap'; document.head.appendChild(l); setTimeout(function () { if (!done) { fontsFailed = true; l.remove(); } }, 3000); } function themeEffective() { var p = themePref(); return p === 'auto' ? 'micro' : p; } function applyTheme() { var t = themeEffective(); var h = document.documentElement; var th = themeById(t) || THEMES[0]; themeFonts(th); h.setAttribute('data-theme', t); h.classList.toggle('zm-theme-dark', !!th.dark); h.setAttribute('data-zm-theme-checked', '1'); var mc = document.querySelector('meta[name="theme-color"]'); if (mc) mc.setAttribute('content', th.color || (t === 'dark' ? '#0a0c12' : t === 'ink' ? '#ffffff' : '#f3f5fa')); var cur = th; Array.prototype.forEach.call(document.querySelectorAll('.zmw-theme-toggle'), function (b) { b.innerHTML = ''; b.appendChild(icon('palette')); b.title = 'Тема оформления: ' + cur.name; b.setAttribute('aria-label', 'Тема оформления'); b.setAttribute('aria-haspopup', 'menu'); }); } var themeMenuEl = null; function closeThemeMenu() { if (!themeMenuEl) return; themeMenuEl.remove(); themeMenuEl = null; document.removeEventListener('click', themeMenuOutside, true); document.removeEventListener('keydown', themeMenuKey, true); } function themeMenuOutside(e) { if (themeMenuEl && !themeMenuEl.contains(e.target) && !(e.target.closest && e.target.closest('.zmw-theme-toggle'))) closeThemeMenu(); } function themeMenuKey(e) { if (e.key === 'Escape') closeThemeMenu(); } var themeDirty = false; function themeSave(id) { if (!sid || sid === NULL_SID) { themeDirty = true; return; } ubus('zapret-manager', 'ui_theme_set', { theme: id }).then(function (r) { themeDirty = !(r && r[0] === 0 && r[1] && r[1].ok); if (r && r[0] === 6) toast('Тема не сохранилась на роутере — выйдите из панели и войдите снова', 'warning'); }).catch(function () { themeDirty = true; }); } function themeSync() { fetch('/zm/theme.txt?t=' + Date.now(), { cache: 'no-store', credentials: 'omit' }).then(function (r) { return r.ok ? r.text() : ''; }).then(function (t) { t = String(t || '').trim(); if (themeDirty) return; if (!t) { if (sget(K_THEME)) themeSave(sget(K_THEME)); return; } if (t !== 'auto' && !themeById(t)) return; if (t === themePref() || (t === 'auto' && !sget(K_THEME))) return; sset(K_THEME, t === 'auto' ? null : t, true); applyTheme(); }).catch(function () {}); } function setTheme(id) { sset(K_THEME, id, true); themeSave(id); document.documentElement.classList.add('zmw-theme-anim'); applyTheme(); setTimeout(function () { document.documentElement.classList.remove('zmw-theme-anim'); }, 400); } if (mqDark && mqDark.addEventListener) mqDark.addEventListener('change', function () { if (themePref() === 'auto') applyTheme(); }); function toggleTheme(ev) { var btn = ev && ev.currentTarget; if (themeMenuEl) { closeThemeMenu(); return; } var cur = themeEffective(); var items = [ E('div', { 'class': 'zmw-theme-group' }, [ 'Тема оформления' ]) ]; THEMES.forEach(function (t) { items.push(themeOpt(t, cur)); }); themeMenuEl = E('div', { 'class': 'zmw-theme-menu', 'role': 'menu' }, items); function themeOpt(t, cur) { return E('button', { 'type': 'button', 'role': 'menuitemradio', 'aria-checked': t.id === cur ? 'true' : 'false', 'class': 'zmw-theme-opt' + (t.id === cur ? ' zmw-on' : ''), 'click': function (e) { e.stopPropagation(); closeThemeMenu(); if (t.id !== cur) setTheme(t.id); } }, [ icon(t.ico), E('span', {}, [ t.name ]), E('span', { 'class': 'zmw-theme-mark' }) ]); } document.body.appendChild(themeMenuEl); if (btn) { var r = btn.getBoundingClientRect(); themeMenuEl.style.top = Math.round(r.bottom + 8) + 'px'; themeMenuEl.style.right = Math.max(8, Math.round(window.innerWidth - r.right)) + 'px'; } setTimeout(function () { document.addEventListener('click', themeMenuOutside, true); document.addEventListener('keydown', themeMenuKey, true); }, 0); } var ROUTES = [ { id: 'dashboard', title: 'Дашборд', sub: 'Состояние всех компонентов', icon: 'dashboard', group: 'Обзор' }, { id: 'strategy', title: 'Zapret', sub: 'Стратегии, тесты, YouTube, игры, Discord и списки', icon: 'shield', group: 'Обход блокировок', dot: 'zapret' }, { id: 'zapret2', title: 'Zapret2', sub: 'Установка и управление Zapret2', icon: 'bolt', group: 'Обход блокировок', dot: 'zapret2' }, { id: 'steer', title: 'Steer', sub: 'Выбранные сервисы через WARP или VPN', icon: 'route', group: 'Обход блокировок', dot: 'steer' }, { id: 'forkozz', title: 'Forkozz', sub: 'Выбранные сервисы через ваш сервер, подписку или туннель', icon: 'fork', group: 'Обход блокировок', dot: 'forkop' }, { id: 'bytetube', title: 'ByeTube', sub: 'YouTube через ByeDPI', icon: 'play', group: 'Обход блокировок', dot: 'bytetube' }, { id: 'tgproxy', title: 'TG WS Proxy', sub: 'Прокси для Telegram', icon: 'send', group: 'Обход блокировок', dot: 'tg' }, { id: 'mixomo', title: 'Mixomo', sub: 'Mihomo, MagiTrickle и WARP', icon: 'layers', group: 'Обход блокировок', dot: 'mixomo' }, { id: 'hosts', title: 'Hosts', sub: 'Домены в hosts и списки GeoHide', icon: 'list', group: 'Сеть', dot: 'hosts' }, { id: 'doh', title: 'DNS over HTTPS', sub: 'Шифрованный DNS для всей сети', icon: 'globe', group: 'Сеть', dot: 'doh' }, { id: 'awg', title: 'AmneziaWG', sub: 'Туннели AmneziaWG и WARP: установка, ключи, интерфейсы', icon: 'anarchy', group: 'Сеть', dot: 'awg' }, { id: 'system', title: 'Система', sub: 'Параметры роутера, зеркала и обслуживание', icon: 'cpu', group: 'Сервис' } ]; var ROUTE_BY_ID = {}; ROUTES.forEach(function (r) { ROUTE_BY_ID[r.id] = r; }); function currentRoute() { var id = (location.hash || '').replace(/^#\/?/, '').split(/[/?]/)[0]; return ROUTE_BY_ID[id] ? id : 'dashboard'; } var root, shell = null, viewEl, titleEl, subEl, navLinks = {}, navDots = {}, deviceEl, verEl, updateEl, memEl; var alertsEl = E('div', { 'class': 'zmw-alerts' }), alertsKey = ''; function mountAlerts() { if (viewEl && alertsEl.parentNode !== viewEl) viewEl.insertBefore(alertsEl, viewEl.firstChild); else if (viewEl && viewEl.firstChild !== alertsEl) viewEl.insertBefore(alertsEl, viewEl.firstChild); } function renderAlerts(h) { var key = (h && h.flow_warn ? 'f' : '') + (h && h.ipv6_warn ? '6' : '') + (h && h.reboot_hint ? 'r' + h.reboot_hint : ''); if (key === alertsKey) return; alertsKey = key; if (!key) { alertsEl.innerHTML = ''; return; } requireModule('zapret-manager.common').then(function(zm) { alertsEl.innerHTML = ''; alertsEl.appendChild(zm.alertBanners(h, function() { alertsKey = '?'; setTimeout(refreshShellStatus, 1500); })); mountAlerts(); }).catch(function() {}); } function buildShell() { var nav = E('nav', { 'class': 'zmw-nav', 'aria-label': 'Разделы' }); var lastGroup = null; ROUTES.forEach(function (r) { if (r.group !== lastGroup) { nav.appendChild(E('div', { 'class': 'zmw-nav-group' }, [ r.group ])); lastGroup = r.group; } var dot = r.dot ? E('span', { 'class': 'zmw-nav-dot' }) : null; var a = E('a', { 'class': 'zmw-nav-item', 'href': '#/' + r.id, 'data-route': r.id }, [ E('span', { 'class': 'zmw-nav-ico' }, [ icon(r.icon) ]), E('span', { 'class': 'zmw-nav-label' }, [ r.title ]), dot ]); a.addEventListener('click', function () { closeDrawer(); }); navLinks[r.id] = a; if (dot) navDots[r.dot] = dot; nav.appendChild(a); }); deviceEl = E('div', { 'class': 'zmw-device' }, [ E('div', { 'class': 'zmw-device-model' }, [ 'Роутер' ]), E('div', { 'class': 'zmw-device-sub' }, [ location.hostname ]), E('div', { 'class': 'zmw-device-sub' }, [ '' ]) ]); verEl = E('div', { 'class': 'zmw-brand-sub' }, [ 'by StressOzz' ]); updateEl = E('a', { 'class': 'zmw-update', 'href': '#/dashboard', 'hidden': '' }, [ 'Доступно обновление' ]); memEl = E('div', { 'class': 'zmw-mem' }); var side = E('aside', { 'class': 'zmw-side', 'id': 'zmw-side' }, [ E('div', { 'class': 'zmw-brand' }, [ logo(), E('div', { 'class': 'zmw-brand-text' }, [ E('div', { 'class': 'zmw-brand-name' }, [ 'Zapret Manager' ]), verEl ]), E('button', { 'class': 'zmw-icon-btn zmw-drawer-close', 'type': 'button', 'aria-label': 'Закрыть меню', 'click': closeDrawer }, [ icon('close') ]) ]), updateEl, nav, E('div', { 'class': 'zmw-side-foot' }, [ deviceEl, memEl, E('div', { 'class': 'zmw-side-links' }, [ E('a', { 'href': luciUrl(), 'target': '_blank', 'rel': 'noreferrer' }, [ icon('external'), 'Открыть LuCI' ]) ]) ]) ]); titleEl = E('h1', { 'class': 'zmw-title' }, [ '' ]); subEl = E('div', { 'class': 'zmw-sub' }, [ '' ]); viewEl = E('main', { 'class': 'zmw-view', 'id': 'zmw-view' }); var themeBtn = E('button', { 'class': 'zmw-icon-btn zmw-theme-toggle', 'type': 'button', 'click': toggleTheme }); var top = E('header', { 'class': 'zmw-top' }, [ E('button', { 'class': 'zmw-icon-btn zmw-burger', 'type': 'button', 'aria-label': 'Меню', 'click': openDrawer }, [ icon('menu') ]), E('div', { 'class': 'zmw-titles' }, [ titleEl, subEl ]), E('div', { 'class': 'zmw-top-actions' }, [ E('a', { 'class': 'zmw-icon-btn zmw-link-btn zmw-link-kvn', 'href': 'http://stresskvn.lol/', 'target': '_blank', 'rel': 'noreferrer', 'title': 'StressKVN — умный VPN для стабильного доступа в любых условиях.' }, [ icon('rocket'), E('span', { 'class': 'zmw-lbl-full' }, [ E('span', { 'class': 'zmw-kvn-l1' }, [ 'StressKVN — лучший VPN' ]), E('span', { 'class': 'zmw-kvn-l2' }, [ 'обход белых списков · телефон, ПК, роутер' ]) ]), E('span', { 'class': 'zmw-lbl-short' }, [ 'StressKVN' ]) ]), E('a', { 'class': 'zmw-icon-btn zmw-link-btn zmw-link-tg', 'href': 'https://t.me/stressozz_manager', 'target': '_blank', 'rel': 'noreferrer', 'title': 'Сообщество Telegram' }, [ icon('telegram'), E('span', { 'class': 'zmw-lbl-full' }, [ 'Сообщество Telegram' ]), E('span', { 'class': 'zmw-lbl-short' }, [ 'Telegram' ]) ]), E('span', { 'class': 'zmw-top-sep' }), E('button', { 'class': 'zmw-icon-btn', 'type': 'button', 'title': 'Обновить страницу', 'click': function (ev) { var b = ev.currentTarget; b.classList.add('zmw-spin'); setTimeout(function () { b.classList.remove('zmw-spin'); }, 700); route(true); } }, [ icon('refresh') ]), themeBtn, E('button', { 'class': 'zmw-icon-btn zmw-logout', 'type': 'button', 'title': 'Выйти', 'click': function () { logout(); } }, [ icon('logout'), E('span', {}, [ 'Выйти' ]) ]) ]) ]); shell = E('div', { 'class': 'zmw-shell' }, [ side, E('div', { 'class': 'zmw-main' }, [ top, viewEl, E('footer', { 'class': 'zmw-foot' }, [ '' ]) ]), E('div', { 'class': 'zmw-scrim', 'click': closeDrawer }) ]); root.appendChild(shell); applyTheme(); } function luciUrl() { return location.protocol + '//' + location.hostname + '/cgi-bin/luci/admin/services/zapret-manager/dashboard'; } function openDrawer() { document.body.classList.add('zmw-drawer-open'); } function closeDrawer() { document.body.classList.remove('zmw-drawer-open'); } var callStatus = declare({ object: 'zapret-manager', method: 'status', expect: {} }); var callSysInfo = declare({ object: 'zapret-manager', method: 'system_info', expect: {} }); var callHealth = declare({ object: 'zapret-manager', method: 'health', expect: {} }); var callUpd = declare({ object: 'zapret-manager', method: 'zm_update_status', expect: {} }); var callBoardInfo = declare({ object: 'system', method: 'info', expect: {} }); function parseSize(v) { var m = String(v || '').trim().match(/^([\d.,]+)\s*([KMGT]?)i?B?$/i); if (!m) return NaN; var n = parseFloat(m[1].replace(',', '.')); var mul = { '': 1, K: 1024, M: 1048576, G: 1073741824, T: 1099511627776 }[m[2].toUpperCase()]; return n * mul; } function fmtSize(b) { if (!isFinite(b)) return '—'; var u = [ 'Б', 'КБ', 'МБ', 'ГБ', 'ТБ' ], i = 0; while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } return (b >= 100 || i === 0 ? Math.round(b) : b.toFixed(1).replace(/\.0$/, '')) + ' ' + u[i]; } var memRows = {}; function memRow(key, label, used, total) { var pct = total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : 0; var r = memRows[key]; if (!r) { r = memRows[key] = { el: E('div', { 'class': 'zmw-mem-row' }, [ E('div', { 'class': 'zmw-mem-head' }, [ E('span', { 'class': 'zmw-mem-label' }, [ label ]), E('span', { 'class': 'zmw-mem-val' }) ]), E('div', { 'class': 'zmw-mem-bar' }, [ E('i') ]) ]) }; r.order = key === 'temp' ? -1 : key === 'ram' ? 0 : key === 'flash' ? 1 : 2; var before = null; Object.keys(memRows).forEach(function (k) { var o = memRows[k]; if (o !== r && o.el.parentNode && o.order > r.order && (!before || o.order < before.order)) before = o; }); memEl.insertBefore(r.el, before ? before.el : null); } r.el.querySelector('.zmw-mem-val').textContent = fmtSize(used) + ' из ' + fmtSize(total); var bar = r.el.querySelector('i'); bar.style.width = pct.toFixed(1) + '%'; r.el.classList.toggle('zmw-mem-hi', pct >= 85); r.el.classList.toggle('zmw-mem-mid', pct >= 65 && pct < 85); r.el.title = label + ': занято ' + Math.round(pct) + '%'; } function tempRow(t) { var r = memRows.temp; if (!r) { r = memRows.temp = { order: -1, el: E('div', { 'class': 'zmw-mem-row' }, [ E('div', { 'class': 'zmw-mem-head' }, [ E('span', { 'class': 'zmw-mem-label' }, [ 'Температура ЦП' ]), E('span', { 'class': 'zmw-mem-val' }) ]), E('div', { 'class': 'zmw-mem-bar' }, [ E('i') ]) ]) }; memEl.insertBefore(r.el, memEl.firstChild); } r.el.querySelector('.zmw-mem-val').textContent = t + ' °C'; r.el.querySelector('i').style.width = Math.max(0, Math.min(100, t)).toFixed(0) + '%'; r.el.classList.toggle('zmw-mem-hi', t >= 80); r.el.classList.toggle('zmw-mem-mid', t >= 65 && t < 80); r.el.title = 'Температура процессора: ' + t + ' °C'; } function loadRow(pct) { var r = memRows.load; if (!r) { r = memRows.load = { order: -0.5, el: E('div', { 'class': 'zmw-mem-row' }, [ E('div', { 'class': 'zmw-mem-head' }, [ E('span', { 'class': 'zmw-mem-label' }, [ 'Нагрузка ЦП' ]), E('span', { 'class': 'zmw-mem-val' }) ]), E('div', { 'class': 'zmw-mem-bar' }, [ E('i') ]) ]) }; var before = null; Object.keys(memRows).forEach(function (k) { var o = memRows[k]; if (o !== r && o.el.parentNode && o.order > r.order && (!before || o.order < before.order)) before = o; }); memEl.insertBefore(r.el, before ? before.el : null); } pct = Math.max(0, Math.min(100, pct)); r.el.querySelector('.zmw-mem-val').textContent = pct + '%'; r.el.querySelector('i').style.width = pct + '%'; r.el.classList.toggle('zmw-mem-hi', pct >= 90); r.el.classList.toggle('zmw-mem-mid', pct >= 70 && pct < 90); r.el.title = 'Нагрузка процессора: ' + pct + '%'; } function refreshMemory() { if (!shell || !sid) return; callBoardInfo().then(function (i) { var m = i && i.memory; if (!m || !m.total) return; var avail = m.available != null ? m.available : (m.free + (m.buffered || 0) + (m.cached || 0)); memRow('ram', 'ОЗУ', m.total - avail, m.total); }).catch(function () {}); callSysInfo().then(function (i) { if (!i || i.error) return; var ru = parseSize(i.root_used), rf = parseSize(i.root_free); if (isFinite(ru) && isFinite(rf)) memRow('flash', 'Флеш', ru, ru + rf); var t = parseFloat(i.cpu_temp); if (!isNaN(t)) tempRow(t); var cl = parseInt(i.cpu_load, 10); if (!isNaN(cl)) loadRow(cl); }).catch(function () {}); } var statusBusy = false; var DOT_TEXT = { 1: 'работает', 2: 'не работает', 3: 'работает частично' }; function refreshShellStatus() { if (!shell || !sid || statusBusy) return; statusBusy = true; callHealth().then(function (h) { if (h && typeof h.zapret === 'number') return h; return callStatus().then(function (d) { d = d || {}; return { zapret: d.zapret === 'installed' ? (d.zapret_running ? 1 : 2) : 0, zapret2: d.zapret2 === 'installed' ? (d.zapret2_running ? 1 : 2) : 0 }; }); }).then(function(h) { renderAlerts(h); Object.keys(navDots).forEach(function(k) { var title = null; if (k === 'awg' && h.awg_total) title = 'туннелей работает: ' + (h.awg_up || 0) + ' из ' + h.awg_total; setDot(navDots[k], h[k], title); }); }).catch(function () {}).then(function () { statusBusy = false; }); } function setDot(el, st, title) { if (!el) return; el.className = 'zmw-nav-dot' + (st === 1 ? ' zmw-on' : st === 2 ? ' zmw-stop' : st === 3 ? ' zmw-part' : ''); el.title = title || DOT_TEXT[st] || ''; } function loadShellInfo() { callSysInfo().then(function (i) { if (!i || i.error) return; deviceEl.firstChild.textContent = String(i.model || 'Роутер').replace(/\s*\(.*\)\s*$/, '') || i.model; deviceEl.title = [ i.model, i.openwrt ? 'OpenWrt ' + i.openwrt : '', i.arch ].filter(Boolean).join('\n'); deviceEl.children[1].textContent = i.openwrt ? 'OpenWrt ' + i.openwrt : location.hostname; deviceEl.children[2].textContent = i.arch || ''; }).catch(function () {}); callUpd().then(function (u) { if (!u || u.error) return; if (u.current) verEl.textContent = 'by StressOzz · v' + u.current; if (u.latest && u.current && u.latest !== u.current && u.newer !== false) { updateEl.hidden = false; updateEl.textContent = 'Доступна версия ' + u.latest; } }).catch(function () {}); } var shellTimer = null; var shellRefreshTimer = null; function scheduleShellRefresh() { clearTimeout(shellRefreshTimer); shellRefreshTimer = setTimeout(function () { refreshShellStatus(); }, 1200); } window.addEventListener('zm:changed', scheduleShellRefresh); function startShellPolling() { if (shellTimer) return; shellTimer = setInterval(function () { if (!document.hidden) refreshShellStatus(); }, 15000); setInterval(function () { if (!document.hidden) refreshMemory(); }, 5000); } var routeToken = 0; function skeleton() { return E('div', { 'class': 'zmw-skel-wrap' }, [ E('div', { 'class': 'zmw-skel zmw-skel-bar' }), E('div', { 'class': 'zmw-skel-grid' }, [ E('div', { 'class': 'zmw-skel zmw-skel-card' }), E('div', { 'class': 'zmw-skel zmw-skel-card' }), E('div', { 'class': 'zmw-skel zmw-skel-card' }) ]), E('div', { 'class': 'zmw-skel zmw-skel-wide' }) ]); } function errorCard(err, retry) { return E('div', { 'class': 'zmw-error' }, [ E('div', { 'class': 'zmw-error-ico' }, [ icon('alert') ]), E('div', {}, [ E('h3', {}, [ 'Не удалось открыть страницу' ]), E('p', {}, [ (err && err.message) || String(err) ]), E('button', { 'class': 'cbi-button cbi-button-positive', 'click': retry }, [ 'Повторить' ]) ]) ]); } function route(force) { if (!shell || !sid) return; var id = currentRoute(); var r = ROUTE_BY_ID[id]; var token = ++routeToken; Object.keys(navLinks).forEach(function (k) { navLinks[k].classList.toggle('zmw-active', k === id); }); titleEl.textContent = r.title; subEl.textContent = r.sub; document.title = r.title + ' · Zapret Manager'; poll._reset(); ui.hideModal(); refreshShellStatus(); viewEl.innerHTML = ''; viewEl.appendChild(alertsEl); viewEl.appendChild(skeleton()); if (!force) window.scrollTo(0, 0); fetchSrc('view.zapret-manager.' + id) .then(function (src) { return evaluate('view.zapret-manager.' + id, src); }) .then(function (Cls) { if (token !== routeToken) return; var v = (typeof Cls === 'function' && Cls.__zmClass) ? new Cls() : Cls; return Promise.resolve(v.load()).then(function (data) { if (token !== routeToken) return; return Promise.resolve(v.render(data)); }).then(function (node) { if (token !== routeToken || !node) return; var page = E('div', { 'class': 'zmw-page' }, [ node ]); viewEl.innerHTML = ''; viewEl.appendChild(alertsEl); viewEl.appendChild(page); }); }) .catch(function (err) { if (token !== routeToken || (err && err.authLost)) return; console.error(err); viewEl.innerHTML = ''; viewEl.appendChild(alertsEl); viewEl.appendChild(errorCard(err, function () { route(true); })); }); } var loginEl = null, authLostShown = false; function showLogin(note, noteKind) { if (loginEl) return; var user = E('input', { 'type': 'text', 'name': 'username', 'autocomplete': 'username', 'autocapitalize': 'off', 'spellcheck': 'false', 'value': sget(K_USER) || 'root', 'required': '' }); var pass = E('input', { 'type': 'password', 'name': 'password', 'autocomplete': 'current-password', 'placeholder': '••••••••' }); var eye = E('button', { 'type': 'button', 'class': 'zmw-eye', 'tabindex': '-1', 'aria-label': 'Показать пароль' }, [ icon('eye') ]); eye.addEventListener('click', function () { var show = pass.type === 'password'; pass.type = show ? 'text' : 'password'; eye.innerHTML = ''; eye.appendChild(icon(show ? 'eyeOff' : 'eye')); pass.focus(); }); var remember = E('input', { 'type': 'checkbox', 'checked': '' }); var errEl = E('div', { 'class': 'zmw-login-msg' + (note ? ' zmw-show zmw-' + (noteKind || 'info') : '') }, [ note || '' ]); var btn = E('button', { 'type': 'submit', 'class': 'zmw-btn-primary' }, [ E('span', {}, [ 'Войти' ]), icon('arrow') ]); var card = E('form', { 'class': 'zmw-login-card', 'autocomplete': 'on' }, [ logo('zmw-logo-lg'), E('h1', {}, [ 'Zapret Manager' ]), E('p', { 'class': 'zmw-login-sub' }, [ 'Панель управления роутером ', E('b', {}, [ location.hostname ]) ]), E('label', { 'class': 'zmw-field' }, [ E('span', { 'class': 'zmw-field-label' }, [ 'Пользователь' ]), E('div', { 'class': 'zmw-input' }, [ icon('user'), user ]) ]), E('label', { 'class': 'zmw-field' }, [ E('span', { 'class': 'zmw-field-label' }, [ 'Пароль' ]), E('div', { 'class': 'zmw-input' }, [ icon('lock'), pass, eye ]) ]), E('label', { 'class': 'zmw-check' }, [ remember, E('span', { 'class': 'zmw-check-box' }), E('span', {}, [ 'Запомнить на этом устройстве' ]) ]), errEl, btn, E('div', { 'class': 'zmw-login-foot' }, [ 'Логин и пароль те же, что и для LuCI' ]) ]); function fail(msg) { errEl.className = 'zmw-login-msg zmw-show zmw-error'; errEl.textContent = msg; card.classList.remove('zmw-shake'); void card.offsetWidth; card.classList.add('zmw-shake'); btn.disabled = false; btn.classList.remove('zmw-loading'); } card.addEventListener('submit', function (ev) { ev.preventDefault(); if (btn.disabled) return; btn.disabled = true; btn.classList.add('zmw-loading'); errEl.className = 'zmw-login-msg'; ubus('session', 'login', { username: user.value.trim(), password: pass.value, timeout: 3600 }, NULL_SID).then(function (res) { if (res[0] === 6 || !res[1] || !res[1].ubus_rpc_session) { fail('Неверный логин или пароль'); return; } var s = res[1].ubus_rpc_session; var acl = res[1].acls && res[1].acls.ubus; if (acl && !acl['zapret-manager'] && !acl['*']) { fail('У пользователя нет доступа к Zapret Manager'); return; } return ubus('zapret-manager', 'status', {}, s).then(function (st) { if (st[0] === 3 || st[0] === 4) { fail('rpcd-плагин zapret-manager не найден — переустановите панель'); return; } sid = s; L.env.sessionid = s; sset(K_SID, s, remember.checked); sset(K_USER, user.value.trim(), true); closeLogin(); startApp(); }); }).catch(function (e) { if (e && e.rpcCode === -32000 && /Object not found/i.test(e.message || '')) fail('Служба rpcd на роутере ещё запускается — подождите минуту и нажмите «Войти» снова'); else fail(e && e.noUbus ? e.message : ('Роутер не отвечает: ' + ((e && e.message) || e))); }); }); loginEl = E('div', { 'class': 'zmw-login' }, [ E('div', { 'class': 'zmw-orbs', 'aria-hidden': 'true' }, [ E('i'), E('i'), E('i') ]), E('div', { 'class': 'zmw-grid-bg', 'aria-hidden': 'true' }), card, E('button', { 'type': 'button', 'class': 'zmw-icon-btn zmw-login-theme zmw-theme-toggle', 'click': toggleTheme }) ]); document.body.classList.add('zmw-locked'); root.appendChild(loginEl); applyTheme(); setTimeout(function () { (user.value ? pass : user).focus(); }, 60); } function closeLogin() { if (!loginEl) return; var el = loginEl; loginEl = null; authLostShown = false; el.classList.add('zmw-leave'); document.body.classList.remove('zmw-locked'); setTimeout(function () { el.remove(); }, 380); } function authLost() { if (authLostShown || loginEl) return; authLostShown = true; sid = null; sset(K_SID, null); poll._reset(); freshLogin('Сессия истекла — войдите снова.', 'warning'); } function dropCaches() { srcCache = {}; modCache = {}; try { if (window.caches && caches.keys) caches.keys().then(function (ks) { ks.forEach(function (k) { caches.delete(k); }); }).catch(function () {}); } catch (e) {} try { var s = store('session'); if (s) Object.keys(s).forEach(function (k) { if (/^zmw\./.test(k) && k !== K_THEME && k !== K_USER && k !== K_NOTE) s.removeItem(k); }); } catch (e) {} } function freshLogin(note, kind) { dropCaches(); var shown = false; var show = function () { if (!shown) { shown = true; showLogin(note, kind); } }; var t = setTimeout(show, 2500); fetch('/zm-webui.html?t=' + Date.now(), { cache: 'no-store' }).then(function (r) { return r.ok ? r.text() : ''; }).then(function (html) { var m = /app\.js\?v=([0-9A-Za-z_]+)/.exec(html || ''); if (m && m[1] !== BUILD && !shown) { clearTimeout(t); shown = true; try { var s = store('session'); if (s) s.setItem(K_NOTE, JSON.stringify([ (note ? note + ' ' : '') + 'Панель обновлена — загружена новая версия.', kind || 'info' ])); } catch (e) {} location.replace('/?fresh=' + Date.now() + location.hash); return; } clearTimeout(t); show(); }).catch(function () { clearTimeout(t); show(); }); } function logout() { var s = sid; sid = null; sset(K_SID, null); var done = function () { poll._reset(); if (viewEl) viewEl.innerHTML = ''; freshLogin('Вы вышли из панели.', 'info'); }; if (!s) return done(); ubus('session', 'destroy', {}, s).catch(function () {}).then(done); } var started = false; function startApp() { if (themeDirty && sget(K_THEME)) themeSave(sget(K_THEME)); if (!shell) buildShell(); document.body.classList.add('zmw-ready'); if (!started) { started = true; window.addEventListener('hashchange', function () { route(); }); startShellPolling(); } loadShellInfo(); refreshShellStatus(); refreshMemory(); route(); } function boot() { root = document.getElementById('zmw-root'); document.body.classList.add('zmw-body'); applyTheme(); themeSync(); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeDrawer(); } }); var note = null; try { var ss = store('session'); note = ss && JSON.parse(ss.getItem(K_NOTE) || 'null'); if (ss) ss.removeItem(K_NOTE); } catch (e) {} if (/[?&]fresh=/.test(location.search)) { try { history.replaceState(null, '', '/' + location.hash); } catch (e) {} } if (note && !sid) { showLogin(note[0], note[1]); return; } if (/[?&]logout=1/.test(location.search)) { var s = sid; sid = null; sset(K_SID, null); try { history.replaceState(null, '', '/' + location.hash); } catch (e) {} if (s) ubus('session', 'destroy', {}, s).catch(function () {}); freshLogin('Вы вышли из панели. Войдите снова.', 'info'); return; } if (!sid) { showLogin(); return; } ubus('session', 'access', { scope: 'ubus', object: 'zapret-manager', 'function': 'status' }, sid).then(function (res) { if (!(res[0] === 0 && res[1] && res[1].access)) { sid = null; sset(K_SID, null); showLogin(); return; } return Promise.all([ ubus('session', 'access', { scope: 'ubus', object: 'zapret-manager', 'function': 'ui_theme_set' }, sid), ubus('session', 'access', { scope: 'ubus', object: 'zapret-manager', 'function': 'zapret_action' }, sid) ]).then(function (rr) { var r2 = rr[0], w = rr[1]; if (r2[0] === 0 && r2[1] && r2[1].access === false && w[0] === 0 && w[1] && w[1].access === true) { var old = sid; sid = null; sset(K_SID, null); ubus('session', 'destroy', {}, old).catch(function () {}); showLogin('Панель обновилась — войдите снова, чтобы заработали новые функции и сохранялась тема.', 'info'); return; } startApp(); }, function () { startApp(); }); }).catch(function (e) { sid = null; sset(K_SID, null); showLogin(e && e.noUbus ? e.message : null, 'error'); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); })(); ZM_INSTALLER_EOF cat > '/www/zm/app.css' << 'ZM_INSTALLER_EOF' :root { --font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --mono: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", monospace; --a1: #7c5cff; --a2: #3b82f6; --a3: #22d3ee; --grad: linear-gradient(135deg, var(--a1) 0%, var(--a2) 55%, var(--a3) 100%); --grad-soft: linear-gradient(135deg, rgba(124,92,255,.16), rgba(34,211,238,.10)); --ring: 0 0 0 3px rgba(124,92,255,.28); --radius: 18px; --radius-sm: 12px; --side-w: 272px; --bg: #f3f5fa; --bg-glow-1: rgba(124,92,255,.12); --bg-glow-2: rgba(34,211,238,.10); --surface: rgba(255,255,255,.82); --surface-solid: #ffffff; --surface-2: #f5f6fb; --surface-3: #eceff6; --side-bg: rgba(255,255,255,.66); --border: rgba(15,23,42,.08); --border-2: rgba(15,23,42,.14); --text: #0f172a; --text-2: #334155; --muted: #64748b; --shadow: 0 1px 2px rgba(15,23,42,.04), 0 12px 32px -18px rgba(15,23,42,.18); --shadow-lg: 0 24px 60px -24px rgba(15,23,42,.35); --input-bg: #ffffff; --console: #0b1020; --console-border: rgba(15,23,42,.12); --ok: #059669; --ok-bg: rgba(16,185,129,.12); --ok-dot: #10b981; --bad: #dc2626; --bad-bg: rgba(239,68,68,.10); --bad-dot: #ef4444; --warn: #b45309; --warn-bg: rgba(245,158,11,.14); --warn-dot: #f59e0b; --off: #64748b; --off-bg: rgba(100,116,139,.12); --background-color-medium: var(--surface-solid); --background-color-low: var(--surface-2); color-scheme: light; } html[data-theme="dark"] { --bg: #07090f; --bg-glow-1: rgba(124,92,255,.20); --bg-glow-2: rgba(34,211,238,.10); --surface: rgba(22,27,38,.72); --surface-solid: #121722; --surface-2: rgba(255,255,255,.045); --surface-3: rgba(255,255,255,.08); --side-bg: rgba(12,15,23,.72); --border: rgba(255,255,255,.07); --border-2: rgba(255,255,255,.13); --text: #e8ebf4; --text-2: #c3c9d8; --muted: #8a93a8; --shadow: 0 1px 0 rgba(255,255,255,.03) inset, 0 16px 40px -22px rgba(0,0,0,.8); --shadow-lg: 0 30px 80px -30px rgba(0,0,0,.9); --input-bg: rgba(255,255,255,.04); --console: #070a12; --console-border: rgba(255,255,255,.08); --ok: #34d399; --ok-bg: rgba(52,211,153,.12); --ok-dot: #34d399; --bad: #f87171; --bad-bg: rgba(248,113,113,.12); --bad-dot: #f87171; --warn: #fbbf24; --warn-bg: rgba(251,191,36,.12); --warn-dot: #fbbf24; --off: #94a3b8; --off-bg: rgba(148,163,184,.12); color-scheme: dark; } *, *::before, *::after { box-sizing: border-box; } html, body { margin: 0; padding: 0; } body.zmw-body { font-family: var(--font); font-size: 14px; line-height: 1.5; color: var(--text); background-color: var(--bg); background-image: radial-gradient(900px 600px at -10% -20%, var(--bg-glow-1), transparent 60%), radial-gradient(800px 520px at 110% -10%, var(--bg-glow-2), transparent 60%); background-attachment: fixed; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; min-height: 100vh; overflow-x: hidden; } body.zmw-locked { overflow: hidden; } html.zmw-theme-anim body, html.zmw-theme-anim .zmw-side, html.zmw-theme-anim .zm-card, html.zmw-theme-anim .zmw-top, html.zmw-theme-anim .cbi-button { transition: background-color .35s, color .35s, border-color .35s !important; } a { color: inherit; } ::selection { background: rgba(124,92,255,.35); } ::-webkit-scrollbar { width: 12px; height: 12px; } ::-webkit-scrollbar-button { display: none; width: 0; height: 0; } ::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; } ::-webkit-scrollbar-thumb { background: rgba(128,128,140,.38); border-radius: 10px; border: 3px solid transparent; background-clip: padding-box; min-height: 36px; } ::-webkit-scrollbar-thumb:hover { background: rgba(128,128,140,.6); background-clip: padding-box; } html.zm-theme-dark ::-webkit-scrollbar-thumb { background: rgba(255,255,255,.16); background-clip: padding-box; } html.zm-theme-dark ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.3); background-clip: padding-box; } @supports not selector(::-webkit-scrollbar) { * { scrollbar-width: thin; scrollbar-color: rgba(128,128,140,.45) transparent; } } #zmw-view .zm-config-editor::-webkit-scrollbar-track, #zmw-view textarea::-webkit-scrollbar-track { margin: 10px 0; } #zmw-view .zm-config-editor::-webkit-scrollbar-corner, #zmw-view textarea::-webkit-scrollbar-corner, #zmw-view textarea::-webkit-resizer { background: transparent; } .zmw-i { width: 20px; height: 20px; flex-shrink: 0; display: block; } .zmw-logo { width: 42px; height: 42px; border-radius: 13px; background: radial-gradient(120% 90% at 30% 0%, rgba(56,189,248,.35), transparent 60%), linear-gradient(160deg, #13223f 0%, #0a1426 55%, #060c18 100%); display: grid; place-items: center; flex-shrink: 0; box-shadow: 0 8px 22px -8px rgba(14,165,233,.75), inset 0 1px 0 rgba(255,255,255,.14), inset 0 0 0 1px rgba(56,189,248,.28); position: relative; overflow: visible; } .zmw-logo svg { width: 32px; height: 32px; filter: drop-shadow(0 0 6px rgba(56,189,248,.55)) drop-shadow(0 2px 2px rgba(0,0,0,.45)); } .zmw-logo-lg { width: 76px; height: 76px; border-radius: 22px; margin: 0 auto 18px; } .zmw-logo-lg svg { width: 58px; height: 58px; animation: zmw-zap 4.5s ease-in-out infinite; } .zmw-logo-lg::after { content: ""; position: absolute; inset: -12px; border-radius: 30px; background: radial-gradient(circle, rgba(14,165,233,.55), rgba(124,92,255,.25) 60%, transparent 75%); filter: blur(18px); z-index: -1; } @keyframes zmw-zap { 0%, 86%, 100% { filter: drop-shadow(0 0 8px rgba(56,189,248,.55)) drop-shadow(0 2px 2px rgba(0,0,0,.45)); } 90% { filter: drop-shadow(0 0 18px rgba(125,211,252,1)) drop-shadow(0 0 4px #fff); } 94% { filter: drop-shadow(0 0 6px rgba(56,189,248,.45)) drop-shadow(0 2px 2px rgba(0,0,0,.45)); } } .zmw-icon-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 40px; min-width: 40px; padding: 0 10px; border-radius: 12px; border: 1px solid var(--border); background: var(--surface); color: var(--text-2); cursor: pointer; font: 600 13px/1 var(--font); transition: background .15s, border-color .15s, color .15s, transform .1s; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } .zmw-icon-btn:hover { border-color: var(--border-2); color: var(--text); background: var(--surface-solid); } .zmw-icon-btn:active { transform: scale(.96); } .zmw-icon-btn:focus-visible { outline: none; box-shadow: var(--ring); } .zmw-icon-btn .zmw-i { width: 19px; height: 19px; } .zmw-spin .zmw-i { animation: zmw-spin .7s cubic-bezier(.4,0,.2,1); } @keyframes zmw-spin { to { transform: rotate(360deg); } } .zmw-shell { display: flex; min-height: 100vh; opacity: 0; transition: opacity .35s; } body.zmw-ready .zmw-shell { opacity: 1; } body.zmw-locked .zmw-shell { filter: blur(6px); pointer-events: none; } .zmw-side { position: sticky; top: 0; height: 100vh; width: var(--side-w); flex-shrink: 0; display: flex; flex-direction: column; padding: 20px 14px 16px; background: var(--side-bg); border-right: 1px solid var(--border); backdrop-filter: blur(18px) saturate(140%); -webkit-backdrop-filter: blur(18px) saturate(140%); z-index: 40; overflow-y: auto; } .zmw-brand { display: flex; align-items: center; gap: 12px; padding: 2px 8px 14px; } .zmw-brand-text { min-width: 0; flex: 1; } .zmw-brand-name { font-weight: 750; font-size: 16px; letter-spacing: -.01em; } .zmw-brand-sub { font-size: 12px; color: var(--muted); margin-top: 1px; } .zmw-drawer-close { display: none; } .zmw-update { display: block; margin: 0 6px 12px; padding: 9px 12px; border-radius: 12px; font-size: 12.5px; font-weight: 600; text-decoration: none; color: var(--text); background: var(--grad-soft); border: 1px solid rgba(124,92,255,.35); } .zmw-update::before { content: "✦ "; color: var(--a1); } .zmw-update[hidden] { display: none; } .zmw-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; } .zmw-nav-group { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); opacity: .8; padding: 12px 12px 4px; } .zmw-nav-group:first-child { padding-top: 4px; } .zmw-nav-item { position: relative; display: flex; align-items: center; gap: 12px; padding: 5px 12px; border-radius: 12px; text-decoration: none; color: var(--text-2); font-weight: 550; font-size: 14px; transition: background .15s, color .15s; } .zmw-nav-item:hover { background: var(--surface-2); color: var(--text); } .zmw-nav-ico { width: 28px; height: 28px; border-radius: 10px; display: grid; place-items: center; flex-shrink: 0; background: var(--surface-2); border: 1px solid var(--border); color: var(--muted); transition: background .2s, color .2s, border-color .2s, box-shadow .2s; } .zmw-nav-ico .zmw-i { width: 18px; height: 18px; } .zmw-nav-label { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .zmw-nav-item.zmw-active { background: var(--grad-soft); color: var(--text); } .zmw-nav-item.zmw-active::before { content: ""; position: absolute; left: -9px; top: 10px; bottom: 10px; width: 4px; border-radius: 4px; background: var(--grad); } .zmw-nav-item.zmw-active .zmw-nav-ico { background: var(--grad); color: #fff; border-color: transparent; box-shadow: 0 6px 16px -6px rgba(99,102,241,.8); } .zmw-nav-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; display: none; } .zmw-nav-dot.zmw-on, .zmw-nav-dot.zmw-stop, .zmw-nav-dot.zmw-part { display: block; } .zmw-nav-dot.zmw-part { background: var(--warn-dot); box-shadow: 0 0 0 3px var(--warn-bg), 0 0 10px var(--warn-dot); } .zmw-nav-dot.zmw-on { background: var(--ok-dot); box-shadow: 0 0 0 3px var(--ok-bg), 0 0 10px var(--ok-dot); } .zmw-nav-dot.zmw-stop { background: var(--bad-dot); box-shadow: 0 0 0 3px var(--bad-bg), 0 0 10px var(--bad-dot); animation: zmw-blink 1.6s ease-in-out infinite; } @keyframes zmw-blink { 50% { opacity: .45; } } .zmw-side-foot { display: flex; flex-direction: column; gap: 9px; padding: 14px 6px 0; margin-top: 12px; border-top: 1px solid var(--border); } .zmw-pill { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 12px; text-decoration: none; font-weight: 650; font-size: 13px; border: 1px solid var(--border); background: var(--surface-2); } .zmw-pill-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; background: var(--off); } .zmw-pill-ok { color: var(--ok); background: var(--ok-bg); border-color: transparent; } .zmw-pill-ok .zmw-pill-dot { background: var(--ok-dot); animation: zmw-pulse 2.2s infinite; } .zmw-pill-bad { color: var(--bad); background: var(--bad-bg); border-color: transparent; } .zmw-pill-bad .zmw-pill-dot { background: var(--bad-dot); } .zmw-pill-off { color: var(--muted); } @keyframes zmw-pulse { 0% { box-shadow: 0 0 0 0 rgba(16,185,129,.55); } 70% { box-shadow: 0 0 0 8px rgba(16,185,129,0); } 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); } } .zmw-device { padding: 2px 6px 9px; border-bottom: 1px dashed var(--border); } .zmw-device-sub:empty { display: none; } .zmw-device-model { font-size: 12.5px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 650; color: var(--text); overflow-wrap: anywhere; } .zmw-device-sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .zmw-side-links { display: flex; gap: 6px; } .zmw-side-links a { flex: 1; padding: 8px 10px !important; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 8px; border-radius: 10px; border: 1px solid var(--border); font-size: 12.5px; font-weight: 600; text-decoration: none; color: var(--text-2); transition: background .15s, color .15s; } .zmw-side-links a:hover { background: var(--surface-2); color: var(--text); } .zmw-side-links .zmw-i { width: 15px; height: 15px; } .zmw-mem { display: flex; flex-direction: column; gap: 9px; padding: 2px 6px; } .zmw-mem:empty { display: none; } .zmw-mem-head { display: flex; justify-content: space-between; gap: 8px; font-size: 11.5px; margin-bottom: 4px; } .zmw-mem-label { color: var(--muted); font-weight: 600; } .zmw-mem-val { color: var(--text-2); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; } .zmw-mem-bar { height: 6px; border-radius: 6px; background: var(--surface-3); overflow: hidden; } .zmw-mem-bar i { display: block; height: 100%; width: 0; border-radius: 6px; background: var(--grad); transition: width .6s cubic-bezier(.2,.8,.2,1); } .zmw-mem-mid .zmw-mem-bar i { background: linear-gradient(90deg, #f59e0b, #fbbf24); } .zmw-mem-hi .zmw-mem-bar i { background: linear-gradient(90deg, #ef4444, #f87171); } #zmw-view .zm-meter .zmw-mem-head { font-size: 13px; margin-bottom: 6px; align-items: baseline; } #zmw-view .zm-meter .zmw-mem-label { font-weight: 500; } .zmw-credit { font-size: 11.5px; color: var(--muted); text-align: center; opacity: .7; } .zmw-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } .zmw-top { position: sticky; top: 0; z-index: 30; display: flex; align-items: center; gap: 14px; padding: 18px 32px; background: linear-gradient(to bottom, var(--bg) 30%, transparent); } html[data-theme="dark"] .zmw-top { background: linear-gradient(to bottom, rgba(7,9,15,.92) 35%, rgba(7,9,15,0)); } html[data-theme="light"] .zmw-top { background: linear-gradient(to bottom, rgba(243,245,250,.94) 35%, rgba(243,245,250,0)); } .zmw-burger { display: none; } .zmw-titles { flex: 1; min-width: 0; } .zmw-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.2; } .zmw-sub { font-size: 13px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .zmw-top-actions { display: flex; gap: 8px; align-items: center; } .zmw-logout span { padding-right: 2px; } .zmw-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .zmw-link-btn { text-decoration: none; padding: 0 13px; } .zmw-link-btn .zmw-i { width: 18px; height: 18px; } .zmw-lbl-short { display: none; } .zmw-link-kvn { color: #fff; border-color: #3C3489; background: #3C3489; box-shadow: 0 6px 16px -10px rgba(60,52,137,.9); transition: background .2s, transform .1s, box-shadow .2s; } .zmw-link-kvn:hover { color: #fff; border-color: #463d9c; background: #463d9c; box-shadow: 0 8px 20px -10px rgba(60,52,137,1); } .zmw-link-kvn .zmw-lbl-full { text-align: left; font-weight: 500; line-height: 1.25; } .zmw-link-kvn .zmw-kvn-l1 { display: block; font-size: 17px; font-weight: 500; white-space: nowrap; padding-left: 6px; } .zmw-link-kvn .zmw-kvn-l2 { display: block; font-size: 11px; font-weight: 400; opacity: .85; white-space: nowrap; } @media (min-width: 1361px) { .zmw-link-kvn { height: auto; min-height: 40px; padding: 6px 14px; gap: 10px; } .zmw-link-kvn .zmw-i { width: 24px; height: 24px; } } .zmw-link-tg { color: #229ed9; border-color: rgba(34,158,217,.35); background: rgba(34,158,217,.10); } .zmw-link-tg:hover { color: #229ed9; border-color: rgba(34,158,217,.6); background: rgba(34,158,217,.16); } html[data-theme="dark"] .zmw-link-tg, html[data-theme="dark"] .zmw-link-tg:hover { color: #5cc1f0; } .zmw-top-sep { width: 1px; height: 24px; background: var(--border-2); margin: 0 4px; } #zmw-view .zm-header-links { display: none; } .zmw-view { padding: 6px 32px 24px; flex: 1; width: 100%; max-width: 1320px; } .zmw-foot { padding: 8px 32px 24px; font-size: 12px; color: var(--muted); opacity: .6; } .zmw-page { animation: zmw-page-in .42s cubic-bezier(.2,.8,.2,1) both; } @keyframes zmw-page-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } .zmw-scrim { display: none; } .zmw-skel-wrap { display: flex; flex-direction: column; gap: 16px; } .zmw-skel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; } .zmw-skel { border-radius: var(--radius); background: linear-gradient(90deg, var(--surface-2) 0%, var(--surface-3) 40%, var(--surface-2) 80%); background-size: 300% 100%; animation: zmw-shimmer 1.4s infinite linear; border: 1px solid var(--border); } .zmw-skel-bar { height: 64px; } .zmw-skel-card { height: 170px; } .zmw-skel-wide { height: 240px; } @keyframes zmw-shimmer { from { background-position: 100% 0; } to { background-position: -200% 0; } } .zmw-error { display: flex; gap: 16px; align-items: flex-start; padding: 22px 24px; border-radius: var(--radius); background: var(--surface); border: 1px solid rgba(239,68,68,.3); box-shadow: var(--shadow); } .zmw-error-ico { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; background: var(--bad-bg); color: var(--bad); flex-shrink: 0; } .zmw-error h3 { margin: 0 0 6px; font-size: 16px; } .zmw-error p { margin: 0 0 14px; color: var(--muted); overflow-wrap: anywhere; } .zmw-login { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; padding: 20px; background: var(--bg); overflow: auto; animation: zmw-fade .4s ease both; } .zmw-login.zmw-leave { animation: zmw-fade-out .38s ease both; } @keyframes zmw-fade { from { opacity: 0; } to { opacity: 1; } } @keyframes zmw-fade-out { to { opacity: 0; transform: scale(1.02); } } .zmw-orbs { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } .zmw-orbs i { position: absolute; border-radius: 50%; filter: blur(70px); opacity: .55; } .zmw-orbs i:nth-child(1) { width: 520px; height: 520px; background: #7c5cff; left: -140px; top: -160px; animation: zmw-float1 18s ease-in-out infinite alternate; } .zmw-orbs i:nth-child(2) { width: 460px; height: 460px; background: #22d3ee; right: -140px; bottom: -160px; opacity: .38; animation: zmw-float2 22s ease-in-out infinite alternate; } .zmw-orbs i:nth-child(3) { width: 320px; height: 320px; background: #3b82f6; right: 18%; top: 12%; opacity: .25; animation: zmw-float1 26s ease-in-out infinite alternate-reverse; } html[data-theme="light"] .zmw-orbs i { opacity: .28; } @keyframes zmw-float1 { to { transform: translate(120px, 80px) scale(1.1); } } @keyframes zmw-float2 { to { transform: translate(-100px, -60px) scale(1.15); } } .zmw-grid-bg { position: absolute; inset: 0; pointer-events: none; background-image: linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px); background-size: 44px 44px; -webkit-mask-image: radial-gradient(ellipse at center, #000 20%, transparent 70%); mask-image: radial-gradient(ellipse at center, #000 20%, transparent 70%); opacity: .7; } .zmw-login-card { position: relative; z-index: 1; width: 100%; max-width: 400px; padding: 36px 32px 28px; border-radius: 26px; background: var(--surface); border: 1px solid var(--border-2); box-shadow: var(--shadow-lg); backdrop-filter: blur(24px) saturate(150%); -webkit-backdrop-filter: blur(24px) saturate(150%); text-align: center; animation: zmw-card-in .6s cubic-bezier(.2,.8,.2,1) both; } @keyframes zmw-card-in { from { opacity: 0; transform: translateY(18px) scale(.98); } to { opacity: 1; transform: none; } } .zmw-login-card h1 { margin: 0; font-size: 26px; font-weight: 800; letter-spacing: -.02em; background: linear-gradient(135deg, var(--text) 30%, var(--a1) 75%, var(--a3)); -webkit-background-clip: text; background-clip: text; color: transparent; } .zmw-login-sub { margin: 6px 0 24px; color: var(--muted); font-size: 13.5px; } .zmw-login-sub b { color: var(--text-2); font-weight: 650; } .zmw-shake { animation: zmw-shake .45s cubic-bezier(.36,.07,.19,.97) both; } @keyframes zmw-shake { 10%,90% { transform: translateX(-1px); } 20%,80% { transform: translateX(3px); } 30%,50%,70% { transform: translateX(-6px); } 40%,60% { transform: translateX(6px); } } .zmw-field { display: block; text-align: left; margin-bottom: 14px; } .zmw-field-label { display: block; font-size: 12.5px; font-weight: 650; color: var(--text-2); margin: 0 0 6px 2px; } .zmw-input { display: flex; align-items: center; gap: 10px; height: 48px; padding: 0 6px 0 14px; border-radius: 14px; border: 1px solid var(--border-2); background: var(--input-bg); color: var(--muted); transition: border-color .15s, box-shadow .15s; } .zmw-input:focus-within { border-color: var(--a1); box-shadow: var(--ring); color: var(--a1); } .zmw-input .zmw-i { width: 18px; height: 18px; } .zmw-input input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: var(--text); font: 500 15px/1 var(--font); } .zmw-input input::placeholder { color: var(--muted); opacity: .6; } .zmw-eye { border: 0; background: transparent; color: var(--muted); width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; cursor: pointer; } .zmw-eye:hover { background: var(--surface-2); color: var(--text); } .zmw-check { display: flex; align-items: center; gap: 10px; margin: 4px 2px 16px; font-size: 13px; color: var(--text-2); cursor: pointer; text-align: left; user-select: none; } .zmw-check input { position: absolute; opacity: 0; pointer-events: none; } .zmw-check-box { width: 20px; height: 20px; border-radius: 7px; flex-shrink: 0; border: 1.5px solid var(--border-2); background: var(--input-bg); display: grid; place-items: center; transition: background .15s, border-color .15s; } .zmw-check input:checked + .zmw-check-box { background: var(--grad); border-color: transparent; } .zmw-check input:checked + .zmw-check-box::after { content: ""; width: 10px; height: 5px; border: 2px solid #fff; border-top: 0; border-right: 0; transform: rotate(-45deg) translate(1px, -1px); } .zmw-check input:focus-visible + .zmw-check-box { box-shadow: var(--ring); } .zmw-login-msg { display: none; text-align: left; font-size: 13px; font-weight: 550; padding: 10px 12px; border-radius: 12px; margin-bottom: 14px; } .zmw-login-msg.zmw-show { display: block; } .zmw-login-msg.zmw-error { background: var(--bad-bg); color: var(--bad); } .zmw-login-msg.zmw-warning { background: var(--warn-bg); color: var(--warn); } .zmw-login-msg.zmw-info { background: var(--grad-soft); color: var(--text-2); } .zmw-btn-primary { position: relative; width: 100%; height: 50px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 14px; cursor: pointer; background: var(--grad); background-size: 160% 100%; background-position: 0 0; color: #fff; font: 700 15px/1 var(--font); letter-spacing: .01em; box-shadow: 0 12px 28px -10px rgba(99,102,241,.8), inset 0 1px 0 rgba(255,255,255,.25); transition: background-position .35s, transform .1s, box-shadow .2s; } .zmw-btn-primary:hover { background-position: 100% 0; box-shadow: 0 16px 32px -10px rgba(99,102,241,.9), inset 0 1px 0 rgba(255,255,255,.25); } .zmw-btn-primary:active { transform: scale(.985); } .zmw-btn-primary:focus-visible { outline: none; box-shadow: var(--ring), 0 12px 28px -10px rgba(99,102,241,.8); } .zmw-btn-primary .zmw-i { width: 18px; height: 18px; transition: transform .2s; } .zmw-btn-primary:hover .zmw-i { transform: translateX(3px); } .zmw-btn-primary.zmw-loading span, .zmw-btn-primary.zmw-loading .zmw-i { opacity: 0; } .zmw-btn-primary.zmw-loading::after { content: ""; position: absolute; width: 20px; height: 20px; border-radius: 50%; border: 2.5px solid rgba(255,255,255,.35); border-top-color: #fff; animation: zmw-spin .7s linear infinite; } .zmw-login-foot { margin-top: 18px; font-size: 12.5px; color: var(--muted); } .zmw-login-theme { position: absolute; top: 18px; right: 18px; z-index: 2; } .zmw-modal-wrap { position: fixed; inset: 0; z-index: 300; display: grid; place-items: center; padding: 20px; background: rgba(5,7,12,.55); backdrop-filter: blur(6px); opacity: 0; transition: opacity .2s; } .zmw-modal-wrap.zmw-in { opacity: 1; } .zmw-modal { width: 100%; max-width: 560px; max-height: 85vh; overflow: auto; padding: 24px; border-radius: 20px; background: var(--surface-solid); border: 1px solid var(--border-2); box-shadow: var(--shadow-lg); } .zmw-modal h3 { margin: 0 0 12px; } #zmw-view .zm-wrap { max-width: none; gap: 18px; } #zmw-view .zm-webui-link { display: none !important; } #zmw-view .zm-header { align-items: center; gap: 12px; margin: 0; padding: 20px 24px; border-radius: var(--radius); background: var(--grad-soft); border: 1px solid rgba(124,92,255,.22); position: relative; overflow: hidden; } #zmw-view .zm-header::after { content: ""; position: absolute; right: -60px; top: -80px; width: 240px; height: 240px; border-radius: 50%; background: var(--grad); filter: blur(60px); opacity: .22; pointer-events: none; } #zmw-view .zm-header h2 { font-size: 22px; font-weight: 800; letter-spacing: -.02em; background: linear-gradient(135deg, var(--text) 35%, var(--a1) 80%, var(--a3)); -webkit-background-clip: text; background-clip: text; color: transparent; } #zmw-view .zm-header-by { opacity: 1; color: var(--muted); font-size: 13px; } #zmw-view .zm-header-links { position: relative; z-index: 1; } #zmw-view .zm-header-links a { color: var(--text); background: var(--surface); border: 1px solid var(--border-2); padding: 7px 14px; font-size: 12.5px; font-weight: 650; border-radius: 999px; backdrop-filter: blur(8px); transition: border-color .15s, transform .15s, background .15s; } #zmw-view .zm-header-links a:hover { border-color: var(--a1); transform: translateY(-1px); background: var(--surface-solid); } #zmw-view :not(.zm-cards) > .zm-card + .zm-current-banner, #zmw-view .zm-current-banner + .zm-card, #zmw-view :not(.zm-cards) > .zm-card + .zm-log, #zmw-view .zm-card + .zm-refresh-banner, #zmw-view .zm-card + .zm-cards, #zmw-view .zm-cards + .zm-card { margin-top: 18px; } #zmw-view .zm-wrap > * + * { margin-top: 0 !important; } #zmw-view :not(.zm-cards):not(.bt-cols):not(.zm-wrap) > .zm-card:not(:last-child) { margin-bottom: 18px; } #zmw-view .zm-current-banner { margin-bottom: 0; } #zmw-view .zm-cards { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; } #zmw-view .zm-cards.zm-cards-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @media (max-width: 720px) { #zmw-view .zm-cards.zm-cards-2 { grid-template-columns: minmax(0, 1fr); } } #zmw-view .zm-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px 24px; box-shadow: var(--shadow); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); color: var(--text); transition: border-color .2s, box-shadow .2s; } #zmw-view .zm-card:hover { border-color: var(--border-2); box-shadow: var(--shadow); } #zmw-view .zm-card.zm-list-card { padding: 0; overflow: hidden; } #zmw-view .zm-card.zm-list-card:not(:last-child) { margin-bottom: 14px; } #zmw-view .zm-list-head { padding: 16px 24px; } #zmw-view .zm-list-body { padding: 2px 24px 20px; border-top-color: var(--border); } #zmw-view .zm-list-actions { margin: 14px 0 0; } @media (max-width: 600px) { #zmw-view .zm-list-head { padding: 14px 16px; } #zmw-view .zm-list-body { padding: 2px 16px 16px; } } #zmw-view .zm-card h3 { margin: 0 0 14px; font-size: 16px; font-weight: 700; letter-spacing: -.01em; color: var(--text); } #zmw-view .zm-card h3::before { content: ""; width: 4px; height: 18px; border-radius: 4px; background: var(--grad); flex-shrink: 0; } #zmw-view .zm-card h4 { color: var(--text); } #zmw-view .zm-card p { color: var(--text-2); } #zmw-view .zm-row { font-size: 13.5px; margin: 9px 0; gap: 12px; } #zmw-view .zm-row .zm-label { opacity: 1; color: var(--muted); } #zmw-view .bt-cols { column-gap: 48px; } #zmw-view .bt-col .zm-row { padding: 6px 0; margin: 0; border-bottom: 1px dashed var(--border); } #zmw-view .bt-col .zm-row:last-child { border-bottom: 0; } #zmw-view .zm-hint, #zmw-view p.zm-hint, .zm-dock .zm-hint, .zm-dock p.zm-hint { opacity: 1; color: var(--muted); font-size: 12.5px; line-height: 1.6; } #zmw-view a:not(.cbi-button), .zm-dock a:not(.cbi-button) { color: var(--a2); text-decoration-color: rgba(59,130,246,.35); text-underline-offset: 3px; } html[data-theme="dark"] #zmw-view a:not(.cbi-button), html[data-theme="dark"] .zm-dock a:not(.cbi-button) { color: #8ab4ff; } #zmw-view .zm-badge, .zm-dock .zm-badge { padding: 4px 11px 4px 9px; font-size: 12px; font-weight: 650; gap: 7px; letter-spacing: .01em; } #zmw-view .zm-ok { background: var(--ok-bg); color: var(--ok); } #zmw-view .zm-ok .zm-dot { background: var(--ok-dot); box-shadow: 0 0 8px var(--ok-dot); animation: zmw-pulse 2.4s infinite; } #zmw-view .zm-bad { background: var(--bad-bg); color: var(--bad); } #zmw-view .zm-bad .zm-dot { background: var(--bad-dot); } #zmw-view .zm-warn { background: var(--warn-bg); color: var(--warn); } #zmw-view .zm-warn .zm-dot { background: var(--warn-dot); } #zmw-view .zm-off { background: var(--off-bg); color: var(--off); } #zmw-view .zm-off .zm-dot { background: var(--off); } #zmw-view .cbi-button, .zmw-modal .cbi-button, .zm-refresh-banner .cbi-button, .zm-dock .cbi-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 38px; height: auto; padding: 8px 16px; margin: 0; border-radius: 12px; border: 1px solid var(--border-2); background: var(--surface-solid); color: var(--text); font: 600 13.5px/1.25 var(--font); letter-spacing: .005em; text-decoration: none; white-space: normal; text-align: center; cursor: pointer; box-shadow: 0 1px 2px rgba(0,0,0,.06); transition: border-color .15s, background .15s, transform .1s, box-shadow .15s, color .15s; -webkit-appearance: none; appearance: none; } html[data-theme="dark"] #zmw-view .cbi-button, html[data-theme="dark"] .zm-dock .cbi-button { background: rgba(255,255,255,.05); } #zmw-view .cbi-button:hover, .zm-dock .cbi-button:hover { border-color: rgba(124,92,255,.55); background: var(--surface-solid); transform: translateY(-1px); box-shadow: 0 6px 16px -10px rgba(99,102,241,.6); } html[data-theme="dark"] #zmw-view .cbi-button:hover, html[data-theme="dark"] .zm-dock .cbi-button:hover { background: rgba(255,255,255,.08); } #zmw-view .cbi-button:active, .zm-dock .cbi-button:active { transform: translateY(0) scale(.98); } #zmw-view .cbi-button:focus-visible, .zm-dock .cbi-button:focus-visible { outline: none; box-shadow: var(--ring); } #zmw-view .cbi-button[disabled], #zmw-view .cbi-button:disabled, .zm-dock .cbi-button[disabled], .zm-dock .cbi-button:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; } #zmw-view .cbi-button-positive, .zm-refresh-banner .cbi-button-positive, html[data-theme="dark"] #zmw-view .cbi-button-positive, .zm-dock .cbi-button-positive, html[data-theme="dark"] .zm-dock .cbi-button-positive { background: var(--grad); background-size: 150% 100%; color: #fff; border-color: transparent; box-shadow: 0 8px 20px -10px rgba(99,102,241,.9), inset 0 1px 0 rgba(255,255,255,.22); } #zmw-view .cbi-button-positive:hover, html[data-theme="dark"] #zmw-view .cbi-button-positive:hover, .zm-dock .cbi-button-positive:hover, html[data-theme="dark"] .zm-dock .cbi-button-positive:hover { background: var(--grad); background-size: 150% 100%; background-position: 100% 0; border-color: transparent; color: #fff; box-shadow: 0 12px 26px -10px rgba(99,102,241,1), inset 0 1px 0 rgba(255,255,255,.22); } #zmw-view .cbi-button-remove, html[data-theme="dark"] #zmw-view .cbi-button-remove, .zm-dock .cbi-button-remove, html[data-theme="dark"] .zm-dock .cbi-button-remove { background: var(--bad-bg); color: var(--bad); border-color: rgba(239,68,68,.28); box-shadow: none; } #zmw-view .cbi-button-remove:hover, html[data-theme="dark"] #zmw-view .cbi-button-remove:hover, .zm-dock .cbi-button-remove:hover, html[data-theme="dark"] .zm-dock .cbi-button-remove:hover { background: rgba(239,68,68,.18); border-color: rgba(239,68,68,.5); color: var(--bad); box-shadow: 0 8px 20px -12px rgba(239,68,68,.8); } #zmw-view .zm-actions { gap: 10px; margin: 16px 0; } #zmw-view .zm-actions.zmw-tabs { display: flex; flex-wrap: nowrap; gap: 4px; padding: 5px; margin: 0 0 4px !important; border-radius: 16px; background: var(--surface); border: 1px solid var(--border); box-shadow: var(--shadow); overflow-x: auto; scrollbar-width: none; backdrop-filter: blur(14px); width: max-content; max-width: 100%; } #zmw-view .zm-actions.zmw-tabs::-webkit-scrollbar { display: none; } #zmw-view .zmw-tabs .cbi-button, .zm-dock .zmw-tabs .cbi-button { flex-shrink: 0; white-space: nowrap; border: 0; background: transparent; box-shadow: none; color: var(--muted); min-height: 36px; padding: 7px 16px; border-radius: 11px; } #zmw-view .zmw-tabs .cbi-button:hover, .zm-dock .zmw-tabs .cbi-button:hover { background: var(--surface-2); color: var(--text); transform: none; box-shadow: none; } #zmw-view .zmw-tabs .cbi-button-positive, #zmw-view .zmw-tabs .cbi-button-positive:hover, .zm-dock .zmw-tabs .cbi-button-positive, .zm-dock .zmw-tabs .cbi-button-positive:hover { background: var(--grad); color: #fff; box-shadow: 0 6px 16px -8px rgba(99,102,241,.9); } #zmw-view .cbi-input-text, #zmw-view input[type="text"], #zmw-view input[type="number"], #zmw-view input[type="password"], #zmw-view input[type="url"], #zmw-view select, #zmw-view .bt-input, .zmw-modal input[type="text"], .zmw-modal select { height: 40px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border-2); background: var(--input-bg); color: var(--text); font: 500 14px/1 var(--font); outline: none; box-shadow: none; transition: border-color .15s, box-shadow .15s; max-width: 100%; } #zmw-view select, .zmw-modal select { -webkit-appearance: none; appearance: none; cursor: pointer; padding-right: 38px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a93a8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 16px; } #zmw-view select option { background: var(--surface-solid); color: var(--text); } #zmw-view .zm-hour-select { min-width: 120px; font-variant-numeric: tabular-nums; } #zmw-view .zm-actions > .cbi-input-text[type="text"] { flex: 1 1 240px; min-width: 0; } #zmw-view .cbi-input-text:focus, #zmw-view input:focus, #zmw-view select:focus, #zmw-view .bt-input:focus { border-color: var(--a1); box-shadow: var(--ring); } #zmw-view input::placeholder { color: var(--muted); opacity: .7; } #zmw-view .zm-grid { gap: 10px; } #zmw-view .zm-cat { border-color: var(--border-2, var(--border)); background: var(--surface-2); } #zmw-view .zm-cat.zm-list-open { background: transparent; } #zmw-view .zm-cat-body { border-top-color: var(--border); } #zmw-view .zm-linkbtn { color: var(--a1); } #zmw-view .zm-linkbtn:hover:not([disabled]) { background: var(--surface-2); } #zmw-view .zm-cat-src { border-color: var(--border-2, var(--border)); } #zmw-view .zm-cat-src-title { color: var(--text); } #zmw-view .zm-grid-devices { gap: 10px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); } #zmw-view .zm-tile { background: var(--surface-2); border: 1px solid var(--border); border-radius: 13px; padding: 11px 16px; font-size: 13.5px; font-weight: 550; color: var(--text-2); transition: border-color .15s, background .15s, transform .12s, box-shadow .15s, color .15s; } html.zm-theme-dark #zmw-view .zm-tile:not(.zm-active):not(.zm-tile-off) { background: var(--surface-2); border-color: var(--border); } #zmw-view .zm-tile:hover { border-color: rgba(124,92,255,.55); color: var(--text); transform: translateY(-2px); box-shadow: 0 10px 20px -14px rgba(99,102,241,.7); } html.zm-theme-dark #zmw-view .zm-tile:not(.zm-active):not(.zm-tile-off):hover { border-color: rgba(124,92,255,.55); } #zmw-view .zm-tile.zm-active { background: var(--grad-soft); border-color: rgba(124,92,255,.65); color: var(--text); font-weight: 700; box-shadow: 0 0 0 1px rgba(124,92,255,.45) inset, 0 10px 24px -14px rgba(99,102,241,.9); } #zmw-view .zm-tile.zm-active::before { content: none; } html[data-theme="dark"] #zmw-view .zm-tile.zm-active::before { color: #a594ff; } #zmw-view .zm-tile.zm-tile-off { background: var(--bad-bg); border-color: rgba(239,68,68,.35); color: var(--bad); } #zmw-view .zm-tile.zm-tile-pending { opacity: .5; } #zmw-view .zm-log { background: var(--console); color: #dfe6f3; border: 1px solid var(--console-border); border-radius: 16px; padding: 0 20px 18px; font: 13px/1.75 var(--mono); box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow); position: relative; background-image: none; overflow-x: hidden; overflow-y: auto; scrollbar-width: auto; scrollbar-color: auto; } #zmw-view .zm-log::before { content: ''; display: block; position: sticky; top: 0; z-index: 2; height: 40px; margin: 0 -20px 6px; background: radial-gradient(circle at 20px 20px, #ff5f57 5px, transparent 5.5px), radial-gradient(circle at 38px 20px, #febc2e 5px, transparent 5.5px), radial-gradient(circle at 56px 20px, #28c840 5px, transparent 5.5px), var(--console); background-repeat: no-repeat; opacity: 1; } #zmw-view .zm-log:empty::before { content: ''; opacity: 1; } #zmw-view .zm-log:empty::after { content: "Ожидание вывода..."; color: #8b949e; } #zmw-view .zm-log.bt-panel { padding-top: 16px; } #zmw-view .zm-log.bt-panel::before { display: none; } #zmw-view .zm-log::-webkit-scrollbar { width: 14px; height: 14px; } #zmw-view .zm-log::-webkit-scrollbar-track { background: transparent; margin: 44px 0 12px; } #zmw-view .zm-log.bt-panel::-webkit-scrollbar-track { margin: 12px 0; } #zmw-view .zm-log::-webkit-scrollbar-thumb { background: rgba(255,255,255,.18); border-radius: 10px; border: 4px solid transparent; background-clip: padding-box; min-height: 40px; } #zmw-view .zm-log::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.32); background-clip: padding-box; } #zmw-view .zm-log::-webkit-scrollbar-corner { background: transparent; } @supports not selector(::-webkit-scrollbar) { #zmw-view .zm-log { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.25) transparent; } } #zmw-view .zm-log-arrow { color: #67e8f9; } #zmw-view .zm-log-msg-info { color: #fcd34d; } #zmw-view .zm-log-msg-ok { color: #4ade80; } #zmw-view .zm-log-msg-error { color: #fb7185; } #zmw-view .zm-log-msg-warn { color: #fdba74; } #zmw-view .zm-log-code { color: #94a3b8; } #zmw-view .zm-config-editor { background: var(--console); color: #e2e8f0; border: 1px solid var(--console-border); border-radius: 16px; padding: 16px 18px; font: 13px/1.65 var(--mono); outline: none; transition: border-color .15s, box-shadow .15s; } #zmw-view .zm-config-editor:focus { border-color: var(--a1); box-shadow: var(--ring); } #zmw-view .zm-refresh-banner, .zm-refresh-banner { background: var(--warn-bg); border: 1px solid rgba(245,158,11,.35); color: var(--warn); border-radius: var(--radius); padding: 14px 18px; font-size: 14px; font-weight: 600; margin-top: 0; } #zmw-view .zm-current-banner { background: var(--grad-soft); border: 1px solid rgba(124,92,255,.28); border-radius: 14px; padding: 12px 16px; font-size: 13.5px; color: var(--text); } #zmw-view .zm-current-banner.zm-current-empty { background: var(--surface-2); border-color: var(--border); color: var(--muted); } #zmw-view .zm-current-banner.zm-current-top { margin: 14px 0 0 !important; } #zmw-view .zm-current-banner.zm-current-top + .zm-card { margin-top: 18px !important; } #zmw-view .bt-current-cmd { background: var(--console); color: #86efac; border-radius: 12px; font-family: var(--mono); } #zmw-view .zm-tg-link-card { background: var(--ok-bg); border: 1px solid rgba(16,185,129,.28); border-radius: 16px; } #zmw-view .zm-tg-link-box { background: var(--console); color: #86efac; border-radius: 12px; font-family: var(--mono); font-size: 13.5px; } #zmw-view .zm-tg-qr-box img { border-radius: 14px; box-shadow: var(--shadow); } #zmw-view .zm-ab-panel { gap: 18px; } #zmw-view .zm-ab-panel > .zm-card { margin: 0 !important; } #zmw-view .zm-ab-hero { background: var(--surface); position: relative; overflow: hidden; } #zmw-view .zm-ab-hero::after { content: ""; position: absolute; right: -80px; top: -80px; width: 240px; height: 240px; border-radius: 50%; background: radial-gradient(circle, rgba(26,163,255,.16), transparent 70%); pointer-events: none; } #zmw-view .zm-ab-hero h3::before, #zmw-view .zm-ab-title h3::before { display: none; } #zmw-view .zm-ab-foot { border-top-color: var(--border); color: var(--muted); opacity: 1; } #zmw-view .zm-ab-sum b { color: var(--text); } #zmw-view .zm-ab-sum-ok b { color: var(--ok); } #zmw-view .zm-ab-note-warn { background: var(--warn-bg); color: var(--warn); border-color: rgba(245,158,11,.35); } #zmw-view .zm-ab-step { color: var(--muted); opacity: 1; } #zmw-view .zm-ab-step::before { background: var(--border-2); } #zmw-view .zm-ab-step-dot { background: var(--surface-2); border: 1px solid var(--border); } #zmw-view .zm-ab-step.zm-ab-done { color: var(--text-2); } #zmw-view .zm-ab-step.zm-ab-done .zm-ab-step-dot { background: var(--ok-dot); border-color: transparent; color: #fff; } #zmw-view .zm-ab-step.zm-ab-done::before, #zmw-view .zm-ab-step.zm-ab-now::before { background: var(--grad); } #zmw-view .zm-ab-step.zm-ab-now { color: var(--text); } #zmw-view .zm-ab-step.zm-ab-now .zm-ab-step-dot { background: var(--grad); border-color: transparent; color: #fff; } #zmw-view .zm-ab-svc-item, html.zm-theme-dark #zmw-view .zm-ab-svc-item { background: var(--surface-2); border-color: var(--border); border-radius: 13px; } #zmw-view .zm-ab-svc-item.zm-ab-svc-bad { border-color: rgba(239,68,68,.35); } #zmw-view .zm-awg-if, html.zm-theme-dark #zmw-view .zm-awg-if { background: var(--surface-2); border-color: var(--border); border-radius: 14px; } #zmw-view .zm-ab-svc-name { color: var(--text); } #zmw-view .zm-ab-player video { border-radius: 14px; box-shadow: var(--shadow); } #zmw-view .zm-ab-logcard .zm-log { margin-top: 0; } #zmw-view .zm-st-stat, html.zm-theme-dark #zmw-view .zm-st-stat { background: var(--surface-2); border-color: var(--border); border-radius: 14px; } #zmw-view .zm-st-stat-label { color: var(--muted); opacity: 1; } #zmw-view .zm-st-stat-value { color: var(--text); } #zmw-view .zm-st-ok { color: var(--ok); } #zmw-view .zm-st-warn { color: var(--warn); } #zmw-view .zm-st-bad { color: var(--bad); } #zmw-view .zm-st-off { color: var(--muted); opacity: 1; } #zmw-view .zm-st-promo > div { background: var(--grad-soft); border-color: rgba(124,92,255,.25); border-radius: 14px; } #zmw-view .zm-st-promo b { color: var(--text); } #zmw-view .zm-st-promo span { color: var(--muted); opacity: 1; } #zmw-view .zm-svc, html.zm-theme-dark #zmw-view .zm-svc { background: var(--surface-2); border-color: var(--border); border-radius: 14px; } #zmw-view .zm-svc:hover { border-color: rgba(124,92,255,.55); box-shadow: 0 10px 20px -14px rgba(99,102,241,.7); } #zmw-view .zm-svc.zm-svc-on, html.zm-theme-dark #zmw-view .zm-svc.zm-svc-on { background: var(--grad-soft); border-color: rgba(124,92,255,.6); } #zmw-view .zm-svc-name { color: var(--text); } #zmw-view .zm-svc-sub { color: var(--muted); opacity: 1; } #zmw-view .zm-switch { background: var(--border-2); } #zmw-view .zm-switch.zm-switch-on { background: var(--grad); } #zmw-view .zm-st-note-ok { background: var(--ok-bg); color: var(--ok); border-color: rgba(16,185,129,.3); } #zmw-view .zm-st-note-bad { background: var(--bad-bg); color: var(--bad); border-color: rgba(239,68,68,.3); } #zmw-view .zm-st-check { color: var(--text); } #zmw-view .zm-st-check-why { color: var(--muted); opacity: 1; } #zmw-view .zm-credits-grid { gap: 12px; } #zmw-view .zm-credit-tile, html.zm-theme-dark #zmw-view .zm-credit-tile { background: var(--surface-2); border: 1px solid var(--border); border-radius: 14px; padding: 14px; transition: border-color .15s, transform .15s; } #zmw-view .zm-credit-tile:hover { border-color: var(--border-2); transform: translateY(-1px); } #zmw-view .zm-credit-tile.zm-credit-self { background: var(--grad-soft); border-color: rgba(124,92,255,.35); } #zmw-view .zm-credit-author { color: var(--muted); opacity: 1; } #zmw-view .bt-table td { border-top-color: rgba(255,255,255,.08); } #zmw-view .bt-chip { border-radius: 7px; } #zmw-view iframe { border: 1px solid var(--border-2) !important; border-radius: 16px !important; background: #fff; box-shadow: var(--shadow); } #zmw-view pre:not(.zm-log) { font-family: var(--mono); } #zmw-view hr { border: 0; border-top: 1px solid var(--border); } .zmw-body #zm-toast-container { top: auto; bottom: 28px; right: 28px; left: auto; gap: 12px; max-width: 520px; width: calc(100% - 56px); align-items: flex-end; justify-content: flex-end; z-index: 10001; pointer-events: none; overflow: hidden; transition: bottom .18s ease; } .zmw-body #zm-toast-container > .zm-toast { flex-shrink: 0; } .zmw-body .zm-toast { --toast-bg: #161b26; --toast-fg: #f4f6fb; --toast-border: rgba(255,255,255,.08); pointer-events: auto; align-items: center; gap: 16px; width: 100%; padding: 20px 24px 20px 20px; border-radius: 18px; border: 1px solid var(--toast-border); border-left: 6px solid var(--ok); background: var(--toast-bg); color: var(--toast-fg); box-shadow: 0 18px 50px rgba(0,0,0,.35), 0 2px 8px rgba(0,0,0,.2); font-size: 16.5px; font-weight: 600; line-height: 1.45; opacity: 0; transform: translateY(16px) scale(.98); transition: opacity .25s ease, transform .3s cubic-bezier(.2,.8,.2,1); } html.zm-theme-dark .zmw-body .zm-toast, html.zm-theme-dark.zmw-body .zm-toast { --toast-bg: #ffffff; --toast-fg: #141821; --toast-border: rgba(0,0,0,.06); box-shadow: 0 18px 50px rgba(0,0,0,.55), 0 0 0 1px rgba(255,255,255,.08); } .zmw-body .zm-toast-error { border-left-color: var(--bad); } .zmw-body .zm-toast-warning { border-left-color: var(--warn); } .zmw-body .zm-toast.zm-toast-show { opacity: 1; transform: none; } .zmw-body .zm-toast-icon { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; align-self: center; box-shadow: 0 0 0 4px rgba(0,0,0,.06); } html body.zmw-body .zm-toast .zm-toast-icon { color: #fff; } html body.zmw-body .zm-toast { background: var(--toast-bg); color: var(--toast-fg); } .zmw-body .zm-toast-info .zm-toast-icon { background: #1f9d55; } .zmw-body .zm-toast-error .zm-toast-icon { background: #d93b3b; } .zmw-body .zm-toast-warning .zm-toast-icon { background: #d08a00; } @media (max-width: 1360px) { .zmw-lbl-full { display: none; } .zmw-lbl-short { display: inline; } } @media (max-width: 1180px) { .zmw-logout span { display: none; } } @media (max-width: 1080px) and (min-width: 961px), (max-width: 760px) { .zmw-lbl-short { display: none; } .zmw-link-btn { padding: 0 10px; } .zmw-sub { display: none; } } @media (max-width: 960px) { :root { --side-w: min(86vw, 300px); } .zmw-side { position: fixed; left: 0; top: 0; bottom: 0; height: auto; transform: translateX(-104%); transition: transform .32s cubic-bezier(.2,.8,.2,1); box-shadow: var(--shadow-lg); background: var(--surface-solid); } body.zmw-drawer-open .zmw-side { transform: none; } .zmw-drawer-close { display: inline-flex; } .zmw-scrim { display: block; position: fixed; inset: 0; z-index: 35; background: rgba(5,7,12,.5); backdrop-filter: blur(3px); opacity: 0; pointer-events: none; transition: opacity .3s; } body.zmw-drawer-open .zmw-scrim { opacity: 1; pointer-events: auto; } body.zmw-drawer-open { overflow: hidden; } .zmw-burger { display: inline-flex; } .zmw-top { padding: 12px 16px; gap: 10px; } .zmw-view { padding: 4px 16px 20px; } .zmw-foot { padding: 4px 16px 20px; } .zmw-title { font-size: 20px; } } @media (max-width: 600px) { .zmw-sub { display: none; } .zmw-top-actions { gap: 5px; } .zmw-top-sep { display: none; } .zmw-icon-btn { height: 36px; min-width: 36px; padding: 0 8px; border-radius: 11px; } .zmw-link-btn { padding: 0 8px; } .zmw-top { gap: 8px; } .zmw-title { font-size: 18px; } #zmw-view .zm-card { padding: 18px 16px; border-radius: 16px; } #zmw-view .zm-cards { grid-template-columns: 1fr; } #zmw-view .zm-header { padding: 16px; } #zmw-view .zm-header-links { margin-left: 0; } #zmw-view .zm-actions .cbi-button, .zm-dock .zm-actions .cbi-button { flex: 1 1 auto; } #zmw-view .zmw-tabs .cbi-button, .zm-dock .zmw-tabs .cbi-button { flex: 0 0 auto; } #zmw-view .zm-actions.zmw-tabs { width: 100%; } #zmw-view .zm-refresh-banner, .zm-refresh-banner { flex-direction: column; align-items: stretch; text-align: left; } .zmw-brand-name { font-size: 15px; white-space: nowrap; } .zmw-nav-item { padding: 7px 12px; } .zmw-login-card { padding: 30px 20px 22px; border-radius: 22px; } .zmw-body #zm-toast-container { left: 12px; right: 12px; bottom: 12px; width: auto; max-width: none; } #zmw-view .zm-log { font-size: 12.5px; padding: 0 14px 14px; } #zmw-view .zm-log::before { margin: 0 -14px 6px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } #zmw-view .zm-node { border-color: var(--border); background: var(--surface-2); border-radius: 14px; } html.zm-theme-dark #zmw-view .zm-node:not(.zm-active) { background: var(--surface-2); border-color: var(--border); } #zmw-view .zm-node:hover { border-color: rgba(124,92,255,.55); transform: translateY(-2px); box-shadow: 0 10px 20px -14px rgba(99,102,241,.7); } #zmw-view .zm-node.zm-active { border-color: rgba(124,92,255,.6); background: var(--grad-soft); box-shadow: var(--ring); } #zmw-view .zm-node.zm-active .zm-node-name::before { color: var(--a1); } #zmw-view .zm-node.zm-sec-add { border-style: dashed; } #zmw-view .zm-fk-sec:hover { border-color: var(--a1); } #zmw-view .zm-sec-add-text { color: var(--a1); } #zmw-view .zm-node-name { color: var(--text); } #zmw-view .zm-node-foot > span:first-child { color: var(--muted); opacity: 1; } #zmw-view .zm-lat-good { color: var(--ok); } #zmw-view .zm-lat-mid { color: var(--warn); } #zmw-view .zm-lat-bad { color: var(--bad); } #zmw-view .zm-sub-meta { background: var(--grad-soft); border: 1px solid rgba(124,92,255,.22); border-radius: 14px; } #zmw-view .zm-sub-fact > span { color: var(--muted); opacity: 1; } #zmw-view .zm-quota { background: var(--surface-3); } #zmw-view .zm-quota > i { background: var(--grad); } #zmw-view .zm-quota.zm-quota-high > i { background: var(--bad-dot); } #zmw-view .zm-seg { background: var(--surface-2); border-color: var(--border); border-radius: 12px; } #zmw-view .zm-seg-item { border-radius: 9px; color: var(--text-2); } #zmw-view .zm-seg-item.zm-active { background: var(--grad); color: #fff; box-shadow: 0 6px 16px -10px rgba(99,102,241,.9); } @media (max-width: 600px) { #zmw-view .zm-nodes { grid-template-columns: repeat(2, minmax(0, 1fr)); } #zmw-view .zm-sub-meta { flex-direction: column; align-items: flex-start; } } #zmw-view .zm-log-bar { top: 46px; } #zmw-view .zm-log-min { top: -38px; right: -10px; border-radius: 8px; border-color: rgba(255,255,255,.12); background: rgba(255,255,255,.05); color: #cbd5e1; } #zmw-view .zm-log-min:hover { background: rgba(124,92,255,.35); border-color: rgba(124,92,255,.6); color: #fff; } #zmw-view .zm-log.zm-log-collapsed { padding-bottom: 12px; } .zmw-logo, .zmw-logo-lg { background: none; box-shadow: none; border-radius: 0; } .zmw-logo svg { filter: drop-shadow(0 2px 3px rgba(0,120,220,.25)); } .zmw-logo-lg::after { opacity: .55; } html[data-theme="dark"] .zmw-logo svg { filter: drop-shadow(0 0 1px rgba(255,255,255,.55)) drop-shadow(0 0 10px rgba(0,182,255,.45)); } .zmw-theme-menu { position: fixed; z-index: 1000; min-width: 184px; padding: 6px; display: flex; flex-direction: column; gap: 2px; background: var(--surface-solid); border: 1px solid var(--border-2); border-radius: 14px; box-shadow: var(--shadow-lg); animation: zmw-menu-in .14s ease-out; } @keyframes zmw-menu-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } .zmw-theme-opt { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border: 0; border-radius: 10px; background: none; color: var(--text); font: inherit; font-size: 14px; cursor: pointer; text-align: left; } .zmw-theme-opt:hover { background: var(--surface-2); } .zmw-theme-opt.zmw-on { background: var(--grad-soft); font-weight: 650; } .zmw-theme-opt .zmw-i { width: 18px; height: 18px; flex-shrink: 0; } .zmw-theme-opt > span:nth-of-type(1) { flex: 1; } .zmw-theme-mark { color: var(--a1); width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .zmw-theme-opt.zmw-on .zmw-theme-mark { background: currentColor; } html[data-theme="ink"] { --a1: #0a9cff; --a2: #0a9cff; --a3: #00c0ff; --grad: linear-gradient(180deg, #00c0ff, #0a9cff); --grad-soft: #d6f0ff; --ring: 0 0 0 3px rgba(10,156,255,.35); --radius: 6px; --radius-sm: 4px; --bg: #ffffff; --bg-glow-1: transparent; --bg-glow-2: transparent; --surface: #ffffff; --surface-solid: #ffffff; --surface-2: #e8f6ff; --surface-3: #d6f0ff; --side-bg: #e8f6ff; --border: #0a0a0a; --border-2: #0a0a0a; --text: #0a0a0a; --text-2: #1f2a36; --muted: #3d4a57; --shadow: 5px 5px 0 #0a0a0a; --shadow-lg: 8px 8px 0 #0a0a0a; --input-bg: #ffffff; --console: #0a0a0a; --console-border: #0a0a0a; --ok: #0a0a0a; --ok-bg: #19c45a; --ok-dot: #0a0a0a; --bad: #0a0a0a; --bad-bg: #ff4d3d; --bad-dot: #0a0a0a; --warn: #0a0a0a; --warn-bg: #ffc21a; --warn-dot: #0a0a0a; --off: #0a0a0a; --off-bg: #eeeeee; color-scheme: light; } html[data-theme="ink"] ::selection { background: #00c0ff; color: #0a0a0a; } html[data-theme="ink"] body.zmw-body { background-image: none; } html[data-theme="ink"] .zmw-orbs, html[data-theme="ink"] .zmw-grid-bg { display: none; } html[data-theme="ink"] .zmw-top { background: linear-gradient(to bottom, rgba(255,255,255,.96) 35%, rgba(255,255,255,0)); } html[data-theme="ink"] .zmw-side { background: #e8f6ff; border-right: 2.5px solid #0a0a0a; backdrop-filter: none; -webkit-backdrop-filter: none; box-shadow: none; } html[data-theme="ink"] .zmw-nav-item { border: 2px solid transparent; border-radius: 6px; } html[data-theme="ink"] .zmw-nav-item:hover { background: #d6f0ff; border-color: #0a0a0a; } html[data-theme="ink"] .zmw-nav-item.zmw-active { background: #0a9cff; color: #0a0a0a; border-color: #0a0a0a; box-shadow: 3px 3px 0 #0a0a0a; font-weight: 700; } html[data-theme="ink"] .zmw-nav-item.zmw-active::before { display: none; } html[data-theme="ink"] .zmw-nav-item.zmw-active .zmw-nav-ico { color: #0a0a0a; } html[data-theme="ink"] .zmw-mem-bar { height: 10px; border: 2px solid #0a0a0a; border-radius: 3px; background: #ffffff; } html[data-theme="ink"] .zmw-mem-bar i { border-radius: 0; background: #0a9cff; } html[data-theme="ink"] .zmw-mem-mid .zmw-mem-bar i { background: #ffc21a; } html[data-theme="ink"] .zmw-mem-hi .zmw-mem-bar i { background: #ff4d3d; } html[data-theme="ink"] .zmw-icon-btn, html[data-theme="ink"] .zmw-btn-primary { border: 2px solid #0a0a0a; border-radius: 6px; box-shadow: 3px 3px 0 #0a0a0a; background: #ffffff; color: #0a0a0a; transition: transform .08s, box-shadow .08s; } html[data-theme="ink"] .zmw-btn-primary { background: #0a9cff; } html[data-theme="ink"] .zmw-icon-btn:active, html[data-theme="ink"] .zmw-btn-primary:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 #0a0a0a; } html[data-theme="ink"] .zmw-login-card, html[data-theme="ink"] .zmw-modal, html[data-theme="ink"] .zmw-theme-menu { border: 2.5px solid #0a0a0a; border-radius: 8px; box-shadow: 6px 6px 0 #0a0a0a; background: #ffffff; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="ink"] .zmw-theme-opt { border: 2px solid transparent; border-radius: 5px; } html[data-theme="ink"] .zmw-theme-opt.zmw-on { background: #0a9cff; border-color: #0a0a0a; } html[data-theme="ink"] .zmw-theme-mark { color: #0a0a0a; } html[data-theme="ink"] .zmw-input { border: 2px solid #0a0a0a; border-radius: 6px; } html[data-theme="ink"] .zmw-check-box { border: 2px solid #0a0a0a; border-radius: 3px; } html[data-theme="ink"] #zmw-view .zm-card { background: #ffffff; border: 2.5px solid #0a0a0a; border-radius: 6px; box-shadow: 5px 5px 0 #0a0a0a; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="ink"] #zmw-view .zm-card:hover { box-shadow: 5px 5px 0 #0a0a0a; border-color: #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-card h3::before { background: #0a9cff; border: 1.5px solid #0a0a0a; } html[data-theme="ink"] #zmw-view .cbi-button, html[data-theme="ink"] .zm-dock .cbi-button { border: 2px solid #0a0a0a; border-radius: 6px; box-shadow: 3px 3px 0 #0a0a0a; background: #ffffff; color: #0a0a0a; transition: transform .08s, box-shadow .08s; } html[data-theme="ink"] #zmw-view .cbi-button:hover, html[data-theme="ink"] .zm-dock .cbi-button:hover { background: #e8f6ff; transform: none; box-shadow: 3px 3px 0 #0a0a0a; } html[data-theme="ink"] #zmw-view .cbi-button:active, html[data-theme="ink"] .zm-dock .cbi-button:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 #0a0a0a; } html[data-theme="ink"] #zmw-view .cbi-button-positive, html[data-theme="ink"] #zmw-view .cbi-button-positive:hover, html[data-theme="ink"] .zm-dock .cbi-button-positive, html[data-theme="ink"] .zm-dock .cbi-button-positive:hover { background: #0a9cff; color: #0a0a0a; border-color: #0a0a0a; } html[data-theme="ink"] #zmw-view .cbi-button-remove, html[data-theme="ink"] #zmw-view .cbi-button-remove:hover, html[data-theme="ink"] .zm-dock .cbi-button-remove, html[data-theme="ink"] .zm-dock .cbi-button-remove:hover { background: #ff4d3d; color: #0a0a0a; border-color: #0a0a0a; } html[data-theme="ink"] #zmw-view .cbi-button-action, html[data-theme="ink"] #zmw-view .cbi-button-action:hover, html[data-theme="ink"] .zm-dock .cbi-button-action, html[data-theme="ink"] .zm-dock .cbi-button-action:hover { background: #d6f0ff; color: #0a0a0a; } html[data-theme="ink"] #zmw-view .cbi-button[disabled], html[data-theme="ink"] .zm-dock .cbi-button[disabled] { box-shadow: none; opacity: .5; } html[data-theme="ink"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="ink"] .zm-dock .zmw-tabs .cbi-button { box-shadow: none; } html[data-theme="ink"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="ink"] .zm-dock .zmw-tabs .cbi-button-positive { box-shadow: 3px 3px 0 #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-tile { background: #ffffff; border: 2px solid #0a0a0a; border-radius: 6px; box-shadow: 3px 3px 0 #0a0a0a; color: #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-tile:hover { background: #e8f6ff; transform: none; } html[data-theme="ink"] #zmw-view .zm-tile.zm-active { background: #0a9cff; color: #0a0a0a; border-color: #0a0a0a; box-shadow: 3px 3px 0 #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-tile.zm-active::before { color: #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-badge, html[data-theme="ink"] .zm-dock .zm-badge { border: 2px solid #0a0a0a; color: #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-badge .zm-dot, html[data-theme="ink"] .zm-dock .zm-badge .zm-dot { box-shadow: none; animation: none; } html[data-theme="ink"] #zmw-view input[type="text"], html[data-theme="ink"] #zmw-view input[type="password"], html[data-theme="ink"] #zmw-view input[type="number"], html[data-theme="ink"] #zmw-view select, html[data-theme="ink"] #zmw-view textarea:not(.zm-config-editor) { border: 2px solid #0a0a0a; border-radius: 6px; background: #ffffff; } html[data-theme="ink"] #zmw-view .zm-config-editor, html[data-theme="ink"] #zmw-view .zm-log { border: 2.5px solid #0a0a0a; border-radius: 6px; box-shadow: 5px 5px 0 #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-current-banner, html[data-theme="ink"] #zmw-view .zm-refresh-banner { border: 2px solid #0a0a0a; border-radius: 6px; background: #d6f0ff; box-shadow: 3px 3px 0 #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-node, html[data-theme="ink"] #zmw-view .zm-svc, html[data-theme="ink"] #zmw-view .zm-awg-if, html[data-theme="ink"] #zmw-view .zm-st-stat, html[data-theme="ink"] #zmw-view .zm-credit-tile, html[data-theme="ink"] #zmw-view .zm-ab-svc-item { background: #ffffff; border: 2px solid #0a0a0a; border-radius: 6px; box-shadow: 3px 3px 0 #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-node.zm-active, html[data-theme="ink"] #zmw-view .zm-svc.zm-svc-on { background: #0a9cff; border-color: #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-node:hover { transform: none; } html[data-theme="ink"] #zmw-view .zm-seg { border: 2px solid #0a0a0a; border-radius: 6px; background: #ffffff; } html[data-theme="ink"] #zmw-view .zm-seg-item { border-radius: 3px; color: #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-seg-item.zm-active { background: #0a9cff; color: #0a0a0a; box-shadow: none; outline: 2px solid #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-sub-meta { background: #e8f6ff; border: 2px solid #0a0a0a; border-radius: 6px; } html[data-theme="ink"] #zmw-view .zm-quota { border: 2px solid #0a0a0a; height: 10px; background: #ffffff; } html[data-theme="ink"] #zmw-view .zm-quota > i { background: #0a9cff; border-radius: 0; } html[data-theme="ink"] #zmw-view .zm-switch { border: 2px solid #0a0a0a; background: #ffffff; } html[data-theme="ink"] #zmw-view .zm-switch > span { border: 2px solid #0a0a0a; box-shadow: none; top: 1px; left: 1px; } html[data-theme="ink"] #zmw-view .zm-switch.zm-switch-on { background: #0a9cff; } html[data-theme="ink"] #zmw-view a:not(.cbi-button), html[data-theme="ink"] .zm-dock a:not(.cbi-button) { color: #0060c0; text-decoration: underline; text-underline-offset: 2px; } html[data-theme="ink"] .zmw-logo svg { filter: none; } html[data-theme="ink"] .zmw-logo-lg::after { display: none; } html[data-theme="bento"] { --font: "Onest", system-ui, sans-serif; --a1: #1c1a17; --a2: #1c1a17; --a3: #5b554c; --grad: #1c1a17; --grad-soft: #e4ddd0; --ring: 0 0 0 3px rgba(28,26,23,.2); --radius: 28px; --radius-sm: 16px; --bg: #efebe4; --bg-glow-1: transparent; --bg-glow-2: transparent; --surface: #ffffff; --surface-solid: #ffffff; --surface-2: #f6f2ea; --surface-3: #eee8dd; --side-bg: #efebe4; --border: rgba(28,26,23,.06); --border-2: rgba(28,26,23,.12); --text: #1c1a17; --text-2: #3d3830; --muted: #6f685d; --shadow: none; --shadow-lg: 0 24px 60px -24px rgba(28,26,23,.35); --input-bg: #f6f2ea; --ok: #1f5a2c; --ok-bg: #d8f0dc; --ok-dot: #2f7d4f; --bad: #8a1c14; --bad-bg: #ffd9d4; --bad-dot: #d64532; --warn: #7a4a0f; --warn-bg: #ffe2b8; --warn-dot: #d98a1c; --off: #6f685d; --off-bg: #eee8dd; color-scheme: light; } html[data-theme="bento"] body.zmw-body { background-image: none; } html[data-theme="bento"] .zmw-orbs, html[data-theme="bento"] .zmw-grid-bg { display: none; } html[data-theme="bento"] .zmw-top { background: linear-gradient(to bottom, rgba(239,235,228,.96) 40%, rgba(239,235,228,0)); } html[data-theme="bento"] .zmw-title { font-family: "Unbounded", sans-serif; font-weight: 800; letter-spacing: -.03em; } html[data-theme="bento"] .zmw-side { background: transparent; border-right: 0; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="bento"] .zmw-nav-item { border-radius: 999px; } html[data-theme="bento"] .zmw-nav-item:hover { background: #ffffff; } html[data-theme="bento"] .zmw-nav-item.zmw-active { background: #1c1a17; color: #ffffff; } html[data-theme="bento"] .zmw-nav-item.zmw-active::before { display: none; } html[data-theme="bento"] .zmw-nav-item.zmw-active .zmw-nav-ico { color: #ffffff; } html[data-theme="bento"] .zmw-mem-bar { height: 8px; background: #e4ddd0; } html[data-theme="bento"] .zmw-mem-bar i { background: #1c1a17; } html[data-theme="bento"] #zmw-view .zm-card { background: #ffffff; border: 0; border-radius: 30px; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; padding: 26px 28px; } html[data-theme="bento"] #zmw-view .zm-cards > .zm-card:nth-child(4n+2) { background: #dcecff; } html[data-theme="bento"] #zmw-view .zm-cards > .zm-card:nth-child(4n+3) { background: #ffe6c4; } html[data-theme="bento"] #zmw-view .zm-cards > .zm-card:nth-child(4n+4) { background: #dff2e2; } html[data-theme="bento"] #zmw-view .zm-card h3::before { display: none; } html[data-theme="bento"] #zmw-view .zm-card h3 { font-size: 15px; font-weight: 600; color: inherit; opacity: .8; } html[data-theme="bento"] #zmw-view .cbi-button, html[data-theme="bento"] .zmw-icon-btn, html[data-theme="bento"] .zm-dock .cbi-button { border-radius: 14px; border: 0; background: #f6f2ea; color: #1c1a17; box-shadow: none; } html[data-theme="bento"] #zmw-view .cbi-button:hover, html[data-theme="bento"] .zmw-icon-btn:hover, html[data-theme="bento"] .zm-dock .cbi-button:hover { background: #e4ddd0; transform: none; } html[data-theme="bento"] #zmw-view .cbi-button-positive, html[data-theme="bento"] #zmw-view .cbi-button-positive:hover, html[data-theme="bento"] .zmw-btn-primary, html[data-theme="bento"] .zm-dock .cbi-button-positive, html[data-theme="bento"] .zm-dock .cbi-button-positive:hover { background: #1c1a17; color: #ffffff; border: 0; box-shadow: none; } html[data-theme="bento"] #zmw-view .cbi-button-remove, html[data-theme="bento"] #zmw-view .cbi-button-remove:hover, html[data-theme="bento"] .zm-dock .cbi-button-remove, html[data-theme="bento"] .zm-dock .cbi-button-remove:hover { background: #ffd9d4; color: #8a1c14; } html[data-theme="bento"] #zmw-view .zm-tile { border-radius: 18px; border: 0; background: #f6f2ea; } html[data-theme="bento"] #zmw-view .zm-tile.zm-active { background: #1c1a17; color: #ffffff; border: 0; } html[data-theme="bento"] #zmw-view .zm-badge, html[data-theme="bento"] .zm-dock .zm-badge { border-radius: 999px; } html[data-theme="bento"] #zmw-view .zm-log, html[data-theme="bento"] #zmw-view .zm-config-editor { border-radius: 24px; } html[data-theme="bento"] .zmw-login-card, html[data-theme="bento"] .zmw-modal, html[data-theme="bento"] .zmw-theme-menu { border: 0; border-radius: 28px; background: #ffffff; backdrop-filter: none; } html[data-theme="minimal"] { --font: "IBM Plex Sans", system-ui, sans-serif; --a1: #0078e8; --a2: #0078e8; --a3: #0078e8; --grad: #0078e8; --grad-soft: transparent; --ring: 0 0 0 2px rgba(0,120,232,.35); --radius: 0px; --radius-sm: 0px; --bg: #ffffff; --bg-glow-1: transparent; --bg-glow-2: transparent; --surface: #ffffff; --surface-solid: #ffffff; --surface-2: #fafafa; --surface-3: #f0f0f0; --side-bg: #ffffff; --border: #ececec; --border-2: #e0e0e0; --text: #111111; --text-2: #333333; --muted: #6b6b6b; --shadow: none; --shadow-lg: 0 20px 60px -30px rgba(0,0,0,.25); --input-bg: #ffffff; --ok: #111111; --ok-bg: transparent; --ok-dot: #0078e8; --bad: #b3261e; --bad-bg: transparent; --bad-dot: #b3261e; --warn: #111111; --warn-bg: transparent; --warn-dot: #c77700; --off: #6b6b6b; --off-bg: transparent; color-scheme: light; } html[data-theme="minimal"] body.zmw-body { background-image: none; font-weight: 300; } html[data-theme="minimal"] .zmw-orbs, html[data-theme="minimal"] .zmw-grid-bg { display: none; } html[data-theme="minimal"] .zmw-top { background: #ffffff; } html[data-theme="minimal"] .zmw-title { font-weight: 300; font-size: 34px; letter-spacing: -.03em; } html[data-theme="minimal"] .zmw-side { background: #ffffff; border-right: 1px solid #ececec; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="minimal"] .zmw-logo svg { filter: grayscale(1) brightness(.2); } html[data-theme="minimal"] .zmw-nav-item { border-radius: 0; font-weight: 400; color: #6b6b6b; } html[data-theme="minimal"] .zmw-nav-item:hover { background: none; color: #111111; } html[data-theme="minimal"] .zmw-nav-item.zmw-active { background: none; color: #111111; font-weight: 500; } html[data-theme="minimal"] .zmw-nav-item.zmw-active::before { background: #111111; width: 2px; border-radius: 0; } html[data-theme="minimal"] .zmw-nav-ico { opacity: .5; } html[data-theme="minimal"] .zmw-mem-bar { height: 2px; background: #ececec; border-radius: 0; } html[data-theme="minimal"] .zmw-mem-bar i { background: #111111; border-radius: 0; } html[data-theme="minimal"] #zmw-view .zm-card { border: 0; border-top: 1px solid #ececec; border-radius: 0; box-shadow: none; background: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; padding: 28px 0; } html[data-theme="minimal"] #zmw-view .zm-card:hover { box-shadow: none; } html[data-theme="minimal"] #zmw-view .zm-card h3 { font-weight: 400; font-size: 13px; text-transform: uppercase; letter-spacing: .12em; color: #6b6b6b; } html[data-theme="minimal"] #zmw-view .zm-card h3::before { display: none; } html[data-theme="minimal"] #zmw-view .cbi-button, html[data-theme="minimal"] .zmw-icon-btn, html[data-theme="minimal"] .zm-dock .cbi-button { border-radius: 0; border: 1px solid #111111; background: #ffffff; color: #111111; box-shadow: none; font-weight: 400; } html[data-theme="minimal"] #zmw-view .cbi-button:hover, html[data-theme="minimal"] .zmw-icon-btn:hover, html[data-theme="minimal"] .zm-dock .cbi-button:hover { background: #111111; color: #ffffff; transform: none; } html[data-theme="minimal"] #zmw-view .cbi-button-positive, html[data-theme="minimal"] #zmw-view .cbi-button-positive:hover, html[data-theme="minimal"] .zmw-btn-primary, html[data-theme="minimal"] .zm-dock .cbi-button-positive, html[data-theme="minimal"] .zm-dock .cbi-button-positive:hover { background: #111111; color: #ffffff; border: 1px solid #111111; box-shadow: none; } html[data-theme="minimal"] #zmw-view .cbi-button-remove, html[data-theme="minimal"] .zm-dock .cbi-button-remove { border-color: #b3261e; color: #b3261e; background: #ffffff; } html[data-theme="minimal"] #zmw-view .cbi-button-remove:hover, html[data-theme="minimal"] .zm-dock .cbi-button-remove:hover { background: #b3261e; color: #ffffff; } html[data-theme="minimal"] #zmw-view .zm-tile { border-radius: 0; border: 1px solid #e0e0e0; background: #ffffff; font-weight: 400; } html[data-theme="minimal"] #zmw-view .zm-tile.zm-active { border-color: #111111; background: #ffffff; color: #111111; box-shadow: inset 0 0 0 1px #111111; } html[data-theme="minimal"] #zmw-view .zm-tile.zm-active::before { color: #0078e8; } html[data-theme="minimal"] #zmw-view .zm-badge, html[data-theme="minimal"] .zm-dock .zm-badge { padding-left: 0; font-weight: 400; } html[data-theme="minimal"] #zmw-view .zm-badge .zm-dot, html[data-theme="minimal"] .zm-dock .zm-badge .zm-dot { width: 6px; height: 6px; box-shadow: none; animation: none; } html[data-theme="minimal"] #zmw-view .zm-log, html[data-theme="minimal"] #zmw-view .zm-config-editor { border-radius: 0; } html[data-theme="minimal"] .zmw-login-card, html[data-theme="minimal"] .zmw-modal, html[data-theme="minimal"] .zmw-theme-menu { border-radius: 0; border: 1px solid #111111; box-shadow: none; background: #ffffff; backdrop-filter: none; } html[data-theme="minimal"] .zmw-theme-opt { border-radius: 0; } html[data-theme="retro"] { --font: Tahoma, Verdana, "Segoe UI", sans-serif; --a1: #000080; --a2: #000080; --a3: #1084d0; --grad: linear-gradient(90deg, #000080, #1084d0); --grad-soft: #dcdcdc; --ring: 0 0 0 1px #000000; --radius: 0px; --radius-sm: 0px; --bg: #008080; --bg-glow-1: transparent; --bg-glow-2: transparent; --surface: #c0c0c0; --surface-solid: #c0c0c0; --surface-2: #d4d0c8; --surface-3: #ffffff; --side-bg: #c0c0c0; --border: #808080; --border-2: #000000; --text: #000000; --text-2: #000000; --muted: #404040; --shadow: none; --shadow-lg: none; --input-bg: #ffffff; --console: #000000; --console-border: #808080; --ok: #006400; --ok-bg: transparent; --ok-dot: #008000; --bad: #a00000; --bad-bg: transparent; --bad-dot: #ff0000; --warn: #806000; --warn-bg: transparent; --warn-dot: #c0a000; --off: #404040; --off-bg: transparent; color-scheme: light; } html[data-theme="retro"] body.zmw-body { background-image: none; font-size: 13px; } html[data-theme="retro"] .zmw-orbs, html[data-theme="retro"] .zmw-grid-bg { display: none; } html[data-theme="retro"] .zmw-top { background: #008080; } html[data-theme="retro"] .zmw-title, html[data-theme="retro"] .zmw-sub { color: #ffffff; } html[data-theme="retro"] .zmw-title { font-size: 20px; font-weight: 700; letter-spacing: 0; } html[data-theme="retro"] .zmw-side { background: #c0c0c0; border-right: 2px solid #000000; box-shadow: inset -1px 0 0 #808080, inset 1px 1px 0 #ffffff; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="retro"] .zmw-nav-item { border-radius: 0; } html[data-theme="retro"] .zmw-nav-item:hover { background: #d4d0c8; } html[data-theme="retro"] .zmw-nav-item.zmw-active { background: #000080; color: #ffffff; } html[data-theme="retro"] .zmw-nav-item.zmw-active::before { display: none; } html[data-theme="retro"] .zmw-nav-item.zmw-active .zmw-nav-ico { color: #ffffff; } html[data-theme="retro"] .zmw-mem-bar { height: 16px; border-radius: 0; background: #ffffff; border-top: 1px solid #808080; border-left: 1px solid #808080; border-right: 1px solid #ffffff; border-bottom: 1px solid #ffffff; padding: 2px; box-sizing: border-box; } html[data-theme="retro"] .zmw-mem-bar i { border-radius: 0; background: repeating-linear-gradient(90deg, #000080 0 8px, transparent 8px 10px); } html[data-theme="retro"] .zmw-mem-mid .zmw-mem-bar i, html[data-theme="retro"] .zmw-mem-hi .zmw-mem-bar i { background: repeating-linear-gradient(90deg, #a00000 0 8px, transparent 8px 10px); } html[data-theme="retro"] #zmw-view .zm-card { background: #c0c0c0; border-radius: 0; backdrop-filter: none; -webkit-backdrop-filter: none; border-top: 2px solid #ffffff; border-left: 2px solid #ffffff; border-right: 2px solid #000000; border-bottom: 2px solid #000000; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; } html[data-theme="retro"] #zmw-view .zm-card { padding: 0 14px 14px; } html[data-theme="retro"] #zmw-view .zm-card h3 { margin: 3px -11px 14px; padding: 5px 8px; background: linear-gradient(90deg, #000080, #1084d0); color: #ffffff; font-size: 13px; font-weight: 700; } html[data-theme="retro"] #zmw-view .zm-card h3::before { display: none; } html[data-theme="retro"] #zmw-view .cbi-button, html[data-theme="retro"] .zmw-icon-btn, html[data-theme="retro"] .zmw-btn-primary, html[data-theme="retro"] .zm-dock .cbi-button { border-radius: 0; background: #c0c0c0; color: #000000; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; border-top: 2px solid #ffffff; border-left: 2px solid #ffffff; border-right: 2px solid #000000; border-bottom: 2px solid #000000; font-weight: 400; } html[data-theme="retro"] #zmw-view .cbi-button:hover, html[data-theme="retro"] .zmw-icon-btn:hover, html[data-theme="retro"] .zm-dock .cbi-button:hover { background: #c0c0c0; transform: none; } html[data-theme="retro"] #zmw-view .cbi-button:active, html[data-theme="retro"] .zmw-icon-btn:active, html[data-theme="retro"] .zm-dock .cbi-button:active { border-top-color: #000000; border-left-color: #000000; border-right-color: #ffffff; border-bottom-color: #ffffff; } html[data-theme="retro"] #zmw-view .cbi-button-positive, html[data-theme="retro"] #zmw-view .cbi-button-positive:hover, html[data-theme="retro"] .zm-dock .cbi-button-positive, html[data-theme="retro"] .zm-dock .cbi-button-positive:hover { background: #c0c0c0; color: #000000; font-weight: 700; outline: 1px dotted #000000; outline-offset: -5px; } html[data-theme="retro"] #zmw-view .cbi-button-remove, html[data-theme="retro"] #zmw-view .cbi-button-remove:hover, html[data-theme="retro"] .zm-dock .cbi-button-remove, html[data-theme="retro"] .zm-dock .cbi-button-remove:hover { background: #c0c0c0; color: #a00000; } html[data-theme="retro"] #zmw-view .zm-tile { border-radius: 0; background: #ffffff; border-top: 2px solid #808080; border-left: 2px solid #808080; border-right: 2px solid #ffffff; border-bottom: 2px solid #ffffff; } html[data-theme="retro"] #zmw-view .zm-tile.zm-active { background: #000080; color: #ffffff; } html[data-theme="retro"] #zmw-view .zm-badge, html[data-theme="retro"] .zm-dock .zm-badge { border-radius: 0; padding-left: 0; } html[data-theme="retro"] #zmw-view .zm-badge .zm-dot, html[data-theme="retro"] .zm-dock .zm-badge .zm-dot { box-shadow: none; animation: none; } html[data-theme="retro"] #zmw-view input, html[data-theme="retro"] #zmw-view select, html[data-theme="retro"] #zmw-view textarea:not(.zm-config-editor), html[data-theme="retro"] .zmw-input { border-radius: 0; background: #ffffff; border-top: 2px solid #808080; border-left: 2px solid #808080; border-right: 2px solid #ffffff; border-bottom: 2px solid #ffffff; } html[data-theme="retro"] #zmw-view .zm-log, html[data-theme="retro"] #zmw-view .zm-config-editor { border-radius: 0; } html[data-theme="retro"] .zmw-login-card, html[data-theme="retro"] .zmw-modal, html[data-theme="retro"] .zmw-theme-menu { border-radius: 0; background: #c0c0c0; backdrop-filter: none; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; border-top: 2px solid #ffffff; border-left: 2px solid #ffffff; border-right: 2px solid #000000; border-bottom: 2px solid #000000; } html[data-theme="retro"] .zmw-theme-opt { border-radius: 0; } html[data-theme="retro"] .zmw-theme-opt:hover, html[data-theme="retro"] .zmw-theme-opt.zmw-on { background: #000080; color: #ffffff; } html[data-theme="retro"] .zmw-foot { color: #ffffff; } html[data-theme="depth"] { --font: "Onest", system-ui, sans-serif; --a1: #00b6ff; --a2: #0090ff; --a3: #7fdcff; --grad: linear-gradient(180deg, #33c6ff, #0090ff); --grad-soft: rgba(0,182,255,.14); --ring: 0 0 0 3px rgba(0,182,255,.4); --radius: 20px; --radius-sm: 14px; --bg: #0a0e18; --bg-glow-1: rgba(0,182,255,.18); --bg-glow-2: rgba(255,140,60,.10); --surface: #16203a; --surface-solid: #16203a; --surface-2: rgba(127,220,255,.06); --surface-3: rgba(127,220,255,.12); --side-bg: rgba(10,14,24,.85); --border: rgba(127,220,255,.16); --border-2: rgba(127,220,255,.3); --text: #e8eefc; --text-2: #c7d3ea; --muted: #9fb0cc; --shadow: 0 1px 0 rgba(255,255,255,.08) inset, 0 10px 0 -2px #0c1426, 0 28px 50px -12px rgba(0,0,0,.8); --shadow-lg: 0 40px 90px -30px rgba(0,0,0,.9); --input-bg: rgba(255,255,255,.05); --console: #060910; --console-border: rgba(127,220,255,.2); --ok: #5ef0a8; --ok-bg: rgba(94,240,168,.12); --ok-dot: #5ef0a8; --bad: #ff7b7b; --bad-bg: rgba(255,123,123,.12); --bad-dot: #ff7b7b; --warn: #ffc48a; --warn-bg: rgba(255,196,138,.12); --warn-dot: #ff9a4d; --off: #9fb0cc; --off-bg: rgba(159,176,204,.12); color-scheme: dark; } html[data-theme="depth"] .zmw-title { font-family: "Unbounded", sans-serif; font-weight: 800; } html[data-theme="depth"] .zmw-top { background: linear-gradient(to bottom, rgba(10,14,24,.94) 40%, rgba(10,14,24,0)); } html[data-theme="depth"] #zmw-view { perspective: 1600px; } html[data-theme="depth"] #zmw-view .zm-card { background: linear-gradient(160deg, #1b2740, #121a2c); border: 1px solid rgba(127,220,255,.2); transform: translateZ(0); transition: transform .35s cubic-bezier(.2,.8,.2,1), box-shadow .35s; } html[data-theme="depth"] #zmw-view .zm-card:hover { transform: rotateX(4deg) translateY(-4px); box-shadow: 0 1px 0 rgba(255,255,255,.1) inset, 0 14px 0 -2px #0c1426, 0 40px 60px -14px rgba(0,0,0,.85), 0 0 40px -10px rgba(0,182,255,.35); border-color: rgba(127,220,255,.4); } html[data-theme="depth"] #zmw-view .cbi-button, html[data-theme="depth"] .zmw-icon-btn, html[data-theme="depth"] .zm-dock .cbi-button { background: #1e2b48; border: 1px solid rgba(127,220,255,.25); color: #e8eefc; box-shadow: 0 4px 0 #0c1426, 0 8px 16px -6px rgba(0,0,0,.7); transition: transform .08s, box-shadow .08s; } html[data-theme="depth"] #zmw-view .cbi-button:hover, html[data-theme="depth"] .zmw-icon-btn:hover, html[data-theme="depth"] .zm-dock .cbi-button:hover { background: #253559; transform: translateY(-1px); } html[data-theme="depth"] #zmw-view .cbi-button:active, html[data-theme="depth"] .zmw-icon-btn:active, html[data-theme="depth"] .zm-dock .cbi-button:active { transform: translateY(4px); box-shadow: 0 0 0 #0c1426; } html[data-theme="depth"] #zmw-view .cbi-button-positive, html[data-theme="depth"] #zmw-view .cbi-button-positive:hover, html[data-theme="depth"] .zmw-btn-primary, html[data-theme="depth"] .zm-dock .cbi-button-positive, html[data-theme="depth"] .zm-dock .cbi-button-positive:hover { background: linear-gradient(180deg, #33c6ff, #0090ff); color: #04111f; border-color: #0090ff; box-shadow: 0 4px 0 #005a9e, 0 10px 24px -8px rgba(0,182,255,.8); } html[data-theme="depth"] #zmw-view .cbi-button-remove, html[data-theme="depth"] #zmw-view .cbi-button-remove:hover, html[data-theme="depth"] .zm-dock .cbi-button-remove, html[data-theme="depth"] .zm-dock .cbi-button-remove:hover { background: linear-gradient(180deg, #ff8a7a, #e84a3a); color: #1a0503; border-color: #e84a3a; box-shadow: 0 4px 0 #8f2419; } html[data-theme="depth"] #zmw-view .zm-tile { background: #1e2b48; border: 1px solid rgba(127,220,255,.2); box-shadow: 0 4px 0 #0c1426; transition: transform .15s, box-shadow .15s; } html[data-theme="depth"] #zmw-view .zm-tile:hover { transform: translateY(-2px); box-shadow: 0 6px 0 #0c1426, 0 14px 24px -10px rgba(0,182,255,.5); } html[data-theme="depth"] #zmw-view .zm-tile.zm-active { background: linear-gradient(180deg, #33c6ff, #0090ff); color: #04111f; border-color: #0090ff; box-shadow: 0 4px 0 #005a9e; } html[data-theme="depth"] .zmw-side { box-shadow: 10px 0 40px -20px rgba(0,0,0,.8); } html[data-theme="depth"] .zmw-nav-item.zmw-active { background: linear-gradient(180deg, rgba(0,182,255,.25), rgba(0,144,255,.12)); box-shadow: 0 3px 0 #0c1426, inset 0 1px 0 rgba(255,255,255,.08); } html[data-theme="depth"] .zmw-mem-bar { background: #0c1426; box-shadow: inset 0 2px 3px rgba(0,0,0,.6); } html[data-theme="depth"] .zmw-mem-bar i { background: linear-gradient(180deg, #7fdcff, #0090ff); box-shadow: 0 0 10px rgba(0,182,255,.6); } html[data-theme="depth"] .zmw-login-card, html[data-theme="depth"] .zmw-modal, html[data-theme="depth"] .zmw-theme-menu { background: linear-gradient(160deg, #1b2740, #121a2c); border: 1px solid rgba(127,220,255,.25); } .zmw-theme-menu { max-height: calc(100vh - 90px); overflow-y: auto; } html[data-theme="bento"] #zmw-view .zm-tile.zm-active::before { color: #9fe0b0; } html[data-theme="bento"] .zmw-theme-opt.zmw-on { background: #1c1a17; color: #ffffff; } html[data-theme="bento"] .zmw-theme-opt.zmw-on .zmw-theme-mark { color: #9fe0b0; } html[data-theme="minimal"] .zmw-theme-opt.zmw-on { background: #f3f3f3; } html[data-theme="retro"] #zmw-view .zm-tile.zm-active::before { color: #ffff00; } html[data-theme="retro"] .zmw-theme-opt:hover .zmw-theme-mark, html[data-theme="retro"] .zmw-theme-opt.zmw-on .zmw-theme-mark { color: #ffff00; } html[data-theme="retro"] .zmw-nav-item { padding-left: 12px; } html[data-theme="retro"] .zmw-link-btn, html[data-theme="retro"] .zmw-link-btn:hover { color: #000000; } html[data-theme="depth"] #zmw-view .zm-tile.zm-active::before { color: #04111f; } html[data-theme="depth"] .zmw-theme-mark { color: #7fdcff; } html[data-theme="ink"] #zmw-view .zm-node.zm-active .zm-node-name::before, html[data-theme="ink"] #zmw-view .zm-node.zm-active .zm-lat { color: #0a0a0a; } html[data-theme="bento"] #zmw-view .zm-seg-item.zm-active, html[data-theme="retro"] #zmw-view .zm-seg-item.zm-active { color: #ffffff; } #zmw-view .zm-credit-tile.zm-credit-all { grid-column: 1 / -1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } .zmw-theme-menu { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 4px; width: 340px; max-width: calc(100vw - 16px); } .zmw-theme-group { grid-column: 1 / -1; padding: 8px 10px 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); } .zmw-theme-group:first-child { padding-top: 4px; } .zmw-theme-opt { min-width: 0; } .zmw-theme-opt > span:nth-of-type(1) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } html[data-theme="micro"] { --font: "Onest", system-ui, sans-serif; --a1: #2d5bff; --a2: #2d5bff; --a3: #2d5bff; --grad: #2d5bff; --grad-soft: #e6ecff; --ring: 0 0 0 3px #2d5bff55; --radius: 20px; --radius-sm: 20px; --bg: #f4f5f8; --bg-glow-1: transparent; --bg-glow-2: transparent; --surface: #ffffff; --surface-solid: #ffffff; --surface-2: #f7f8fb; --surface-3: #eceff5; --side-bg: #ffffff; --border: #e3e6ee; --border-2: #e3e6ee; --text: #151a26; --text-2: #151a26; --muted: #5a6377; --shadow: 0 1px 2px rgba(0,0,0,.04); --shadow-lg: 0 30px 70px -30px rgba(0,0,0,.45); --input-bg: #f7f8fb; --ok: #11643e; --ok-bg: #dcf5e9; --ok-dot: #11643e; --warn: #7a4d00; --warn-bg: #fff1d6; --warn-dot: #7a4d00; --bad: #9a1f1f; --bad-bg: #ffe1e1; --bad-dot: #9a1f1f; --off: #4a5266; --off-bg: #eceff5; color-scheme: light; } html[data-theme="micro"] body.zmw-body { background: #f4f5f8; } html[data-theme="micro"] .zmw-orbs, html[data-theme="micro"] .zmw-grid-bg { display: none; } html[data-theme="micro"] .zmw-top { background: linear-gradient(to bottom, #f4f5f8 55%, transparent); } html[data-theme="micro"] .zmw-title { font-family: "Onest", sans-serif; color: #151a26; } html[data-theme="micro"] .zmw-sub, html[data-theme="micro"] .zmw-foot { color: #5a6377; } html[data-theme="micro"] .zmw-side { background: #ffffff; border-right: 1px solid #e3e6ee; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; color: #151a26; } html[data-theme="micro"] .zmw-brand-name, html[data-theme="micro"] .zmw-mem-row { color: #151a26; } html[data-theme="micro"] .zmw-nav-group, html[data-theme="micro"] .zmw-brand-sub, html[data-theme="micro"] .zmw-mem-label, html[data-theme="micro"] .zmw-side-links a { color: #5d6679; } html[data-theme="micro"] .zmw-nav-item { color: #151a26; border-radius: 12px; } html[data-theme="micro"] .zmw-nav-item:hover { background: #f0f3fa; color: #151a26; } html[data-theme="micro"] .zmw-nav-item.zmw-active { background: #2d5bff; color: #ffffff; box-shadow: none; } html[data-theme="micro"] .zmw-nav-item.zmw-active::before { display: none; } html[data-theme="micro"] .zmw-nav-item.zmw-active .zmw-nav-ico { color: #ffffff; } html[data-theme="micro"] .zmw-mem-bar { background: #e3e6ee; border-radius: 6px; } html[data-theme="micro"] .zmw-mem-bar i { background: #2d5bff; border-radius: inherit; } html[data-theme="micro"] #zmw-view .zm-card { background: #ffffff; border: 1px solid #e3e6ee; border-radius: 20px; box-shadow: 0 1px 2px rgba(0,0,0,.04); backdrop-filter: none; -webkit-backdrop-filter: none; color: #151a26; } html[data-theme="micro"] #zmw-view .zm-card:hover { border-color: #e3e6ee; } html[data-theme="micro"] #zmw-view .zm-card h3 { color: #151a26; } html[data-theme="micro"] #zmw-view .zm-card h3::before { background: #2d5bff; } html[data-theme="micro"] #zmw-view .zm-hint, html[data-theme="micro"] #zmw-view p.zm-hint, html[data-theme="micro"] #zmw-view .zm-label, html[data-theme="micro"] .zm-dock .zm-hint, html[data-theme="micro"] .zm-dock p.zm-hint { color: #5a6377; opacity: 1; } html[data-theme="micro"] #zmw-view a:not(.cbi-button), html[data-theme="micro"] .zm-dock a:not(.cbi-button) { color: #2d5bff; } html[data-theme="micro"] #zmw-view .cbi-button, html[data-theme="micro"] .zmw-icon-btn, html[data-theme="micro"] .zm-dock .cbi-button { background: #f7f8fb; color: #151a26; border: 1px solid #e3e6ee; border-radius: 14px; box-shadow: 0 1px 2px rgba(0,0,0,.06); } html[data-theme="micro"] #zmw-view .cbi-button:hover, html[data-theme="micro"] .zmw-icon-btn:hover, html[data-theme="micro"] .zm-dock .cbi-button:hover { background: #eceff5; color: #151a26; } html[data-theme="micro"] #zmw-view .cbi-button-positive, html[data-theme="micro"] #zmw-view .cbi-button-positive:hover, html[data-theme="micro"] .zmw-btn-primary, html[data-theme="micro"] .zm-dock .cbi-button-positive, html[data-theme="micro"] .zm-dock .cbi-button-positive:hover { background: #2d5bff; color: #ffffff; border-color: #2d5bff; } html[data-theme="micro"] #zmw-view .cbi-button-remove, html[data-theme="micro"] #zmw-view .cbi-button-remove:hover, html[data-theme="micro"] .zm-dock .cbi-button-remove, html[data-theme="micro"] .zm-dock .cbi-button-remove:hover { background: #ffe1e1; color: #9a1f1f; border-color: #ffe1e1; } html[data-theme="micro"] #zmw-view .cbi-button-action, html[data-theme="micro"] #zmw-view .cbi-button-action:hover, html[data-theme="micro"] .zm-dock .cbi-button-action, html[data-theme="micro"] .zm-dock .cbi-button-action:hover { background: #e6ecff; color: #151a26; } html[data-theme="micro"] #zmw-view .zm-tile:not(.zm-active):not(.zm-tile-off), html[data-theme="micro"] #zmw-view .zm-tile { background: #f7f8fb; color: #151a26; border: 1px solid #e3e6ee; border-radius: 14px; } html[data-theme="micro"] #zmw-view .zm-tile.zm-active { background: #2d5bff; color: #ffffff; border-color: #2d5bff; } html[data-theme="micro"] #zmw-view .zm-tile.zm-active::before { color: #ffffff; } html[data-theme="micro"] #zmw-view .zm-seg-item { color: #151a26; } html[data-theme="micro"] #zmw-view .zm-seg-item.zm-active { background: #2d5bff; color: #ffffff; } html[data-theme="micro"] #zmw-view .zm-node.zm-active { background: #e6ecff; border-color: #2d5bff; } html[data-theme="micro"] #zmw-view .zm-node.zm-active .zm-node-name, html[data-theme="micro"] #zmw-view .zm-node.zm-active .zm-node-name::before { color: #151a26; } html[data-theme="micro"] #zmw-view .zm-badge, html[data-theme="micro"] .zm-dock .zm-badge { border-radius: 999px; } html[data-theme="micro"] #zmw-view input, html[data-theme="micro"] #zmw-view select, html[data-theme="micro"] #zmw-view textarea:not(.zm-config-editor), html[data-theme="micro"] .zmw-input { background: #f7f8fb; color: #151a26; border: 1px solid #e3e6ee; border-radius: 14px; } html[data-theme="micro"] #zmw-view .zm-current-banner, html[data-theme="micro"] #zmw-view .zm-refresh-banner { background: #e6ecff; color: #151a26; border-color: #2d5bff; } html[data-theme="micro"] #zmw-view .zm-credit-tile, html.zm-theme-dark[data-theme="micro"] #zmw-view .zm-credit-tile { background: #f7f8fb; border-color: #e3e6ee; color: #151a26; } html[data-theme="micro"] #zmw-view .zm-credit-tile.zm-credit-self { background: #e6ecff; } html[data-theme="micro"] #zmw-view .zm-switch.zm-switch-on { background: #2d5bff; } html[data-theme="micro"] .zmw-check input:checked + .zmw-check-box { background: #2d5bff; } html[data-theme="micro"] .zmw-check input:checked + .zmw-check-box::after { border-color: #ffffff; } html[data-theme="micro"] .zmw-login-card, html[data-theme="micro"] .zmw-modal, html[data-theme="micro"] .zmw-theme-menu { background: #ffffff; color: #151a26; border: 1px solid #e3e6ee; backdrop-filter: none; -webkit-backdrop-filter: none; } html[data-theme="micro"] .zmw-theme-opt { color: #151a26; } html[data-theme="micro"] .zmw-theme-opt:hover { background: #f7f8fb; } html[data-theme="micro"] .zmw-theme-opt.zmw-on { background: #2d5bff; color: #ffffff; } html[data-theme="micro"] .zmw-theme-opt.zmw-on .zmw-theme-mark, html[data-theme="micro"] .zmw-theme-opt.zmw-on .zmw-i { color: #ffffff; } html[data-theme="micro"] .zmw-theme-group { color: #5a6377; } html[data-theme="micro"] #zmw-view .cbi-button, html[data-theme="micro"] .zmw-icon-btn, html[data-theme="micro"] .zmw-btn-primary, html[data-theme="micro"] .zm-dock .cbi-button { transition: transform .12s, box-shadow .12s, background .2s, border-radius .25s; } html[data-theme="micro"] #zmw-view .cbi-button:hover, html[data-theme="micro"] .zmw-icon-btn:hover, html[data-theme="micro"] .zm-dock .cbi-button:hover { transform: scale(1.03); } html[data-theme="micro"] #zmw-view .cbi-button:active, html[data-theme="micro"] .zmw-icon-btn:active, html[data-theme="micro"] .zmw-btn-primary:active, html[data-theme="micro"] .zm-dock .cbi-button:active { transform: scale(.95); box-shadow: none; } html[data-theme="micro"] #zmw-view .cbi-button-positive:hover, html[data-theme="micro"] .zmw-btn-primary:hover, html[data-theme="micro"] .zm-dock .cbi-button-positive:hover { border-radius: 22px; box-shadow: 0 12px 24px -10px rgba(45,91,255,.7); } html[data-theme="micro"] #zmw-view .zm-tile { transition: transform .2s cubic-bezier(.2,.8,.2,1), box-shadow .2s; } html[data-theme="micro"] #zmw-view .zm-tile:hover { transform: translateY(-2px); box-shadow: 0 10px 18px -12px rgba(45,91,255,.6); } html[data-theme="micro"] #zmw-view .zm-badge .zm-dot, html[data-theme="micro"] .zm-dock .zm-badge .zm-dot { animation: zmw-micro-ring 2s infinite; } @keyframes zmw-micro-ring { 0% { box-shadow: 0 0 0 0 rgba(30,180,110,.45); } 70% { box-shadow: 0 0 0 7px rgba(30,180,110,0); } 100% { box-shadow: 0 0 0 0 rgba(30,180,110,0); } } html[data-theme="micro"] .zmw-nav-item { transition: background .2s, transform .15s; } html[data-theme="micro"] .zmw-nav-item:active { transform: scale(.97); } html[data-theme="ink"] #zmw-view .zm-actions.zmw-tabs { background: #ffffff; border: 2.5px solid #0a0a0a; border-radius: 6px; box-shadow: 4px 4px 0 #0a0a0a; backdrop-filter: none; } html[data-theme="ink"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="ink"] .zm-dock .zmw-tabs .cbi-button { background: transparent; border: 2px solid transparent; border-radius: 4px; box-shadow: none; color: #0a0a0a; transform: none; } html[data-theme="ink"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="ink"] .zm-dock .zmw-tabs .cbi-button:hover { background: #e8f6ff; border-color: transparent; box-shadow: none; } html[data-theme="ink"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="ink"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="ink"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="ink"] .zm-dock .zmw-tabs .cbi-button-positive:hover { background: #0a9cff; border-color: #0a0a0a; color: #0a0a0a; box-shadow: none; } html[data-theme="bento"] #zmw-view .zm-actions.zmw-tabs { background: #ffffff; border: 0; border-radius: 999px; box-shadow: none; backdrop-filter: none; } html[data-theme="bento"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="bento"] .zm-dock .zmw-tabs .cbi-button { background: transparent; border: 0; border-radius: 999px; color: #6f685d; box-shadow: none; } html[data-theme="bento"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="bento"] .zm-dock .zmw-tabs .cbi-button:hover { background: #f6f2ea; color: #1c1a17; } html[data-theme="bento"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="bento"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="bento"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="bento"] .zm-dock .zmw-tabs .cbi-button-positive:hover { background: #1c1a17; color: #ffffff; } html[data-theme="minimal"] #zmw-view .zm-actions.zmw-tabs { background: transparent; border: 0; border-bottom: 1px solid #e0e0e0; border-radius: 0; box-shadow: none; padding: 0; gap: 24px; backdrop-filter: none; } html[data-theme="minimal"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="minimal"] .zm-dock .zmw-tabs .cbi-button { background: transparent; border: 0; border-bottom: 2px solid transparent; border-radius: 0; color: #6b6b6b; padding: 10px 0; box-shadow: none; font-weight: 400; } html[data-theme="minimal"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="minimal"] .zm-dock .zmw-tabs .cbi-button:hover { background: transparent; color: #111111; } html[data-theme="minimal"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="minimal"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="minimal"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="minimal"] .zm-dock .zmw-tabs .cbi-button-positive:hover { background: transparent; color: #111111; border-bottom-color: #111111; font-weight: 500; } html[data-theme="retro"] #zmw-view .zm-actions.zmw-tabs { background: transparent; border: 0; border-bottom: 2px solid #ffffff; border-radius: 0; box-shadow: none; padding: 0 0 0 4px; gap: 2px; align-items: flex-end; backdrop-filter: none; } html[data-theme="retro"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="retro"] .zm-dock .zmw-tabs .cbi-button { background: #c0c0c0; color: #000000; border-radius: 0; border-top: 2px solid #ffffff; border-left: 2px solid #ffffff; border-right: 2px solid #000000; border-bottom: 0; box-shadow: inset -1px 0 0 #808080; min-height: 30px; padding: 5px 16px; margin: 0; outline: none; font-weight: 400; } html[data-theme="retro"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="retro"] .zm-dock .zmw-tabs .cbi-button:hover { background: #c0c0c0; color: #000000; } html[data-theme="retro"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="retro"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="retro"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="retro"] .zm-dock .zmw-tabs .cbi-button-positive:hover { background: #c0c0c0; color: #000000; font-weight: 700; min-height: 34px; outline: none; margin-bottom: -2px; padding-bottom: 7px; } html[data-theme="depth"] #zmw-view .zm-actions.zmw-tabs { background: #16203a; border: 1px solid rgba(127,220,255,.2); box-shadow: 0 6px 0 -2px #0c1426; } html[data-theme="depth"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="depth"] .zm-dock .zmw-tabs .cbi-button { background: transparent; border: 0; box-shadow: none; color: #9fb0cc; } html[data-theme="depth"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="depth"] .zm-dock .zmw-tabs .cbi-button:hover { background: rgba(127,220,255,.08); color: #e8eefc; transform: none; } html[data-theme="depth"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="depth"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="depth"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="depth"] .zm-dock .zmw-tabs .cbi-button-positive:hover { background: linear-gradient(180deg, #33c6ff, #0090ff); color: #04111f; box-shadow: 0 3px 0 #005a9e; } html[data-theme="micro"] #zmw-view .zm-actions.zmw-tabs { background: #ffffff; border: 1px solid #e3e6ee; border-radius: 16px; box-shadow: 0 1px 2px rgba(0,0,0,.04); backdrop-filter: none; } html[data-theme="micro"] #zmw-view .zmw-tabs .cbi-button, html[data-theme="micro"] .zm-dock .zmw-tabs .cbi-button { background: transparent; border: 0; border-radius: 11px; color: #5a6377; box-shadow: none; transform: none; } html[data-theme="micro"] #zmw-view .zmw-tabs .cbi-button:hover, html[data-theme="micro"] .zm-dock .zmw-tabs .cbi-button:hover { background: #f0f3fa; color: #151a26; transform: none; } html[data-theme="micro"] #zmw-view .zmw-tabs .cbi-button-positive, html[data-theme="micro"] #zmw-view .zmw-tabs .cbi-button-positive:hover, html[data-theme="micro"] .zm-dock .zmw-tabs .cbi-button-positive, html[data-theme="micro"] .zm-dock .zmw-tabs .cbi-button-positive:hover { background: #2d5bff; color: #ffffff; box-shadow: 0 6px 14px -8px rgba(45,91,255,.9); border-radius: 11px; } html[data-theme="micro"] #zmw-view .zmw-tabs .cbi-button:active, html[data-theme="micro"] .zm-dock .zmw-tabs .cbi-button:active { transform: scale(.96); } html[data-theme="retro"] #zmw-view .zm-current-banner { background: #ffffe1; border: 1px solid #000000; border-radius: 0; color: #000000; } html[data-theme="micro"] .zmw-icon-btn.zmw-link-kvn { background: #3C3489; color: #ffffff; border-color: #3C3489; box-shadow: 0 6px 16px -10px rgba(60,52,137,.9); } html[data-theme="micro"] .zmw-icon-btn.zmw-link-kvn:hover { background: #463d9c; color: #ffffff; border-color: #463d9c; box-shadow: 0 8px 20px -10px rgba(60,52,137,1); } html[data-theme="micro"] .zmw-icon-btn.zmw-link-tg, html[data-theme="micro"] .zmw-icon-btn.zmw-link-tg:hover { background: #e8f4fc; color: #1b6fa8; border-color: #bfdff3; } html[data-theme="ink"] .zmw-icon-btn.zmw-link-kvn, html[data-theme="ink"] .zmw-icon-btn.zmw-link-kvn:hover { background: #0a9cff; color: #0a0a0a; } html[data-theme="ink"] .zmw-icon-btn.zmw-link-tg, html[data-theme="ink"] .zmw-icon-btn.zmw-link-tg:hover { background: #d6f0ff; color: #0a0a0a; } html[data-theme="bento"] .zmw-icon-btn.zmw-link-kvn, html[data-theme="bento"] .zmw-icon-btn.zmw-link-kvn:hover { background: #1c1a17; color: #ffffff; } html[data-theme="bento"] .zmw-icon-btn.zmw-link-tg, html[data-theme="bento"] .zmw-icon-btn.zmw-link-tg:hover { background: #dcecff; color: #1f4f82; } html[data-theme="minimal"] .zmw-icon-btn.zmw-link-kvn, html[data-theme="minimal"] .zmw-icon-btn.zmw-link-kvn:hover { background: #111111; color: #ffffff; border-color: #111111; } html[data-theme="minimal"] .zmw-icon-btn.zmw-link-tg, html[data-theme="minimal"] .zmw-icon-btn.zmw-link-tg:hover { background: #ffffff; color: #0060bb; border-color: #0060bb; } html[data-theme="retro"] .zmw-icon-btn.zmw-link-kvn, html[data-theme="retro"] .zmw-icon-btn.zmw-link-kvn:hover { color: #000080; font-weight: 700; } html[data-theme="retro"] .zmw-icon-btn.zmw-link-tg, html[data-theme="retro"] .zmw-icon-btn.zmw-link-tg:hover { color: #000080; } html[data-theme="depth"] .zmw-icon-btn.zmw-link-kvn, html[data-theme="depth"] .zmw-icon-btn.zmw-link-kvn:hover { background: linear-gradient(180deg, #33c6ff, #0090ff); color: #04111f; border-color: #0090ff; } html[data-theme="depth"] .zmw-icon-btn.zmw-link-tg, html[data-theme="depth"] .zmw-icon-btn.zmw-link-tg:hover { background: rgba(127,220,255,.12); color: #7fdcff; } #zmw-view .zm-st-stat-sub { color: var(--muted); opacity: 1; } #zmw-view .zm-log-wait { color: #fcd34d; } #zmw-view .zm-tt-tile, #zmw-view .zm-tt-row, html.zm-theme-dark #zmw-view .zm-tt-tile, html.zm-theme-dark #zmw-view .zm-tt-row { background: var(--surface-2); border-color: var(--border); } #zmw-view .zm-tt-tile { border-radius: 14px; } #zmw-view .zm-tt-tile.zm-tt-active { border-color: var(--accent, #2d5bff); box-shadow: 0 0 0 1px var(--accent, #2d5bff) inset; } #zmw-view .zm-tt-name, #zmw-view .zm-tt-head b { color: var(--text); } #zmw-view .zm-tt-row-head:hover { background: var(--surface-3, rgba(45,91,255,.06)); } #zmw-view .zm-tt-bar { background: var(--border); } #zmw-view .zm-si-box, html.zm-theme-dark #zmw-view .zm-si-box { background: var(--surface-2); border-color: var(--border); border-radius: 14px; } #zmw-view .zm-si-head h4 { color: var(--text); } #zmw-view .zm-chip { background: var(--surface-3, rgba(110,118,129,.14)); color: var(--text); } #zmw-view .zm-chip-ok { background: rgba(46,160,67,.14); color: #16a34a; } #zmw-view .zm-chip-bad { background: rgba(220,38,38,.12); color: #dc2626; } #zmw-view .zm-si-pre { color: var(--text); background: transparent; border: 0; padding: 0; } .zmw-alerts { display: flex; flex-direction: column; gap: 10px; } .zmw-alerts:not(:empty) { margin-bottom: 16px; } .zmw-alerts .zm-alerts { gap: 10px; } html[data-theme="ink"] #zmw-view .zm-switch.zm-switch-on > span { left: 17px; } html[data-theme="ink"] #zmw-view .zm-svc.zm-svc-on .zm-switch.zm-switch-on { background: #0a0a0a; } html[data-theme="ink"] #zmw-view .zm-svc.zm-svc-on .zm-svc-sub { color: #0a0a0a; opacity: .75; } html[data-theme="retro"] #zmw-view .zm-refresh-banner { background: #ffffe1; border: 1px solid #000000; border-radius: 0; color: #000000; box-shadow: none; }#zmw-view .zm-log-step { color: #a9b4c7; } #zmw-view .zm-log-step-ok { color: #4ade80; } #zmw-view .zm-log-step-bad { color: #fb7185; } #zmw-view .zm-savebar, .zm-dock .zm-savebar { background: var(--surface-solid); border: 1px solid rgba(124,92,255,.45); border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); color: var(--text); } #zmw-view .zm-savebar-dot, .zm-dock .zm-savebar-dot { background: var(--warn-dot); box-shadow: 0 0 0 4px var(--warn-bg); } .zm-stopbar { font-family: var(--font); } html[data-theme="dark"] .zm-stopbar, html[data-theme="depth"] .zm-stopbar { background: #2a1416; color: #fecaca; border-color: rgba(248,113,113,.5); } #zmw-view .zm-ri-note { background: var(--grad-soft); border-color: rgba(124,92,255,.3); color: var(--text); } #zmw-view .zm-swrow-label { color: var(--text); } #zmw-view .zm-svc.zm-svc-inc { border-style: dashed; } #zmw-view .zm-svc.zm-svc-taken { opacity: .5; cursor: not-allowed; } #zmw-view .zm-sb-left, #zmw-view .zm-lists-now { background: var(--surface-2); border-color: var(--border); border-radius: var(--radius-sm); color: var(--text); } #zmw-view .zm-sb-left { background: var(--bad-bg); border-color: rgba(239,68,68,.3); } .zm-stopbar .cbi-button.zm-stopbar-btn { padding: 10px 18px; border-radius: 12px; font-size: 14px; line-height: 1.2; box-shadow: 0 8px 20px -10px rgba(220,38,38,.8); } @media (min-width: 961px) { html body.zmw-body .zm-dock { left: calc(var(--side-w) + (100vw - var(--side-w)) / 2); width: min(860px, calc(100vw - var(--side-w) - 48px)); } } .zm-dock .zm-savebar { background: var(--surface-solid); border: 1px solid rgba(124,92,255,.45); border-radius: var(--radius-sm); color: var(--text); box-shadow: var(--shadow-lg); } .zm-dock .zm-savebar-dot { background: var(--warn-dot); box-shadow: 0 0 0 4px var(--warn-bg); } .zm-dock .cbi-button { font-family: var(--font); } #zmw-view .zm-dns-item { background: var(--surface-2); border-color: var(--border); border-radius: var(--radius-sm); } #zmw-view .zm-dns-item.zm-dns-active { background: var(--ok-bg); border-color: rgba(16,185,129,.4); } #zmw-view .zm-dns-num { background: var(--grad); } #zmw-view .zm-dns-text b { color: var(--text); } #zmw-view .zm-dns-text span { color: var(--muted); opacity: 1; } #zmw-view .zm-dns-btns .cbi-button { padding: 6px 10px; min-width: 36px; } #zmw-view .zm-hide-grid .zm-node.zm-hide-on { opacity: .62; border-style: dashed; border-color: rgba(239,68,68,.7); background: rgba(239,68,68,.08); box-shadow: none; } #zmw-view .zm-hide-box { border-color: rgba(245,158,11,.5); background: rgba(245,158,11,.07); } #zmw-view .zm-hide-chip { background: var(--surface-2); border: 1px solid var(--border); } #zmw-view .zm-hide-grid .zm-node.zm-hide-on .zm-hide-mark { color: #ef4444; } #zmw-view .zm-link-row { background: var(--surface-2); border-color: var(--border); } #zmw-view .zm-route-res { background: var(--surface-2); border-top-color: var(--border); border-right-color: var(--border); border-bottom-color: var(--border); } #zmw-view .zm-route-chip { border-color: var(--border); color: var(--text); } #zmw-view .zm-route-k { color: var(--muted); opacity: 1; } #zmw-view .zm-links-ed .zm-config-editor { margin: 0; } #zmw-view .zm-links-ed .zm-links-tools { margin: 8px 0 0; } #zmw-view .zm-link-sub { color: var(--muted); opacity: 1; } #zmw-view .zm-link-num { background: var(--surface-3, rgba(127,127,127,.15)); color: var(--text-2, inherit); } #zmw-view .zm-hide-quick { background: var(--surface-2); border-color: var(--border); } #zmw-view .zm-hide-q { border-color: var(--border); color: var(--text); } #zmw-view .zm-hide-q.zm-hide-q-on { border-color: rgba(239,68,68,.7); background: rgba(239,68,68,.1); color: #ef4444; } ZM_INSTALLER_EOF cat > '/www/zm-webui.html' << 'ZM_INSTALLER_EOF' Zapret Manager
ZM_INSTALLER_EOF ZMW_BUILD="$(date +%s)" sed -i "s/__ZMW_BUILD__/$ZMW_BUILD/g" /www/zm/app.js /www/zm/app.css /www/zm-webui.html chmod 0644 /www/zm/app.js /www/zm/app.css /www/zm-webui.html mkdir -p /etc/crontabs 2>/dev/null || true [ -f /etc/crontabs/root ] || : > /etc/crontabs/root 2>/dev/null || true sed -i '/# zm-rpcd-watch$/d' /etc/crontabs/root 2>/dev/null || true echo '*/2 * * * * /opt/zapret-manager-luci/backend.sh zm_watch >/dev/null 2>&1 # zm-rpcd-watch' >> /etc/crontabs/root 2>/dev/null || true if [ -x /etc/init.d/cron ]; then /etc/init.d/cron enable >/dev/null 2>&1 || true /etc/init.d/cron restart >/dev/null 2>&1 || true fi if [ -s /opt/zapret-manager-luci/state/ui.theme ]; then cp -f /opt/zapret-manager-luci/state/ui.theme /www/zm/theme.txt 2>/dev/null || true chmod 0644 /www/zm/theme.txt 2>/dev/null || true fi /opt/zapret-manager-luci/backend.sh redbtn_panel_gone >/dev/null 2>&1 || true rm -f /opt/zapret-manager-luci/state/steer.vpnprobe 2>/dev/null || true ZMW_RESTART=0 if [ ! -e /usr/lib/uhttpd_ubus.so ]; then echo -e "${CYAN}Устанавливаем ${NC}uhttpd-mod-ubus${CYAN} для Web UI${NC}" $INSTALL uhttpd-mod-ubus >&2 || { $PM update >&2; $INSTALL uhttpd-mod-ubus >&2; } || true ZMW_RESTART=1 fi if ! uci -q get uhttpd.zmweb >/dev/null; then uci set uhttpd.zmweb=uhttpd uci add_list uhttpd.zmweb.listen_http='0.0.0.0:7788' uci add_list uhttpd.zmweb.listen_http='[::]:7788' uci set uhttpd.zmweb.home='/www' uci set uhttpd.zmweb.index_page='zm-webui.html' uci set uhttpd.zmweb.ubus_prefix='/ubus' uci set uhttpd.zmweb.no_dirlists='1' uci set uhttpd.zmweb.rfc1918_filter='1' uci set uhttpd.zmweb.max_requests='12' uci set uhttpd.zmweb.max_connections='100' uci set uhttpd.zmweb.script_timeout='120' uci set uhttpd.zmweb.network_timeout='30' uci set uhttpd.zmweb.http_keepalive='20' uci set uhttpd.zmweb.tcp_keepalive='1' uci commit uhttpd ZMW_RESTART=1 fi [ "$ZMW_RESTART" = "1" ] && { /etc/init.d/uhttpd restart >/dev/null 2>&1 || true; } ZMW_PORT="$(uci -q get uhttpd.zmweb.listen_http | tr ' ' '\n' | head -n1 | sed 's/.*://')" ZMW_IP="$(/opt/zapret-manager-luci/backend.sh lan_ip 2>/dev/null || true)" [ -n "$ZMW_IP" ] || ZMW_IP="192.168.1.1" echo -e "Zapret Manager ${GREEN}для ${NC}LuCI ${GREEN}установлен!${NC}" echo -e "\n${CYAN}Web UI: ${NC}http://${ZMW_IP}:${ZMW_PORT:-7788}${NC}\n" echo "sh <(wget -q -O - https://raw.githubusercontent.com/StressOzz/Zapret-Manager/main/Zapret-Manager.sh)" > /usr/bin/zms; chmod +x /usr/bin/zms